Skip to main content

hatchery_c2_client/
runtime.rs

1use hatchery_core::{BrowserClientCommand, BrowserStreamEvent, 
2    c2_auth_transcript, c2_bound_auth_transcript, provider_id_is_legacy, C2AuthDirection, C2ClientAuthentication, C2ClientFrame,
3    C2ClientHello, C2Hello, C2NodeEvent, C2NodeResponse, C2RelayFailure,
4    C2RelayFailureCode,
5    C2RequestEnvelope, C2RequestId, C2ServerFrame, C2Topology, CapabilityId,
6    ClientCompatibilityOffer, NegotiatedC2ControlCompatibility, NodeRequest, NodeRoute,
7    RoutedNodeEvent, RoutedNodeRequest, RoutedNodeResponse,
8    BUILD_STAMP, C2_COMPATIBILITY_METADATA_CAPABILITY,
9    C2_OPAQUE_UNIX_PATH_CAPABILITY, C2_REPOSITORY_PATH_CAPABILITY,
10    C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
11    C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
12    C2_HISTORY_CONTEXT_PACK_CAPABILITY,
13    C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14    C2_NATIVE_SESSION_CATALOG_CAPABILITY,
15    C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
16    C2_NATIVE_SESSION_INDEX_CAPABILITY, C2_NATIVE_SESSION_PREVIEW_CAPABILITY,
17    C2_HOST_DIRECTORY_BROWSE_CAPABILITY,
18    C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
19    C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
20    C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
21    C2_CONTROL_DETAIL_CAPABILITY, C2_SESSION_TASK_CORRELATION_CAPABILITY,
22    C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
23    C2_HARNESS_MCP_READ_PROXY_CAPABILITY,
24    C2_PROVIDER_ID_OPEN_CAPABILITY,
25    C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
26    C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
27    C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, C2_PROVIDER_RUNTIME_STATUS_CAPABILITY,
28    C2_SPAWN_PROFILE_REVISION_CAPABILITY,
29    C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
30    C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
31    C2_AGENT_STREAM_EVENTS_CAPABILITY,
32    C2_ACP_CONTROL_CAPABILITY,
33    C2_GIT_READ_CAPABILITY, C2_WORKSPACE_FILE_READ_CAPABILITY,
34    C2_WORKSPACE_FILE_WRITE_CAPABILITY,
35    C2_WORKSPACE_ENTRY_CREATE_CAPABILITY,
36    C2_WORKTREE_SELECTION_CAPABILITY,
37    C2_AUTH_NONCE_BYTES, MAX_C2_AUTH_FRAME_BYTES, MAX_C2_CLIENT_FRAME_BYTES, MAX_C2_HELLO_FRAME_BYTES,
38    MAX_C2_SERVER_FRAME_BYTES,
39};
40use gate4agent_node_protocol::{
41    read_json_frame_limited_body_timeout, write_json_frame_limited, FrameError,
42};
43use gate4agent_node_wire::{
44    connect_local_stream, local_hmac_sha256, proofs_match, random_nonce,
45};
46use std::collections::BTreeMap;
47use std::io;
48use std::sync::Arc;
49use std::time::{Duration, SystemTime, UNIX_EPOCH};
50use thiserror::Error;
51use tokio::sync::{mpsc, oneshot, watch};
52use tokio::time::{timeout, timeout_at, Instant};
53
54const AUTH_DEADLINE: Duration = Duration::from_secs(5);
55const HELLO_DEADLINE: Duration = Duration::from_secs(10);
56const FRAME_BODY_DEADLINE: Duration = Duration::from_secs(5);
57const RELAY_REPLY_HEADROOM: Duration = Duration::from_secs(5);
58const CONTROLLER_ACQUIRE_RELAY_DEADLINE: Duration = Duration::from_secs(5);
59const WORKSPACE_ENTRY_CREATE_RELAY_DEADLINE: Duration = Duration::from_secs(10);
60const NATIVE_SESSION_RELAY_DEADLINE: Duration = Duration::from_secs(35);
61const COMMAND_CAPACITY: usize = 64;
62const INBOUND_CAPACITY: usize = 2;
63const WRITER_CAPACITY: usize = 64;
64/// Buffer for the primary node-event stream (control, terminal, and
65/// negotiated agent-stream text deltas). Sized so
66/// normal live-session bursts buffer instead of tearing the connection
67/// down; teardown (`RegularEventBackpressure`) stays the last resort for a
68/// consumer that is genuinely stuck, not merely bursty.
69pub(crate) const EVENT_CAPACITY: usize = 1024;
70pub(crate) const HARNESS_MCP_EVENT_CAPACITY: usize = 128;
71const OPAQUE_UNIX_PATH_NOT_NEGOTIATED: &str =
72    "opaque Unix paths require negotiated C2 capability";
73const REPOSITORY_PATH_NOT_NEGOTIATED: &str =
74    "tagged repository paths require negotiated C2 capability";
75const WORKSPACE_FILE_READ_NOT_NEGOTIATED: &str =
76    "workspace file reads require negotiated C2 capability";
77const WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED: &str =
78    "workspace entry creation requires negotiated C2 capability";
79const OPEN_PROVIDER_ID_NOT_NEGOTIATED: &str =
80    "open provider IDs require negotiated C2 capability";
81const SPAWN_SPEC_NOT_NEGOTIATED: &str =
82    "spawn spec defaults/overrides require negotiated C2 capability";
83const SPAWN_PROFILE_REVISION_NOT_NEGOTIATED: &str =
84    "spawn profile revisions require negotiated C2 capability";
85const TERMINAL_FRAME_EVENTS_NOT_NEGOTIATED: &str =
86    "terminal frame events require negotiated C2 capability";
87const AGENT_STREAM_EVENTS_NOT_NEGOTIATED: &str =
88    "agent stream events require negotiated C2 capability";
89const ACP_CONTROL_NOT_NEGOTIATED: &str =
90    "ACP control verbs require negotiated C2 capability";
91const WORKTREE_SELECTION_NOT_NEGOTIATED: &str =
92    "worktree selection requires negotiated C2 capability";
93const MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED: &str =
94    "managed worktree lifecycle requires negotiated C2 capability";
95const MANAGED_WORKTREE_SPAWN_V2_NOT_NEGOTIATED: &str =
96    "managed worktree V2 spawn requires negotiated C2 capability";
97const CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED: &str =
98    "child environment profiles require negotiated C2 capability";
99const SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED: &str =
100    "session bundle materialization requires negotiated C2 capability";
101const HISTORY_CONTEXT_PACK_NOT_NEGOTIATED: &str =
102    "history context packs require negotiated C2 capability";
103const SESSION_RECORD_CONTEXT_EXPORT_NOT_NEGOTIATED: &str =
104    "session-record context export requires negotiated C2 capability";
105const HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED: &str =
106    "host directory browsing requires negotiated C2 capability";
107const STANDALONE_WORKSPACE_LIFECYCLE_NOT_NEGOTIATED: &str =
108    "standalone workspace lifecycle requires negotiated C2 capability";
109const PROVIDER_SESSION_REFERENCE_INDEX_NOT_NEGOTIATED: &str =
110    "provider session reference index requires negotiated C2 capability";
111const INVALID_HISTORY_CONTEXT_PACK_REQUEST: &str =
112    "invalid history context pack request";
113const SESSION_TASK_CORRELATION_NOT_NEGOTIATED: &str =
114    "session task correlation requires negotiated C2 capability";
115const CONTROL_DETAIL_NOT_NEGOTIATED: &str =
116    "control detail requires negotiated C2 capability";
117
118#[derive(Clone, Copy, Default)]
119struct NegotiatedPathCapabilities {
120    opaque_host_paths: bool,
121    repository_paths: bool,
122    workspace_file_read: bool,
123    workspace_file_write: bool,
124    workspace_entry_create: bool,
125    git_read: bool,
126    host_directory_browse: bool,
127    standalone_workspace_lifecycle: bool,
128    provider_session_reference_index: bool,
129    provider_ids_open: bool,
130    spawn_spec_defaults_overrides: bool,
131    spawn_profile_revision: bool,
132    worktree_selection: bool,
133    managed_worktree_lifecycle: bool,
134    managed_worktree_spawn_v2: bool,
135    child_environment_profile: bool,
136    session_bundle_materialization: bool,
137    history_context_pack: bool,
138    session_record_context_export: bool,
139    native_session_catalog: bool,
140    native_session_catalog_paging: bool,
141    native_session_index: bool,
142    native_session_preview: bool,
143    terminal_frame_events: bool,
144    agent_stream_events: bool,
145    agent_progress_snapshot: bool,
146    session_task_correlation: bool,
147    control_detail: bool,
148    delivery_bundle_v2_stage_commit: bool,
149    harness_mcp_read_proxy: bool,
150    acp_control: bool,
151}
152
153#[derive(Clone)]
154pub struct C2ControlHandle {
155    commands: mpsc::Sender<ControlCommand>,
156    browser_commands: mpsc::Sender<BrowserControlCommand>,
157    hello: Arc<C2Hello>,
158    topology: watch::Receiver<Arc<C2Topology>>,
159    terminal_frame_events: bool,
160}
161
162struct BrowserControlCommand {
163    cmd: BrowserClientCommand,
164    reply: oneshot::Sender<Result<(), C2ControlError>>,
165}
166
167impl C2ControlHandle {
168    pub fn hello(&self) -> &C2Hello { &self.hello }
169
170    pub fn current_topology(&self) -> Arc<C2Topology> { Arc::clone(&*self.topology.borrow()) }
171
172    pub fn subscribe_topology(&self) -> watch::Receiver<Arc<C2Topology>> {
173        self.topology.clone()
174    }
175
176    pub fn terminal_frame_events_enabled(&self) -> bool { self.terminal_frame_events }
177
178    pub async fn request(
179        &self,
180        route: NodeRoute,
181        request: NodeRequest,
182    ) -> Result<RoutedNodeResponse, C2ControlError> {
183        self.request_with_correlation(route, request, None).await
184    }
185
186    /// Like [`Self::request`], attaching an opaque HQ correlation id to the
187    /// operator-wire envelope (logged on core; echoed on the reply).
188    pub async fn request_with_correlation(
189        &self,
190        route: NodeRoute,
191        request: NodeRequest,
192        correlation_id: Option<String>,
193    ) -> Result<RoutedNodeResponse, C2ControlError> {
194        reject_unnegotiated_outbound_path(
195            &request,
196            negotiated_path_capabilities(self.hello.compatibility.as_ref()),
197        )?;
198        let deadline = control_request_deadline(&request);
199        let (reply_tx, reply_rx) = oneshot::channel();
200        timeout(deadline, async {
201            self.commands.send(ControlCommand {
202                route,
203                request,
204                correlation_id,
205                reply: reply_tx,
206            })
207                .await.map_err(|_| C2ControlError::Closed)?;
208            reply_rx.await.map_err(|_| C2ControlError::Closed)?
209        }).await.map_err(|_| C2ControlError::Closed)?
210    }
211
212    /// Validates and synchronously enqueues one typed request. The returned
213    /// non-cloneable waiter cannot enqueue or replay the request again.
214    /// Start / drive / stop a dig2browser session on a node (A2 restream sidecar).
215    pub async fn browser_command(
216        &self,
217        cmd: BrowserClientCommand,
218    ) -> Result<(), C2ControlError> {
219        let (reply_tx, reply_rx) = oneshot::channel();
220        timeout(Duration::from_secs(60), async {
221            self.browser_commands
222                .send(BrowserControlCommand { cmd, reply: reply_tx })
223                .await
224                .map_err(|_| C2ControlError::Closed)?;
225            reply_rx.await.map_err(|_| C2ControlError::Closed)?
226        })
227        .await
228        .map_err(|_| C2ControlError::Closed)?
229    }
230
231    pub fn start_request(
232        &self,
233        route: NodeRoute,
234        request: NodeRequest,
235    ) -> Result<C2PendingRequest, C2ControlError> {
236        start_control_request(
237            &self.commands,
238            negotiated_path_capabilities(self.hello.compatibility.as_ref()),
239            route,
240            request,
241        )
242    }
243}
244
245pub struct C2PendingRequest {
246    reply: oneshot::Receiver<Result<RoutedNodeResponse, C2ControlError>>,
247    deadline: Instant,
248}
249
250impl C2PendingRequest {
251    pub async fn finish(self) -> Result<RoutedNodeResponse, C2ControlError> {
252        timeout_at(self.deadline, self.reply)
253            .await
254            .map_err(|_| C2ControlError::Closed)?
255            .map_err(|_| C2ControlError::Closed)?
256    }
257}
258
259fn start_control_request(
260    commands: &mpsc::Sender<ControlCommand>,
261    capabilities: NegotiatedPathCapabilities,
262    route: NodeRoute,
263    request: NodeRequest,
264) -> Result<C2PendingRequest, C2ControlError> {
265        reject_unnegotiated_outbound_path(
266            &request,
267            capabilities,
268        )?;
269        let deadline_at = Instant::now() + control_request_deadline(&request);
270        let (reply_tx, reply_rx) = oneshot::channel();
271        commands.try_send(ControlCommand { route, request, correlation_id: None, reply: reply_tx })
272            .map_err(|error| match error {
273                mpsc::error::TrySendError::Full(_) => C2ControlError::QueueFull,
274                mpsc::error::TrySendError::Closed(_) => C2ControlError::Closed,
275            })?;
276        Ok(C2PendingRequest {
277            reply: reply_rx,
278            deadline: deadline_at,
279        })
280}
281
282fn control_request_deadline(request: &NodeRequest) -> Duration {
283    let relay_deadline = match request {
284        NodeRequest::Snapshot
285        | NodeRequest::Resync { .. }
286        | NodeRequest::ArmHarnessMcpReservation { .. }
287        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
288        | NodeRequest::ActivateHarnessMcpReservation { .. }
289        | NodeRequest::AbortHarnessMcpReservation { .. }
290        | NodeRequest::PutHarnessMcpReplyChunk { .. }
291        | NodeRequest::RejectHarnessMcpCall { .. }
292        | NodeRequest::BeginDeliveryStage { .. }
293        | NodeRequest::PutDeliveryBlobChunk { .. }
294        | NodeRequest::CommitDeliveryStage { .. }
295        | NodeRequest::AbortDeliveryStage { .. }
296        | NodeRequest::BrowseHostDirectories { .. }
297        | NodeRequest::InspectWorkspace { .. }
298        | NodeRequest::ReadWorkspaceFile { .. }
299        | NodeRequest::WriteWorkspaceFile { .. }
300        | NodeRequest::ReadGitHistory { .. }
301        | NodeRequest::ReadGitDiff { .. }
302        | NodeRequest::AcquireController { .. }
303        | NodeRequest::ReleaseController
304        | NodeRequest::RenameSessionRecord { .. }
305        | NodeRequest::SetSessionTask { .. }
306        | NodeRequest::ForgetSessionRecord { .. } => Duration::from_secs(5),
307        NodeRequest::CreateWorkspaceFile { .. }
308        | NodeRequest::CreateWorkspaceDirectory { .. } => {
309            CONTROLLER_ACQUIRE_RELAY_DEADLINE
310                .saturating_add(WORKSPACE_ENTRY_CREATE_RELAY_DEADLINE)
311        }
312        NodeRequest::CatalogNativeSessions { .. }
313        | NodeRequest::PageNativeSessions { .. } => NATIVE_SESSION_RELAY_DEADLINE,
314        NodeRequest::PreviewNativeSession { .. }
315        | NodeRequest::IndexNativeSession { .. }
316        | NodeRequest::PreviewSessionRecord { .. } => NATIVE_SESSION_RELAY_DEADLINE,
317        NodeRequest::ExportContextPackForSessionRecord { .. } => Duration::from_secs(45),
318        NodeRequest::CreateWorktree { .. }
319        | NodeRequest::CreateStandaloneWorkspace { .. }
320        | NodeRequest::RemoveWorktree { .. }
321        | NodeRequest::CleanupManagedWorktree { .. } => Duration::from_secs(240),
322        // HQ PTY spawn over restream can exceed 20s under load (controller
323        // acquire + bridge queue + Claude/Codex cold start). 15+5 headroom
324        // timed out the owner path at ~20s while the node still accepted.
325        NodeRequest::Spawn { .. }
326        | NodeRequest::SpawnWithOptions { .. }
327        | NodeRequest::Resume { .. }
328        | NodeRequest::ResumeSessionRecord { .. } => Duration::from_secs(90),
329        NodeRequest::Stop { .. } => Duration::from_secs(15),
330        NodeRequest::SpawnSpec { spec } =>
331            Duration::from_millis(spec.deadline_ms.get()) + RELAY_REPLY_HEADROOM,
332        NodeRequest::SpawnManagedWorktree { request } =>
333            Duration::from_millis(request.spawn_spec.deadline_ms.get())
334                + RELAY_REPLY_HEADROOM,
335        NodeRequest::SpawnManagedWorktreeV2 { request } =>
336            Duration::from_millis(request.spawn_spec.deadline_ms.get())
337                + RELAY_REPLY_HEADROOM,
338        NodeRequest::ResumeSessionRecord { .. } => Duration::from_secs(35),
339        _ => Duration::from_secs(10),
340    };
341    relay_deadline + RELAY_REPLY_HEADROOM
342}
343
344pub struct C2EventReceiver {
345    events: mpsc::Receiver<RoutedNodeEvent>,
346    harness_mcp_events: mpsc::Receiver<RoutedNodeEvent>,
347    browser_events: mpsc::Receiver<BrowserStreamEvent>,
348}
349
350impl C2EventReceiver {
351    pub async fn recv(&mut self) -> Option<RoutedNodeEvent> {
352        loop {
353            if self.harness_mcp_events.is_closed() && self.harness_mcp_events.is_empty() {
354                return self.events.recv().await;
355            }
356            if self.events.is_closed() && self.events.is_empty() {
357                return self.harness_mcp_events.recv().await;
358            }
359            tokio::select! {
360                biased;
361                event = self.harness_mcp_events.recv() => {
362                    if event.is_some() { return event; }
363                }
364                event = self.events.recv() => {
365                    if event.is_some() { return event; }
366                }
367            }
368        }
369    }
370
371    /// Splits into two independent mutable borrows, one per channel, so a
372    /// caller can race both in a single `tokio::select!` (the reconnect
373    /// bridge's own `pump_one_connection`). Two separate `&mut self` async
374    /// accessor methods (one per channel) cannot be used together in one
375    /// `select!`: the future each returns borrows the whole receiver for
376    /// its lifetime, and the borrow checker rejects two such futures
377    /// coexisting even though the methods would only touch disjoint
378    /// fields. Splitting once, up front, hands out two genuinely disjoint
379    /// borrows instead.
380    pub(crate) fn split_mut(
381        &mut self,
382    ) -> (
383        &mut mpsc::Receiver<RoutedNodeEvent>,
384        &mut mpsc::Receiver<RoutedNodeEvent>,
385        &mut mpsc::Receiver<BrowserStreamEvent>,
386    ) {
387        (
388            &mut self.events,
389            &mut self.harness_mcp_events,
390            &mut self.browser_events,
391        )
392    }
393}
394
395struct EventDelivery {
396    regular: mpsc::Sender<RoutedNodeEvent>,
397    harness_mcp: mpsc::Sender<RoutedNodeEvent>,
398    browser: mpsc::Sender<BrowserStreamEvent>,
399}
400
401impl From<mpsc::Sender<RoutedNodeEvent>> for EventDelivery {
402    fn from(regular: mpsc::Sender<RoutedNodeEvent>) -> Self {
403        let (browser, _) = mpsc::channel(1);
404        Self { harness_mcp: regular.clone(), regular, browser }
405    }
406}
407
408struct ControlCommand {
409    route: NodeRoute,
410    request: NodeRequest,
411    correlation_id: Option<String>,
412    reply: oneshot::Sender<Result<RoutedNodeResponse, C2ControlError>>,
413}
414
415/// Recovers a `BuildStampMismatch` relay rejection's [`C2ControlError::
416/// BuildStampMismatch`] shape from the frame `gate4agent-c2`'s own
417/// pre-handshake refusal actually sends. `C2RelayFailureCode::
418/// BuildStampMismatch` itself carries nothing -- both stamps travel only in
419/// `failure.message` (`"build stamp mismatch: local=<s> remote=<s>"`,
420/// produced by this exact tree's own `gate4agent-c2`), so the peer's stamp
421/// is recovered by parsing that self-authored, fixed-format text rather
422/// than inventing a second wire shape for the same two values. Any other
423/// code, or a `BuildStampMismatch` whose message does not parse (never
424/// happens against a peer built from this tree, but never trusted blindly
425/// either), falls back to the generic [`C2ControlError::Relay`].
426fn map_relay_rejection(failure: C2RelayFailure) -> C2ControlError {
427    if failure.code != C2RelayFailureCode::BuildStampMismatch {
428        return C2ControlError::Relay(failure);
429    }
430    match failure.message.split_whitespace().find_map(|token| token.strip_prefix("remote=")) {
431        Some(remote) => C2ControlError::BuildStampMismatch {
432            local: BUILD_STAMP.to_owned(),
433            remote: remote.to_owned(),
434        },
435        None => C2ControlError::Relay(failure),
436    }
437}
438
439enum OwnerInput {
440    Frame(C2ServerFrame),
441    Closed,
442}
443
444pub async fn connect_local(
445    endpoint: &str,
446    token: &str,
447) -> Result<(C2ControlHandle, C2EventReceiver), C2ControlError> {
448    validate_endpoint(endpoint)?;
449    validate_token(token)?;
450    let pipe = connect_local_stream(endpoint).await?;
451    connect_control_stream(pipe, token).await
452}
453
454/// Dials the C2 control frame protocol over TCP.
455///
456/// This is the HQ path after the kernel WireGuard tunnel is up: `address`
457/// is `c2_tunnel_ip:control_port`, not a public endpoint and not a node.
458/// The unix validator ([`validate_endpoint`]) does not accept this form.
459pub async fn connect_tunnel_tcp(
460    address: std::net::SocketAddr,
461    token: &str,
462) -> Result<(C2ControlHandle, C2EventReceiver), C2ControlError> {
463    validate_tunnel_control_target(address)?;
464    validate_token(token)?;
465    let stream = tokio::net::TcpStream::connect(address).await?;
466    let _ = stream.set_nodelay(true);
467    connect_control_stream(stream, token).await
468}
469
470async fn connect_control_stream<S>(
471    mut pipe: S,
472    token: &str,
473) -> Result<(C2ControlHandle, C2EventReceiver), C2ControlError>
474where
475    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
476{
477    let client_nonce = random_nonce().map_err(C2ControlError::Authentication)?;
478    let compatibility_offer = client_compatibility_offer()?;
479    timeout(AUTH_DEADLINE, write_json_frame_limited(
480        &mut pipe,
481        &C2ClientFrame::Hello(C2ClientHello::negotiating(
482            client_nonce,
483            compatibility_offer.clone(),
484        )),
485        MAX_C2_AUTH_FRAME_BYTES,
486    )).await.map_err(|_| C2ControlError::AuthenticationTimedOut)??;
487    let challenge = timeout(AUTH_DEADLINE, read_server_frame(&mut pipe, MAX_C2_AUTH_FRAME_BYTES))
488        .await.map_err(|_| C2ControlError::AuthenticationTimedOut)??;
489    let challenge = match challenge {
490        C2ServerFrame::Challenge(challenge) => challenge,
491        C2ServerFrame::Rejected(failure) => return Err(map_relay_rejection(failure)),
492        _ => return Err(C2ControlError::Protocol(
493            "C2 did not return an authentication challenge".to_owned(),
494        )),
495    };
496    if challenge.build_stamp != BUILD_STAMP {
497        return Err(C2ControlError::BuildStampMismatch {
498            local: BUILD_STAMP.to_owned(),
499            remote: challenge.build_stamp.clone(),
500        });
501    }
502    let selected = challenge.compatibility.as_ref().ok_or_else(|| {
503        C2ControlError::Protocol(
504            "C2 omitted the required authenticated compatibility selection".to_owned(),
505        )
506    })?;
507    validate_selected_compatibility(
508        &compatibility_offer,
509        Some(selected),
510    )?;
511    let expected_server = c2_proof(
512        token,
513        C2AuthDirection::Server,
514        &client_nonce,
515        &challenge.server_nonce,
516        Some((&compatibility_offer, selected)),
517    )?;
518    if !proofs_match(&challenge.server_proof, &expected_server) {
519        return Err(C2ControlError::Authentication("C2 server proof mismatch".to_owned()));
520    }
521    let client_proof = c2_proof(
522        token,
523        C2AuthDirection::Client,
524        &client_nonce,
525        &challenge.server_nonce,
526        Some((&compatibility_offer, selected)),
527    )?;
528    timeout(AUTH_DEADLINE, write_json_frame_limited(
529        &mut pipe,
530        &C2ClientFrame::Authenticate(C2ClientAuthentication { client_proof }),
531        MAX_C2_AUTH_FRAME_BYTES,
532    )).await.map_err(|_| C2ControlError::AuthenticationTimedOut)??;
533    let hello = timeout(HELLO_DEADLINE, read_server_frame(&mut pipe, MAX_C2_HELLO_FRAME_BYTES))
534        .await.map_err(|_| C2ControlError::AuthenticationTimedOut)??;
535    let hello = match hello {
536        C2ServerFrame::Hello(hello) if hello.build_stamp == BUILD_STAMP => hello,
537        C2ServerFrame::Rejected(failure) =>
538            return Err(map_relay_rejection(failure)),
539        C2ServerFrame::Hello(hello) =>
540            return Err(C2ControlError::BuildStampMismatch {
541                local: BUILD_STAMP.to_owned(),
542                remote: hello.build_stamp.clone(),
543            }),
544        _ => return Err(C2ControlError::Protocol("C2 did not return hello".to_owned())),
545    };
546    if hello.compatibility.is_none() {
547        return Err(C2ControlError::Protocol(
548            "C2 omitted the authenticated compatibility selection from hello".to_owned(),
549        ));
550    }
551    validate_selected_compatibility(&compatibility_offer, hello.compatibility.as_ref())?;
552    if hello.compatibility != challenge.compatibility {
553        return Err(C2ControlError::Protocol(
554            "C2 compatibility selection changed after authentication".to_owned(),
555        ));
556    }
557    let path_capabilities = negotiated_path_capabilities(hello.compatibility.as_ref());
558    if !path_capabilities.provider_ids_open && status_has_open_provider_id(&hello.status) {
559        return Err(C2ControlError::Protocol(
560            OPEN_PROVIDER_ID_NOT_NEGOTIATED.to_owned(),
561        ));
562    }
563    if !path_capabilities.managed_worktree_lifecycle
564        && status_has_managed_worktree(&hello.status)
565    {
566        return Err(C2ControlError::Protocol(
567            MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED.to_owned(),
568        ));
569    }
570    if !path_capabilities.worktree_selection
571        && status_has_managed_worktree(&hello.status)
572    {
573        return Err(C2ControlError::Protocol(
574            WORKTREE_SELECTION_NOT_NEGOTIATED.to_owned(),
575        ));
576    }
577    if !path_capabilities.child_environment_profile
578        && status_has_child_environment_profile(&hello.status)
579    {
580        return Err(C2ControlError::Protocol(
581            CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED.to_owned(),
582        ));
583    }
584    if !path_capabilities.session_bundle_materialization
585        && status_has_session_bundle_materialization(&hello.status)
586    {
587        return Err(C2ControlError::Protocol(
588            SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED.to_owned(),
589        ));
590    }
591
592    let (reader, writer) = tokio::io::split(pipe);
593    let (commands_tx, commands_rx) = mpsc::channel(COMMAND_CAPACITY);
594    let (browser_commands_tx, browser_commands_rx) = mpsc::channel(16);
595    let (events_tx, events_rx) = mpsc::channel(EVENT_CAPACITY);
596    let (browser_events_tx, browser_events_rx) = mpsc::channel::<BrowserStreamEvent>(64);
597    let (harness_mcp_events_tx, harness_mcp_events_rx) =
598        mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
599    let initial_topology = Arc::new(C2Topology::from_status(&hello.status));
600    let (topology_tx, topology_rx) = watch::channel(initial_topology);
601    let (writer_tx, writer_rx) = mpsc::channel(WRITER_CAPACITY);
602    let (owner_tx, owner_rx) = mpsc::channel(INBOUND_CAPACITY);
603    let reader_task = tokio::spawn(control_reader(reader, owner_tx.clone()));
604    let writer_task = tokio::spawn(control_writer(writer, writer_rx, owner_tx));
605    tokio::spawn(async move {
606        control_owner(
607            commands_rx,
608            browser_commands_rx,
609            EventDelivery { regular: events_tx, harness_mcp: harness_mcp_events_tx, browser: browser_events_tx },
610            topology_tx,
611            writer_tx,
612            owner_rx,
613            path_capabilities,
614        ).await;
615        reader_task.abort();
616        writer_task.abort();
617    });
618    Ok((C2ControlHandle {
619        commands: commands_tx,
620        browser_commands: browser_commands_tx,
621        hello: Arc::new(hello),
622        topology: topology_rx,
623        terminal_frame_events: path_capabilities.terminal_frame_events,
624    }, C2EventReceiver {
625        events: events_rx,
626        harness_mcp_events: harness_mcp_events_rx,
627        browser_events: browser_events_rx,
628    }))
629}
630
631pub(crate) fn client_compatibility_offer() -> Result<ClientCompatibilityOffer, C2ControlError> {
632    Ok(ClientCompatibilityOffer {
633        build_stamp: BUILD_STAMP.to_owned(),
634        capabilities: vec![
635            CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY)
636                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
637            CapabilityId::new(C2_OPAQUE_UNIX_PATH_CAPABILITY)
638                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
639            CapabilityId::new(C2_REPOSITORY_PATH_CAPABILITY)
640                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
641            CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY)
642                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
643            CapabilityId::new(C2_WORKSPACE_FILE_WRITE_CAPABILITY)
644                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
645            CapabilityId::new(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY)
646                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
647            CapabilityId::new(C2_GIT_READ_CAPABILITY)
648                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
649            CapabilityId::new(C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY)
650                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
651            CapabilityId::new(C2_PROVIDER_RUNTIME_STATUS_CAPABILITY)
652                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
653            CapabilityId::new(C2_PROVIDER_ID_OPEN_CAPABILITY)
654                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
655            CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY)
656                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
657            CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY)
658                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
659            CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY)
660                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
661            CapabilityId::new(C2_AGENT_STREAM_EVENTS_CAPABILITY)
662                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
663            CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY)
664                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
665            CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY)
666                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
667            CapabilityId::new(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY)
668                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
669            CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY)
670                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
671            CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY)
672                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
673            CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY)
674                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
675            CapabilityId::new(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY)
676                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
677            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_CAPABILITY)
678                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
679            CapabilityId::new(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY)
680                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
681            CapabilityId::new(C2_NATIVE_SESSION_INDEX_CAPABILITY)
682                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
683            CapabilityId::new(C2_NATIVE_SESSION_PREVIEW_CAPABILITY)
684                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
685            CapabilityId::new(C2_HOST_DIRECTORY_BROWSE_CAPABILITY)
686                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
687            CapabilityId::new(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
688                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
689            CapabilityId::new(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY)
690                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
691            CapabilityId::new(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY)
692                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
693            CapabilityId::new(C2_SESSION_TASK_CORRELATION_CAPABILITY)
694                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
695            CapabilityId::new(C2_CONTROL_DETAIL_CAPABILITY)
696                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
697            CapabilityId::new(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
698                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
699            CapabilityId::new(C2_HARNESS_MCP_READ_PROXY_CAPABILITY)
700                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
701            CapabilityId::new(C2_ACP_CONTROL_CAPABILITY)
702                .map_err(|error| C2ControlError::Protocol(error.to_string()))?,
703        ],
704        state_schema: None,
705    })
706}
707
708fn validate_selected_compatibility(
709    offer: &ClientCompatibilityOffer,
710    selected: Option<&NegotiatedC2ControlCompatibility>,
711) -> Result<(), C2ControlError> {
712    let Some(selected) = selected else {
713        return Err(C2ControlError::Protocol(
714            "C2 omitted the required authenticated compatibility selection".to_owned(),
715        ));
716    };
717    if selected.build_stamp != offer.build_stamp {
718        return Err(C2ControlError::BuildStampMismatch {
719            local: offer.build_stamp.clone(),
720            remote: selected.build_stamp.clone(),
721        });
722    }
723    if selected
724        .capabilities
725        .iter()
726        .any(|capability| !offer.capabilities.contains(capability))
727    {
728        return Err(C2ControlError::Protocol(
729            "C2 selected a capability outside the client offer".to_owned(),
730        ));
731    }
732    if !selected
733        .capabilities
734        .iter()
735        .any(|capability| capability.as_str() == C2_COMPATIBILITY_METADATA_CAPABILITY)
736    {
737        return Err(C2ControlError::Protocol(
738            "C2 omitted the required compatibility metadata capability".to_owned(),
739        ));
740    }
741    Ok(())
742}
743
744fn negotiated_path_capabilities(
745    selected: Option<&NegotiatedC2ControlCompatibility>,
746) -> NegotiatedPathCapabilities {
747    let selected_has = |expected| {
748        selected.is_some_and(|selected| {
749            selected
750                .capabilities
751                .iter()
752                .any(|capability| capability.as_str() == expected)
753        })
754    };
755    NegotiatedPathCapabilities {
756        opaque_host_paths: selected_has(C2_OPAQUE_UNIX_PATH_CAPABILITY),
757        repository_paths: selected_has(C2_REPOSITORY_PATH_CAPABILITY),
758        workspace_file_read: selected_has(C2_WORKSPACE_FILE_READ_CAPABILITY),
759        workspace_file_write: selected_has(C2_WORKSPACE_FILE_WRITE_CAPABILITY),
760        workspace_entry_create: selected_has(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY),
761        git_read: selected_has(C2_GIT_READ_CAPABILITY),
762        host_directory_browse: selected_has(C2_HOST_DIRECTORY_BROWSE_CAPABILITY),
763        standalone_workspace_lifecycle:
764            selected_has(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY),
765        provider_session_reference_index:
766            selected_has(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY),
767        provider_ids_open: selected_has(C2_PROVIDER_ID_OPEN_CAPABILITY),
768        spawn_spec_defaults_overrides:
769            selected_has(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY),
770        spawn_profile_revision: selected_has(C2_SPAWN_PROFILE_REVISION_CAPABILITY),
771        worktree_selection: selected_has(C2_WORKTREE_SELECTION_CAPABILITY),
772        managed_worktree_lifecycle:
773            selected_has(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY),
774        managed_worktree_spawn_v2:
775            selected_has(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY),
776        child_environment_profile:
777            selected_has(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY),
778        session_bundle_materialization:
779            selected_has(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY),
780        history_context_pack: selected_has(C2_HISTORY_CONTEXT_PACK_CAPABILITY),
781        session_record_context_export:
782            selected_has(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY),
783        native_session_catalog: selected_has(C2_NATIVE_SESSION_CATALOG_CAPABILITY),
784        native_session_catalog_paging:
785            selected_has(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY),
786        native_session_index: selected_has(C2_NATIVE_SESSION_INDEX_CAPABILITY),
787        native_session_preview: selected_has(C2_NATIVE_SESSION_PREVIEW_CAPABILITY),
788        terminal_frame_events: selected_has(C2_TERMINAL_FRAME_EVENTS_CAPABILITY),
789        agent_stream_events: selected_has(C2_AGENT_STREAM_EVENTS_CAPABILITY),
790        agent_progress_snapshot: selected_has(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY),
791        session_task_correlation: selected_has(C2_SESSION_TASK_CORRELATION_CAPABILITY),
792        control_detail: selected_has(C2_CONTROL_DETAIL_CAPABILITY),
793        delivery_bundle_v2_stage_commit:
794            selected_has(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY),
795        harness_mcp_read_proxy: selected_has(C2_HARNESS_MCP_READ_PROXY_CAPABILITY),
796        acp_control: selected_has(C2_ACP_CONTROL_CAPABILITY),
797    }
798}
799
800/// Names the capability a refused request required. Every capability with a
801/// dedicated arm here names itself by its own message; a capability that
802/// falls to the final arm is one `NodeRequest::required_capability()`
803/// returned but this build's `NegotiatedPathCapabilities` has no dedicated
804/// field for yet -- it must refuse under ITS OWN name, never borrow a
805/// sibling's. That exact misattribution once sent a live investigation into
806/// the wrong crate: a refused `acp-control-v1` request was reported as a
807/// refused workspace file read, because the fallback arm always named
808/// `C2_WORKSPACE_FILE_READ_CAPABILITY` regardless of what was actually
809/// missing.
810fn unnegotiated_capability_refusal(capability: Option<&str>) -> String {
811    match capability {
812        Some(C2_ACP_CONTROL_CAPABILITY) => ACP_CONTROL_NOT_NEGOTIATED.to_owned(),
813        Some(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY) => {
814            SPAWN_SPEC_NOT_NEGOTIATED.to_owned()
815        }
816        Some(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY) => {
817            MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED.to_owned()
818        }
819        Some(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY) => {
820            MANAGED_WORKTREE_SPAWN_V2_NOT_NEGOTIATED.to_owned()
821        }
822        Some(C2_HISTORY_CONTEXT_PACK_CAPABILITY) => {
823            HISTORY_CONTEXT_PACK_NOT_NEGOTIATED.to_owned()
824        }
825        Some(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY) => {
826            SESSION_RECORD_CONTEXT_EXPORT_NOT_NEGOTIATED.to_owned()
827        }
828        Some(C2_HOST_DIRECTORY_BROWSE_CAPABILITY) => {
829            HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED.to_owned()
830        }
831        Some(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY) => {
832            STANDALONE_WORKSPACE_LIFECYCLE_NOT_NEGOTIATED.to_owned()
833        }
834        Some(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY) => {
835            PROVIDER_SESSION_REFERENCE_INDEX_NOT_NEGOTIATED.to_owned()
836        }
837        Some(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY) => {
838            WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED.to_owned()
839        }
840        Some(C2_NATIVE_SESSION_INDEX_CAPABILITY) => {
841            "native session index capability was not negotiated".to_owned()
842        }
843        Some(C2_SESSION_TASK_CORRELATION_CAPABILITY) => {
844            SESSION_TASK_CORRELATION_NOT_NEGOTIATED.to_owned()
845        }
846        Some(C2_CONTROL_DETAIL_CAPABILITY) => CONTROL_DETAIL_NOT_NEGOTIATED.to_owned(),
847        Some(C2_HARNESS_MCP_READ_PROXY_CAPABILITY) => {
848            "harness MCP read proxy capability was not negotiated".to_owned()
849        }
850        Some(C2_WORKSPACE_FILE_READ_CAPABILITY) => {
851            WORKSPACE_FILE_READ_NOT_NEGOTIATED.to_owned()
852        }
853        Some(unrecognized) => format!(
854            "C2 control capability \"{unrecognized}\" is required but was not negotiated",
855        ),
856        // `required_capability_available` is only `false` when
857        // `required_capability()` returned `Some(..)`; this arm is
858        // unreachable in practice and carries no capability to name.
859        None => "a required C2 control capability was not negotiated".to_owned(),
860    }
861}
862
863fn reject_unnegotiated_outbound_path(
864    request: &NodeRequest,
865    capabilities: NegotiatedPathCapabilities,
866) -> Result<(), C2ControlError> {
867    let now_unix_ms = current_unix_ms()?;
868    if !request.harness_mcp_contract_is_valid_at(now_unix_ms) {
869        return Err(C2ControlError::Protocol(
870            "invalid harness MCP proxy request".to_owned(),
871        ));
872    }
873    if !request.history_context_pack_contract_is_valid() {
874        return Err(C2ControlError::Protocol(
875            INVALID_HISTORY_CONTEXT_PACK_REQUEST.to_owned(),
876        ));
877    }
878    if !request.native_session_catalog_contract_is_valid() {
879        return Err(C2ControlError::Protocol(
880            "invalid native session catalog request".to_owned(),
881        ));
882    }
883    if !request.native_session_preview_contract_is_valid() {
884        return Err(C2ControlError::Protocol(
885            "invalid native session preview request".to_owned(),
886        ));
887    }
888    if matches!(request, NodeRequest::IndexNativeSession { selection, .. }
889        if selection.route.scope
890            != gate4agent_node_protocol::NativeSessionCatalogScope::Workspace)
891    {
892        return Err(C2ControlError::Protocol(
893            "external native sessions must be registered as workspaces before indexing"
894                .to_owned(),
895        ));
896    }
897    if !capabilities.opaque_host_paths && node_request_has_unix_bytes(request) {
898        return Err(C2ControlError::Protocol(
899            OPAQUE_UNIX_PATH_NOT_NEGOTIATED.to_owned(),
900        ));
901    }
902    if !capabilities.provider_ids_open
903        && (matches!(request, NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } if !provider_id_is_legacy(provider))
904            || matches!(request, NodeRequest::IndexProviderSession { provider, .. } if !provider_id_is_legacy(provider))
905            || matches!(request, NodeRequest::CatalogNativeSessions { route, .. } if !provider_id_is_legacy(&route.provider))
906            || matches!(request, NodeRequest::PageNativeSessions { route, .. } if !provider_id_is_legacy(&route.provider))
907            || matches!(request, NodeRequest::PreviewNativeSession { selection, .. } if !provider_id_is_legacy(&selection.route.provider))
908            || matches!(request, NodeRequest::IndexNativeSession { selection, .. } if !provider_id_is_legacy(&selection.route.provider))
909            || spawn_spec_requires_open_provider_capability(request)
910            || matches!(request, NodeRequest::ForgetContextPack { .. }))
911    {
912        return Err(C2ControlError::Protocol(
913            OPEN_PROVIDER_ID_NOT_NEGOTIATED.to_owned(),
914        ));
915    }
916    let required_capability_available = match request.required_capability() {
917        None => true,
918        Some(C2_WORKSPACE_FILE_READ_CAPABILITY) => capabilities.workspace_file_read,
919        Some(C2_WORKSPACE_FILE_WRITE_CAPABILITY) => capabilities.workspace_file_write,
920        Some(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY) => capabilities.workspace_entry_create,
921        Some(C2_GIT_READ_CAPABILITY) => capabilities.git_read,
922        Some(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY) => {
923            capabilities.spawn_spec_defaults_overrides
924        }
925        Some(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY) => {
926            capabilities.managed_worktree_lifecycle
927        }
928        Some(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY) => {
929            capabilities.managed_worktree_spawn_v2
930        }
931        Some(C2_HISTORY_CONTEXT_PACK_CAPABILITY) => capabilities.history_context_pack,
932        Some(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY) => {
933            capabilities.session_record_context_export
934        }
935        Some(C2_NATIVE_SESSION_CATALOG_CAPABILITY) => capabilities.native_session_catalog,
936        Some(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY) => {
937            capabilities.native_session_catalog_paging
938        }
939        Some(C2_NATIVE_SESSION_INDEX_CAPABILITY) => capabilities.native_session_index,
940        Some(C2_NATIVE_SESSION_PREVIEW_CAPABILITY) => capabilities.native_session_preview,
941        Some(C2_HOST_DIRECTORY_BROWSE_CAPABILITY) => capabilities.host_directory_browse,
942        Some(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY) => {
943            capabilities.standalone_workspace_lifecycle
944        }
945        Some(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY) => {
946            capabilities.provider_session_reference_index
947        }
948        Some(C2_SESSION_TASK_CORRELATION_CAPABILITY) => {
949            capabilities.session_task_correlation
950        }
951        Some(C2_CONTROL_DETAIL_CAPABILITY) => capabilities.control_detail,
952        Some(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY) => {
953            capabilities.delivery_bundle_v2_stage_commit
954        }
955        Some(C2_HARNESS_MCP_READ_PROXY_CAPABILITY) => capabilities.harness_mcp_read_proxy,
956        Some(C2_ACP_CONTROL_CAPABILITY) => capabilities.acp_control,
957        Some(_) => false,
958    };
959    if !required_capability_available {
960        return Err(C2ControlError::Protocol(unnegotiated_capability_refusal(
961            request.required_capability(),
962        )));
963    }
964    if request.requires_spawn_spec_defaults_overrides_capability()
965        && !capabilities.spawn_spec_defaults_overrides
966    {
967        return Err(C2ControlError::Protocol(
968            SPAWN_SPEC_NOT_NEGOTIATED.to_owned(),
969        ));
970    }
971    if request.requires_spawn_profile_revision_capability()
972        && !capabilities.spawn_profile_revision
973    {
974        return Err(C2ControlError::Protocol(
975            SPAWN_PROFILE_REVISION_NOT_NEGOTIATED.to_owned(),
976        ));
977    }
978    if request.requires_worktree_selection_capability()
979        && !capabilities.worktree_selection
980    {
981        return Err(C2ControlError::Protocol(
982            WORKTREE_SELECTION_NOT_NEGOTIATED.to_owned(),
983        ));
984    }
985    if c2_request_requires_child_environment_profile_capability(request)
986        && !capabilities.child_environment_profile
987    {
988        return Err(C2ControlError::Protocol(
989            CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED.to_owned(),
990        ));
991    }
992    if request.requires_session_bundle_materialization_capability()
993        && !capabilities.session_bundle_materialization
994    {
995        return Err(C2ControlError::Protocol(
996            SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED.to_owned(),
997        ));
998    }
999    if request.requires_history_context_pack_capability()
1000        && !capabilities.history_context_pack
1001    {
1002        return Err(C2ControlError::Protocol(
1003            HISTORY_CONTEXT_PACK_NOT_NEGOTIATED.to_owned(),
1004        ));
1005    }
1006    if !capabilities.repository_paths && node_request_has_unix_repository_path(request) {
1007        return Err(C2ControlError::Protocol(
1008            REPOSITORY_PATH_NOT_NEGOTIATED.to_owned(),
1009        ));
1010    }
1011    Ok(())
1012}
1013
1014fn current_unix_ms() -> Result<u64, C2ControlError> {
1015    SystemTime::now()
1016        .duration_since(UNIX_EPOCH)
1017        .map_err(|_| C2ControlError::Protocol("system clock precedes Unix epoch".to_owned()))?
1018        .as_millis()
1019        .try_into()
1020        .map_err(|_| C2ControlError::Protocol("system clock exceeds protocol range".to_owned()))
1021}
1022
1023fn c2_request_requires_child_environment_profile_capability(request: &NodeRequest) -> bool {
1024    let spec = match request {
1025        NodeRequest::SpawnSpec { spec } => spec,
1026        NodeRequest::SpawnManagedWorktree { request } => &request.spawn_spec,
1027        NodeRequest::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
1028        _ => return false,
1029    };
1030    !matches!(
1031        &spec.overrides.environment_profile_id,
1032        gate4agent_node_protocol::SpawnOverride::Clear
1033    )
1034}
1035
1036fn spawn_spec_requires_open_provider_capability(request: &NodeRequest) -> bool {
1037    let spec = match request {
1038        NodeRequest::SpawnSpec { spec } => spec,
1039        NodeRequest::SpawnManagedWorktree { request } => &request.spawn_spec,
1040        NodeRequest::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
1041        _ => return false,
1042    };
1043    match &spec.overrides.provider {
1044        gate4agent_node_protocol::SpawnOverride::Set { value } => {
1045            !provider_id_is_legacy(value)
1046        }
1047        gate4agent_node_protocol::SpawnOverride::Inherit
1048        | gate4agent_node_protocol::SpawnOverride::Clear => true,
1049    }
1050}
1051
1052fn node_request_has_unix_repository_path(request: &NodeRequest) -> bool {
1053    match request {
1054        NodeRequest::ReadWorkspaceFile { path, .. }
1055        | NodeRequest::WriteWorkspaceFile { path, .. }
1056        | NodeRequest::CreateWorkspaceFile { path, .. }
1057        | NodeRequest::CreateWorkspaceDirectory { path, .. } => path.as_unix_bytes().is_some(),
1058        NodeRequest::ReadGitDiff { request, .. } => request
1059            .path
1060            .as_ref()
1061            .is_some_and(|path| path.as_unix_bytes().is_some()),
1062        NodeRequest::Snapshot
1063        | NodeRequest::Resync { .. }
1064        | NodeRequest::ArmHarnessMcpReservation { .. }
1065        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
1066        | NodeRequest::ActivateHarnessMcpReservation { .. }
1067        | NodeRequest::AbortHarnessMcpReservation { .. }
1068        | NodeRequest::PutHarnessMcpReplyChunk { .. }
1069        | NodeRequest::RejectHarnessMcpCall { .. }
1070        | NodeRequest::BeginDeliveryStage { .. }
1071        | NodeRequest::PutDeliveryBlobChunk { .. }
1072        | NodeRequest::CommitDeliveryStage { .. }
1073        | NodeRequest::AbortDeliveryStage { .. }
1074        | NodeRequest::BrowseHostDirectories { .. }
1075        | NodeRequest::InspectWorkspace { .. }
1076        | NodeRequest::ReadGitHistory { .. }
1077        | NodeRequest::AcquireController { .. }
1078        | NodeRequest::ReleaseController
1079        | NodeRequest::RegisterWorkspace { .. }
1080        | NodeRequest::CreateStandaloneWorkspace { .. }
1081        | NodeRequest::UnregisterWorkspace { .. }
1082        | NodeRequest::CreateWorktree { .. }
1083        | NodeRequest::RemoveWorktree { .. }
1084        | NodeRequest::Spawn { .. }
1085        | NodeRequest::SpawnWithOptions { .. }
1086        | NodeRequest::SpawnSpec { .. }
1087        | NodeRequest::SpawnManagedWorktree { .. }
1088        | NodeRequest::SpawnManagedWorktreeV2 { .. }
1089        | NodeRequest::CleanupManagedWorktree { .. }
1090        | NodeRequest::Resume { .. }
1091        | NodeRequest::RenameSessionRecord { .. }
1092        | NodeRequest::SetSessionTask { .. }
1093        | NodeRequest::IndexProviderSession { .. }
1094        | NodeRequest::IndexNativeSession { .. }
1095        | NodeRequest::ResumeSessionRecord { .. }
1096        | NodeRequest::ForgetSessionRecord { .. }
1097        | NodeRequest::CatalogNativeSessions { .. }
1098        | NodeRequest::PageNativeSessions { .. }
1099        | NodeRequest::PreviewNativeSession { .. }
1100        | NodeRequest::PreviewSessionRecord { .. }
1101        | NodeRequest::DiscoverHistory { .. }
1102        | NodeRequest::LoadHistory { .. }
1103        | NodeRequest::ExportContextPackForSessionRecord { .. }
1104        | NodeRequest::ExportContextPack { .. }
1105        | NodeRequest::ForgetContextPack { .. }
1106        | NodeRequest::ResolveDurableContextPack { .. }
1107        | NodeRequest::ReadContextPack { .. }
1108        | NodeRequest::Prompt { .. }
1109        | NodeRequest::Paste { .. }
1110        | NodeRequest::Input { .. }
1111        | NodeRequest::TerminalBytes { .. }
1112        | NodeRequest::TerminalControl { .. }
1113        | NodeRequest::Resize { .. }
1114        | NodeRequest::Interrupt { .. }
1115        | NodeRequest::Stop { .. }
1116        | NodeRequest::Remove { .. }
1117        | NodeRequest::ResolveInteraction { .. }
1118        | NodeRequest::SetSessionMode { .. }
1119        | NodeRequest::SetSessionConfigOption { .. }
1120        | NodeRequest::SetSessionModel { .. }
1121        | NodeRequest::Shutdown => false,
1122    }
1123}
1124
1125fn node_request_has_unix_bytes(request: &NodeRequest) -> bool {
1126    match request {
1127        NodeRequest::RegisterWorkspace { root, .. }
1128        | NodeRequest::CreateStandaloneWorkspace { root, .. } => {
1129            root.as_unix_bytes().is_some()
1130        }
1131        NodeRequest::BrowseHostDirectories { directory, after } => {
1132            directory.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1133                || after.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1134        }
1135        NodeRequest::CreateWorktree { target_root, .. }
1136        | NodeRequest::RemoveWorktree { target_root, .. } => {
1137            target_root.as_unix_bytes().is_some()
1138        }
1139        NodeRequest::Snapshot
1140        | NodeRequest::Resync { .. }
1141        | NodeRequest::ArmHarnessMcpReservation { .. }
1142        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
1143        | NodeRequest::ActivateHarnessMcpReservation { .. }
1144        | NodeRequest::AbortHarnessMcpReservation { .. }
1145        | NodeRequest::PutHarnessMcpReplyChunk { .. }
1146        | NodeRequest::RejectHarnessMcpCall { .. }
1147        | NodeRequest::InspectWorkspace { .. }
1148        | NodeRequest::ReadWorkspaceFile { .. }
1149        | NodeRequest::WriteWorkspaceFile { .. }
1150        | NodeRequest::CreateWorkspaceFile { .. }
1151        | NodeRequest::CreateWorkspaceDirectory { .. }
1152        | NodeRequest::ReadGitHistory { .. }
1153        | NodeRequest::ReadGitDiff { .. }
1154        | NodeRequest::BeginDeliveryStage { .. }
1155        | NodeRequest::PutDeliveryBlobChunk { .. }
1156        | NodeRequest::CommitDeliveryStage { .. }
1157        | NodeRequest::AbortDeliveryStage { .. }
1158        | NodeRequest::AcquireController { .. }
1159        | NodeRequest::ReleaseController
1160        | NodeRequest::UnregisterWorkspace { .. }
1161        | NodeRequest::Spawn { .. }
1162        | NodeRequest::SpawnWithOptions { .. }
1163        | NodeRequest::SpawnSpec { .. }
1164        | NodeRequest::SpawnManagedWorktree { .. }
1165        | NodeRequest::SpawnManagedWorktreeV2 { .. }
1166        | NodeRequest::CleanupManagedWorktree { .. }
1167        | NodeRequest::Resume { .. }
1168        | NodeRequest::RenameSessionRecord { .. }
1169        | NodeRequest::SetSessionTask { .. }
1170        | NodeRequest::IndexProviderSession { .. }
1171        | NodeRequest::IndexNativeSession { .. }
1172        | NodeRequest::ResumeSessionRecord { .. }
1173        | NodeRequest::ForgetSessionRecord { .. }
1174        | NodeRequest::CatalogNativeSessions { .. }
1175        | NodeRequest::PageNativeSessions { .. }
1176        | NodeRequest::PreviewNativeSession { .. }
1177        | NodeRequest::PreviewSessionRecord { .. }
1178        | NodeRequest::DiscoverHistory { .. }
1179        | NodeRequest::LoadHistory { .. }
1180        | NodeRequest::ExportContextPackForSessionRecord { .. }
1181        | NodeRequest::ExportContextPack { .. }
1182        | NodeRequest::ForgetContextPack { .. }
1183        | NodeRequest::ResolveDurableContextPack { .. }
1184        | NodeRequest::ReadContextPack { .. }
1185        | NodeRequest::Prompt { .. }
1186        | NodeRequest::Paste { .. }
1187        | NodeRequest::Input { .. }
1188        | NodeRequest::TerminalBytes { .. }
1189        | NodeRequest::TerminalControl { .. }
1190        | NodeRequest::Resize { .. }
1191        | NodeRequest::Interrupt { .. }
1192        | NodeRequest::Stop { .. }
1193        | NodeRequest::Remove { .. }
1194        | NodeRequest::ResolveInteraction { .. }
1195        | NodeRequest::SetSessionMode { .. }
1196        | NodeRequest::SetSessionConfigOption { .. }
1197        | NodeRequest::SetSessionModel { .. }
1198        | NodeRequest::Shutdown => false,
1199    }
1200}
1201
1202fn routed_response_has_unix_bytes(response: &RoutedNodeResponse) -> bool {
1203    response
1204        .response
1205        .as_ref()
1206        .is_ok_and(c2_node_response_has_unix_bytes)
1207}
1208
1209fn routed_response_has_terminal_frame_event(response: &RoutedNodeResponse) -> bool {
1210    response
1211        .response
1212        .as_ref()
1213        .is_ok_and(c2_node_response_has_terminal_frame_event)
1214}
1215
1216fn routed_response_has_agent_stream_event(response: &RoutedNodeResponse) -> bool {
1217    response
1218        .response
1219        .as_ref()
1220        .is_ok_and(c2_node_response_has_agent_stream_event)
1221}
1222
1223fn routed_response_has_agent_progress(response: &RoutedNodeResponse) -> bool {
1224    response.response.as_ref().is_ok_and(|response| match response {
1225        C2NodeResponse::Snapshot { snapshot, .. }
1226        | C2NodeResponse::Resync { snapshot, .. } => !snapshot.agent_progress.is_empty(),
1227        _ => false,
1228    })
1229}
1230
1231fn routed_response_requires_history_context_pack(response: &RoutedNodeResponse) -> bool {
1232    match &response.response {
1233        Ok(response) => response.requires_history_context_pack_capability(),
1234        Err(failure) => matches!(
1235            failure.code,
1236            gate4agent_node_protocol::NodeFailureCode::UnknownContextPack
1237                | gate4agent_node_protocol::NodeFailureCode::ContextPackBusy
1238                | gate4agent_node_protocol::NodeFailureCode::ContextPackMaterializationFailed
1239        ),
1240    }
1241}
1242
1243fn routed_response_requires_native_session_catalog(response: &RoutedNodeResponse) -> bool {
1244    response.response.as_ref().is_ok_and(
1245        C2NodeResponse::requires_native_session_catalog_capability,
1246    )
1247}
1248
1249fn routed_response_requires_native_session_catalog_paging(response: &RoutedNodeResponse) -> bool {
1250    match &response.response {
1251        Ok(response) => response.requires_native_session_catalog_paging_capability(),
1252        Err(failure) => failure.requires_native_session_catalog_paging_capability(),
1253    }
1254}
1255
1256fn routed_response_requires_native_session_preview(response: &RoutedNodeResponse) -> bool {
1257    response.response.as_ref().is_ok_and(
1258        C2NodeResponse::requires_native_session_preview_capability,
1259    )
1260}
1261
1262fn routed_response_requires_native_session_index(response: &RoutedNodeResponse) -> bool {
1263    response.response.as_ref().is_ok_and(
1264        C2NodeResponse::requires_native_session_index_capability,
1265    )
1266}
1267
1268fn routed_response_requires_harness_mcp_proxy(response: &RoutedNodeResponse) -> bool {
1269    match &response.response {
1270        Ok(response) => response.requires_harness_mcp_proxy_capability(),
1271        Err(failure) => failure.requires_harness_mcp_proxy_capability(),
1272    }
1273}
1274
1275fn validate_harness_mcp_response(
1276    expected: &NodeRequest,
1277    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1278) -> Result<(), &'static str> {
1279    let Ok(routed) = response else { return Ok(()); };
1280    use C2NodeResponse as Response;
1281    use NodeRequest as Request;
1282    let valid = match (expected, &routed.response) {
1283        (Request::ArmHarnessMcpReservation { reservation_id, activation_digest, expires_at_unix_ms, .. },
1284            Ok(Response::Armed { reservation_id: echoed_id, activation_digest: echoed_digest, expires_at_unix_ms: echoed_expiry })) =>
1285            reservation_id == echoed_id && activation_digest == echoed_digest
1286                && expires_at_unix_ms == echoed_expiry,
1287        (Request::SpawnSpecWithHarnessMcp { reservation_id, activation_digest, .. },
1288            Ok(Response::Spawned { reservation_id: echoed_id, activation_digest: echoed_digest, receipt })) =>
1289            reservation_id == echoed_id && activation_digest == echoed_digest
1290                && receipt.harness_mcp_proxy.as_ref().is_some_and(|proxy| {
1291                    &proxy.reservation_id == reservation_id
1292                        && &proxy.activation_digest == activation_digest
1293                }),
1294        (Request::ActivateHarnessMcpReservation { reservation_id, activation_digest, record_id, session },
1295            Ok(Response::Activated { reservation_id: echoed_id, activation_digest: echoed_digest, record_id: echoed_record, session: echoed_session })) =>
1296            reservation_id == echoed_id && activation_digest == echoed_digest
1297                && record_id == echoed_record && session == echoed_session,
1298        (Request::AbortHarnessMcpReservation { reservation_id, activation_digest },
1299            Ok(Response::Aborted { reservation_id: echoed_id, activation_digest: echoed_digest })) =>
1300            reservation_id == echoed_id && activation_digest == echoed_digest,
1301        (Request::PutHarnessMcpReplyChunk { reservation_id, activation_digest, record_id, session, call_id, offset, final_chunk, chunk_hex },
1302            Ok(Response::ReplyChunkAccepted { reservation_id: echoed_id, activation_digest: echoed_digest, record_id: echoed_record, session: echoed_session, call_id: echoed_call, next_offset, completed })) =>
1303            reservation_id == echoed_id && activation_digest == echoed_digest
1304                && record_id == echoed_record && session == echoed_session && call_id == echoed_call
1305                && offset.checked_add(u32::try_from(chunk_hex.raw_len()).unwrap_or(u32::MAX))
1306                    == Some(*next_offset) && completed == final_chunk,
1307        (Request::RejectHarnessMcpCall { reservation_id, activation_digest, record_id, session, call_id, .. },
1308            Ok(Response::CallRejected { reservation_id: echoed_id, activation_digest: echoed_digest, record_id: echoed_record, session: echoed_session, call_id: echoed_call })) =>
1309            reservation_id == echoed_id && activation_digest == echoed_digest
1310                && record_id == echoed_record && session == echoed_session && call_id == echoed_call,
1311        (request, Err(_)) if request.required_capability()
1312            == Some(C2_HARNESS_MCP_READ_PROXY_CAPABILITY) => true,
1313        (request, Ok(_)) if request.required_capability()
1314            == Some(C2_HARNESS_MCP_READ_PROXY_CAPABILITY) => false,
1315        (_, Ok(response)) if response.requires_harness_mcp_proxy_capability() => false,
1316        _ => true,
1317    };
1318    if valid { Ok(()) } else { Err("C2 harness MCP response does not match the routed request") }
1319}
1320
1321fn validate_provider_session_index_response(
1322    expected: &NodeRequest,
1323    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1324) -> Result<(), &'static str> {
1325    let Ok(routed) = response else {
1326        return Ok(());
1327    };
1328    match (expected, &routed.response) {
1329        (
1330            NodeRequest::IndexProviderSession {
1331                workspace_id,
1332                provider,
1333                ..
1334            },
1335            Ok(C2NodeResponse::ProviderSessionIndexed { record }),
1336        ) if &record.workspace_id == workspace_id && &record.provider == provider => Ok(()),
1337        (NodeRequest::IndexProviderSession { .. }, Err(_)) => Ok(()),
1338        (NodeRequest::IndexProviderSession { .. }, Ok(_)) => {
1339            Err("C2 provider session index response does not match the routed request")
1340        }
1341        (_, Ok(C2NodeResponse::ProviderSessionIndexed { .. })) => {
1342            Err("C2 returned an unexpected provider session index response")
1343        }
1344        _ => Ok(()),
1345    }
1346}
1347
1348fn validate_session_record_context_export_response(
1349    expected: &NodeRequest,
1350    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1351) -> Result<(), &'static str> {
1352    let Ok(routed) = response else {
1353        return Ok(());
1354    };
1355    match (expected, &routed.response) {
1356        (
1357            NodeRequest::ExportContextPackForSessionRecord { record_id, session },
1358            Ok(C2NodeResponse::ContextPackForSessionRecordExported {
1359                record_id: echoed_record_id,
1360                session: echoed_session,
1361                context,
1362            }),
1363        ) if record_id == echoed_record_id && session == echoed_session && context.is_valid() => {
1364            Ok(())
1365        }
1366        (NodeRequest::ExportContextPackForSessionRecord { .. }, Err(_)) => Ok(()),
1367        (NodeRequest::ExportContextPackForSessionRecord { .. }, Ok(_)) => Err(
1368            "C2 session-record context export response does not match the routed request",
1369        ),
1370        (_, Ok(C2NodeResponse::ContextPackForSessionRecordExported { .. })) => Err(
1371            "C2 returned an unexpected session-record context export response",
1372        ),
1373        _ => Ok(()),
1374    }
1375}
1376
1377fn validate_native_session_response(
1378    expected: &NodeRequest,
1379    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1380) -> Result<(), &'static str> {
1381    let Ok(routed) = response else {
1382        return Ok(());
1383    };
1384    match (expected, &routed.response) {
1385        (
1386            NodeRequest::CatalogNativeSessions { route, .. },
1387            Ok(response @ C2NodeResponse::NativeSessionsCataloged {
1388                route: echoed_route,
1389                ..
1390            }),
1391        ) if echoed_route == route && response.native_session_catalog_contract_is_valid() => {
1392            Ok(())
1393        }
1394        (
1395            NodeRequest::PageNativeSessions {
1396                route,
1397                window,
1398                catalog_revision,
1399                ..
1400            },
1401            Ok(response @ C2NodeResponse::NativeSessionsPaged {
1402                route: echoed_route,
1403                page,
1404            }),
1405        ) if echoed_route == route
1406            && page.window == *window
1407            && page.revision == *catalog_revision
1408            && response.native_session_catalog_contract_is_valid() => Ok(()),
1409        (
1410            NodeRequest::PreviewNativeSession { selection, .. },
1411            Ok(response @ C2NodeResponse::NativeSessionPreviewed {
1412                selection: echoed_selection,
1413                ..
1414            }),
1415        ) if echoed_selection == selection
1416            && response.native_session_preview_contract_is_valid() => Ok(()),
1417        (
1418            NodeRequest::IndexNativeSession { selection, .. },
1419            Ok(response @ C2NodeResponse::NativeSessionIndexed {
1420                selection: echoed_selection,
1421                record,
1422            }),
1423        ) if echoed_selection == selection
1424            && selection.route.scope
1425                == gate4agent_node_protocol::NativeSessionCatalogScope::Workspace
1426            && selection.route.workspace_id.as_ref() == Some(&record.workspace_id)
1427            && selection.route.provider == record.provider
1428            && response.native_session_index_contract_is_valid() => Ok(()),
1429        (
1430            NodeRequest::CatalogNativeSessions { .. }
1431            | NodeRequest::PageNativeSessions { .. }
1432            | NodeRequest::PreviewNativeSession { .. }
1433            | NodeRequest::IndexNativeSession { .. },
1434            Err(_),
1435        ) => Ok(()),
1436        (
1437            NodeRequest::CatalogNativeSessions { .. }
1438            | NodeRequest::PageNativeSessions { .. }
1439            | NodeRequest::PreviewNativeSession { .. }
1440            | NodeRequest::IndexNativeSession { .. },
1441            Ok(_),
1442        ) => Err("C2 native session response does not match the routed request"),
1443        (
1444            _,
1445            Ok(
1446                C2NodeResponse::NativeSessionsCataloged { .. }
1447                | C2NodeResponse::NativeSessionsPaged { .. }
1448                | C2NodeResponse::NativeSessionPreviewed { .. }
1449                | C2NodeResponse::NativeSessionIndexed { .. },
1450            ),
1451        ) => Err("C2 returned an unexpected native session response"),
1452        _ => Ok(()),
1453    }
1454}
1455
1456fn validate_workspace_content_response(
1457    expected: &NodeRequest,
1458    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1459) -> Result<(), &'static str> {
1460    let Ok(routed) = response else {
1461        return Ok(());
1462    };
1463    match (expected, &routed.response) {
1464        (NodeRequest::ReadWorkspaceFile { workspace_id, path }, Ok(C2NodeResponse::WorkspaceFileRead { file }))
1465            if &file.workspace_id == workspace_id && &file.path == path => Ok(()),
1466        (NodeRequest::WriteWorkspaceFile { workspace_id, path, text, .. }, Ok(C2NodeResponse::WorkspaceFileWritten { file }))
1467            if &file.workspace_id == workspace_id
1468                && &file.path == path
1469                && matches!(
1470                    &file.content,
1471                    gate4agent_node_protocol::WorkspaceFileContent::Utf8 {
1472                        text: written,
1473                        byte_len,
1474                    } if written == text
1475                        && u32::try_from(text.len()).ok() == Some(*byte_len)
1476                ) => Ok(()),
1477        (NodeRequest::CreateWorkspaceFile { workspace_id, path }, Ok(C2NodeResponse::WorkspaceFileCreated { file }))
1478            if &file.workspace_id == workspace_id
1479                && &file.path == path
1480                && file.revision.is_some()
1481                && matches!(
1482                    &file.content,
1483                    gate4agent_node_protocol::WorkspaceFileContent::Utf8 {
1484                        text,
1485                        byte_len: 0,
1486                    } if text.is_empty()
1487                ) => Ok(()),
1488        (
1489            NodeRequest::CreateWorkspaceDirectory { workspace_id, path },
1490            Ok(C2NodeResponse::WorkspaceDirectoryCreated {
1491                workspace_id: actual_workspace_id,
1492                entry,
1493            }),
1494        ) if actual_workspace_id == workspace_id
1495            && &entry.relative_path == path
1496            && entry.kind == gate4agent_node_protocol::WorkspaceEntryKind::Directory => Ok(()),
1497        (NodeRequest::ReadGitHistory { workspace_id, .. }, Ok(C2NodeResponse::GitHistoryRead { workspace_id: actual, .. }))
1498            if actual == workspace_id => Ok(()),
1499        (NodeRequest::ReadGitDiff { workspace_id, request }, Ok(C2NodeResponse::GitDiffRead { workspace_id: actual, diff }))
1500            if actual == workspace_id && diff.mode == request.mode && diff.path == request.path => Ok(()),
1501        (NodeRequest::ReadWorkspaceFile { .. }
1502            | NodeRequest::WriteWorkspaceFile { .. }
1503            | NodeRequest::CreateWorkspaceFile { .. }
1504            | NodeRequest::CreateWorkspaceDirectory { .. }
1505            | NodeRequest::ReadGitHistory { .. }
1506            | NodeRequest::ReadGitDiff { .. }, Err(_)) => Ok(()),
1507        (NodeRequest::ReadWorkspaceFile { .. }
1508            | NodeRequest::WriteWorkspaceFile { .. }
1509            | NodeRequest::CreateWorkspaceFile { .. }
1510            | NodeRequest::CreateWorkspaceDirectory { .. }
1511            | NodeRequest::ReadGitHistory { .. }
1512            | NodeRequest::ReadGitDiff { .. }, Ok(_)) => {
1513                Err("C2 workspace content response does not match the routed request")
1514            }
1515        (_, Ok(C2NodeResponse::WorkspaceFileRead { .. }
1516            | C2NodeResponse::WorkspaceFileWritten { .. }
1517            | C2NodeResponse::WorkspaceFileCreated { .. }
1518            | C2NodeResponse::WorkspaceDirectoryCreated { .. }
1519            | C2NodeResponse::GitHistoryRead { .. }
1520            | C2NodeResponse::GitDiffRead { .. })) => {
1521                Err("C2 returned an unexpected workspace content response")
1522            }
1523        _ => Ok(()),
1524    }
1525}
1526
1527fn validate_session_task_response(
1528    expected: &NodeRequest,
1529    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1530) -> Result<(), &'static str> {
1531    let Ok(routed) = response else { return Ok(()); };
1532    match (expected, &routed.response) {
1533        (NodeRequest::SetSessionTask { .. }, Err(_)) => Ok(()),
1534        (
1535            NodeRequest::SetSessionTask {
1536                record_id,
1537                expected_revision,
1538                target,
1539            },
1540            Ok(C2NodeResponse::SessionRecordUpdated { record }),
1541        ) if c2_session_task_record_matches(record, record_id, *expected_revision, target) => Ok(()),
1542        (NodeRequest::SetSessionTask { .. }, Ok(_)) => {
1543            Err("C2 session task response does not match the routed request")
1544        }
1545        _ => Ok(()),
1546    }
1547}
1548
1549fn validate_managed_worktree_spawn_v2_response(
1550    expected: &NodeRequest,
1551    response: &Result<RoutedNodeResponse, C2RelayFailure>,
1552) -> Result<(), &'static str> {
1553    let Ok(routed) = response else {
1554        return Ok(());
1555    };
1556    match (expected, &routed.response) {
1557        (
1558            NodeRequest::SpawnManagedWorktreeV2 { request },
1559            Ok(C2NodeResponse::ManagedWorktreeSpawnAccepted { receipt }),
1560        ) if receipt.spawn.incarnation_id == routed.incarnation_id
1561            && receipt.spawn.target.node_id == request.spawn_spec.target.node_id
1562            && receipt.spawn.target.workspace_id == request.spawn_spec.target.workspace_id
1563            && receipt.spawn.target.worktree_id.as_ref() == Some(&receipt.lease.workspace_id)
1564            && receipt.spawn.session.workspace_id == receipt.lease.workspace_id
1565            && receipt.spawn.profile_id == request.spawn_spec.profile_id
1566            && receipt.spawn.profile_revision == request.spawn_spec.expected_profile_revision
1567            && receipt.spawn.idempotency_key == request.spawn_spec.idempotency_key
1568            && receipt.lease.source_workspace_id == request.spawn_spec.target.workspace_id
1569            && receipt.lease.profile_id == request.worktree_profile_id
1570            && receipt.lease.profile_revision == request.expected_profile_revision => Ok(()),
1571        (NodeRequest::SpawnManagedWorktreeV2 { .. }, Err(_)) => Ok(()),
1572        (NodeRequest::SpawnManagedWorktreeV2 { .. }, Ok(_)) => {
1573            Err("C2 managed worktree V2 response does not match the routed request")
1574        }
1575        _ => Ok(()),
1576    }
1577}
1578
1579fn c2_session_task_record_matches(
1580    record: &hatchery_core::C2ManagedSessionRecord,
1581    record_id: &gate4agent_node_protocol::SessionRecordId,
1582    expected_revision: u64,
1583    target: &gate4agent_node_protocol::SessionTaskTargetV1,
1584) -> bool {
1585    if &record.record_id != record_id { return false; }
1586    let next_revision = expected_revision.checked_add(1);
1587    match target {
1588        gate4agent_node_protocol::SessionTaskTargetV1::New => record.task_binding.as_ref()
1589            .is_some_and(|binding| Some(binding.revision) == next_revision && binding.task_id.is_some()),
1590        gate4agent_node_protocol::SessionTaskTargetV1::Existing { task_id } => record.task_binding.as_ref()
1591            .is_some_and(|binding| (binding.revision == expected_revision || Some(binding.revision) == next_revision)
1592                && binding.task_id.as_ref() == Some(task_id)),
1593        gate4agent_node_protocol::SessionTaskTargetV1::Clear => match &record.task_binding {
1594            None => expected_revision == 0,
1595            Some(binding) => binding.task_id.is_none()
1596                && (binding.revision == expected_revision || Some(binding.revision) == next_revision),
1597        },
1598    }
1599}
1600
1601fn c2_node_response_has_terminal_frame_event(response: &C2NodeResponse) -> bool {
1602    match response {
1603        C2NodeResponse::Resync { events, .. } => events
1604            .iter()
1605            .any(|event| c2_node_event_is_terminal_frame(&event.event)),
1606        C2NodeResponse::Snapshot { .. }
1607        | C2NodeResponse::Armed { .. }
1608        | C2NodeResponse::Spawned { .. }
1609        | C2NodeResponse::Activated { .. }
1610        | C2NodeResponse::Aborted { .. }
1611        | C2NodeResponse::ReplyChunkAccepted { .. }
1612        | C2NodeResponse::CallRejected { .. }
1613        | C2NodeResponse::DeliveryStageBegun { .. }
1614        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
1615        | C2NodeResponse::DeliveryCommitted { .. }
1616        | C2NodeResponse::DeliveryStageAborted { .. }
1617        | C2NodeResponse::WorkspaceInspected { .. }
1618        | C2NodeResponse::HostDirectoriesBrowsed { .. }
1619        | C2NodeResponse::WorkspaceFileRead { .. }
1620        | C2NodeResponse::WorkspaceFileWritten { .. }
1621        | C2NodeResponse::WorkspaceFileCreated { .. }
1622        | C2NodeResponse::WorkspaceDirectoryCreated { .. }
1623        | C2NodeResponse::GitHistoryRead { .. }
1624        | C2NodeResponse::GitDiffRead { .. }
1625        | C2NodeResponse::Controller { .. }
1626        | C2NodeResponse::SpawnAccepted { .. }
1627        | C2NodeResponse::SpawnSpecAccepted { .. }
1628        | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1629        | C2NodeResponse::ManagedWorktreeCleanup { .. }
1630        | C2NodeResponse::SessionRecordUpdated { .. }
1631        | C2NodeResponse::ProviderSessionIndexed { .. }
1632        | C2NodeResponse::NativeSessionIndexed { .. }
1633        | C2NodeResponse::SessionRecordResumed { .. }
1634        | C2NodeResponse::SessionRecordForgotten { .. }
1635        | C2NodeResponse::NativeSessionsCataloged { .. }
1636        | C2NodeResponse::NativeSessionsPaged { .. }
1637        | C2NodeResponse::NativeSessionPreviewed { .. }
1638        | C2NodeResponse::SessionRecordPreviewed { .. }
1639        | C2NodeResponse::HistoryDiscovered { .. }
1640        | C2NodeResponse::HistoryLoaded { .. }
1641        | C2NodeResponse::ContextPackForSessionRecordExported { .. }
1642        | C2NodeResponse::ContextPackExported { .. }
1643        | C2NodeResponse::ContextPackForgotten { .. }
1644        | C2NodeResponse::DurableContextPackResolved { .. }
1645        | C2NodeResponse::ContextPackBytesRead { .. }
1646        | C2NodeResponse::WorkspaceRegistered { .. }
1647        | C2NodeResponse::StandaloneWorkspaceCreated { .. }
1648        | C2NodeResponse::WorkspaceUnregistered { .. }
1649        | C2NodeResponse::WorktreeCreated { .. }
1650        | C2NodeResponse::WorktreeRemoved { .. }
1651        | C2NodeResponse::Accepted
1652        | C2NodeResponse::ShuttingDown => false,
1653    }
1654}
1655
1656fn c2_node_event_is_terminal_frame(event: &C2NodeEvent) -> bool {
1657    match event {
1658        C2NodeEvent::TerminalFrame { .. } => true,
1659        C2NodeEvent::HarnessMcpReadCall { .. }
1660        | C2NodeEvent::Control { .. }
1661        | C2NodeEvent::SessionRecordHistorySummarized { .. }
1662        | C2NodeEvent::ControllerChanged { .. }
1663        | C2NodeEvent::WorkspaceAdded { .. }
1664        | C2NodeEvent::WorkspaceRemoved { .. }
1665        | C2NodeEvent::SessionRecordUpserted { .. }
1666        | C2NodeEvent::SessionRecordRemoved { .. }
1667        | C2NodeEvent::ManagedWorktreeUpserted { .. }
1668        | C2NodeEvent::ManagedWorktreeRemoved { .. }
1669        | C2NodeEvent::AgentStream { .. }
1670        | C2NodeEvent::ResyncRequired { .. } => false,
1671    }
1672}
1673
1674fn c2_node_response_has_agent_stream_event(response: &C2NodeResponse) -> bool {
1675    match response {
1676        C2NodeResponse::Resync { events, .. } => events
1677            .iter()
1678            .any(|event| c2_node_event_is_agent_stream(&event.event)),
1679        C2NodeResponse::Snapshot { .. }
1680        | C2NodeResponse::Armed { .. }
1681        | C2NodeResponse::Spawned { .. }
1682        | C2NodeResponse::Activated { .. }
1683        | C2NodeResponse::Aborted { .. }
1684        | C2NodeResponse::ReplyChunkAccepted { .. }
1685        | C2NodeResponse::CallRejected { .. }
1686        | C2NodeResponse::DeliveryStageBegun { .. }
1687        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
1688        | C2NodeResponse::DeliveryCommitted { .. }
1689        | C2NodeResponse::DeliveryStageAborted { .. }
1690        | C2NodeResponse::WorkspaceInspected { .. }
1691        | C2NodeResponse::HostDirectoriesBrowsed { .. }
1692        | C2NodeResponse::WorkspaceFileRead { .. }
1693        | C2NodeResponse::WorkspaceFileWritten { .. }
1694        | C2NodeResponse::WorkspaceFileCreated { .. }
1695        | C2NodeResponse::WorkspaceDirectoryCreated { .. }
1696        | C2NodeResponse::GitHistoryRead { .. }
1697        | C2NodeResponse::GitDiffRead { .. }
1698        | C2NodeResponse::Controller { .. }
1699        | C2NodeResponse::SpawnAccepted { .. }
1700        | C2NodeResponse::SpawnSpecAccepted { .. }
1701        | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1702        | C2NodeResponse::ManagedWorktreeCleanup { .. }
1703        | C2NodeResponse::SessionRecordUpdated { .. }
1704        | C2NodeResponse::ProviderSessionIndexed { .. }
1705        | C2NodeResponse::NativeSessionIndexed { .. }
1706        | C2NodeResponse::SessionRecordResumed { .. }
1707        | C2NodeResponse::SessionRecordForgotten { .. }
1708        | C2NodeResponse::NativeSessionsCataloged { .. }
1709        | C2NodeResponse::NativeSessionsPaged { .. }
1710        | C2NodeResponse::NativeSessionPreviewed { .. }
1711        | C2NodeResponse::SessionRecordPreviewed { .. }
1712        | C2NodeResponse::HistoryDiscovered { .. }
1713        | C2NodeResponse::HistoryLoaded { .. }
1714        | C2NodeResponse::ContextPackForSessionRecordExported { .. }
1715        | C2NodeResponse::ContextPackExported { .. }
1716        | C2NodeResponse::ContextPackForgotten { .. }
1717        | C2NodeResponse::DurableContextPackResolved { .. }
1718        | C2NodeResponse::ContextPackBytesRead { .. }
1719        | C2NodeResponse::WorkspaceRegistered { .. }
1720        | C2NodeResponse::StandaloneWorkspaceCreated { .. }
1721        | C2NodeResponse::WorkspaceUnregistered { .. }
1722        | C2NodeResponse::WorktreeCreated { .. }
1723        | C2NodeResponse::WorktreeRemoved { .. }
1724        | C2NodeResponse::Accepted
1725        | C2NodeResponse::ShuttingDown => false,
1726    }
1727}
1728
1729fn c2_node_event_is_agent_stream(event: &C2NodeEvent) -> bool {
1730    match event {
1731        C2NodeEvent::AgentStream { .. } => true,
1732        C2NodeEvent::HarnessMcpReadCall { .. }
1733        | C2NodeEvent::Control { .. }
1734        | C2NodeEvent::SessionRecordHistorySummarized { .. }
1735        | C2NodeEvent::ControllerChanged { .. }
1736        | C2NodeEvent::WorkspaceAdded { .. }
1737        | C2NodeEvent::WorkspaceRemoved { .. }
1738        | C2NodeEvent::SessionRecordUpserted { .. }
1739        | C2NodeEvent::SessionRecordRemoved { .. }
1740        | C2NodeEvent::ManagedWorktreeUpserted { .. }
1741        | C2NodeEvent::ManagedWorktreeRemoved { .. }
1742        | C2NodeEvent::TerminalFrame { .. }
1743        | C2NodeEvent::ResyncRequired { .. } => false,
1744    }
1745}
1746
1747/// Coarse label for a dropped/tearing-down `C2NodeEvent` -- used only by
1748/// `control_owner`'s event-delivery warns, mirroring `hatchery-harness-
1749/// service`'s own `agent_stream_event_kind_label` (same "name every drop"
1750/// reasoning): a log line that fires once per backpressure episode still
1751/// needs to say WHAT kind of event was in flight when it fired.
1752fn c2_node_event_kind_label(event: &C2NodeEvent) -> &'static str {
1753    match event {
1754        C2NodeEvent::HarnessMcpReadCall { .. } => "harness-mcp-read-call",
1755        C2NodeEvent::Control { .. } => "control",
1756        C2NodeEvent::SessionRecordHistorySummarized { .. } => "session-record-history-summarized",
1757        C2NodeEvent::TerminalFrame { .. } => "terminal-frame",
1758        C2NodeEvent::AgentStream { .. } => "agent-stream",
1759        C2NodeEvent::ControllerChanged { .. } => "controller-changed",
1760        C2NodeEvent::WorkspaceAdded { .. } => "workspace-added",
1761        C2NodeEvent::WorkspaceRemoved { .. } => "workspace-removed",
1762        C2NodeEvent::SessionRecordUpserted { .. } => "session-record-upserted",
1763        C2NodeEvent::SessionRecordRemoved { .. } => "session-record-removed",
1764        C2NodeEvent::ManagedWorktreeUpserted { .. } => "managed-worktree-upserted",
1765        C2NodeEvent::ManagedWorktreeRemoved { .. } => "managed-worktree-removed",
1766        C2NodeEvent::ResyncRequired { .. } => "resync-required",
1767    }
1768}
1769
1770/// Whether THIS full-channel drop should be logged: `true` exactly once per
1771/// contiguous backlog episode (the first drop since `logged` was last
1772/// reset), flipping `*logged` so every later drop in the same episode
1773/// returns `false` -- see `reset_channel_full_drop_episode` for the
1774/// counterpart that ends an episode. Split out as a pure function, used by
1775/// both `control_owner`'s `harness_mcp` and terminal-frame delivery arms, so
1776/// the "once per episode, not per event" contract is directly testable
1777/// without needing a tracing subscriber to observe log call counts.
1778fn should_log_channel_full_drop(logged: &mut bool) -> bool {
1779    if *logged {
1780        return false;
1781    }
1782    *logged = true;
1783    true
1784}
1785
1786/// Ends a full-channel drop episode: a send that succeeds again means the
1787/// backlog cleared, so the next drop (if any) is a fresh episode and earns
1788/// its own log line.
1789fn reset_channel_full_drop_episode(logged: &mut bool) {
1790    *logged = false;
1791}
1792
1793fn c2_node_response_has_unix_bytes(response: &C2NodeResponse) -> bool {
1794    match response {
1795        C2NodeResponse::Snapshot { snapshot, .. } => node_snapshot_has_unix_bytes(snapshot),
1796        C2NodeResponse::Resync { snapshot, events, .. } => {
1797            node_snapshot_has_unix_bytes(snapshot)
1798                || events
1799                    .iter()
1800                    .any(|envelope| c2_node_event_has_unix_bytes(&envelope.event))
1801        }
1802        C2NodeResponse::WorkspaceInspected { inspection } => inspection
1803            .git
1804            .worktrees
1805            .iter()
1806            .any(|worktree| worktree.path.as_unix_bytes().is_some()),
1807        C2NodeResponse::HostDirectoriesBrowsed { listing } => {
1808            listing.directory.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1809                || listing.parent.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1810                || listing.entries.iter().any(|entry| entry.path.as_unix_bytes().is_some())
1811                || listing.next_after.as_ref().is_some_and(|path| path.as_unix_bytes().is_some())
1812        }
1813        C2NodeResponse::WorkspaceFileRead { .. }
1814        | C2NodeResponse::WorkspaceFileWritten { .. }
1815        | C2NodeResponse::WorkspaceFileCreated { .. }
1816        | C2NodeResponse::WorkspaceDirectoryCreated { .. }
1817        | C2NodeResponse::GitHistoryRead { .. }
1818        | C2NodeResponse::GitDiffRead { .. }
1819        | C2NodeResponse::DeliveryStageBegun { .. }
1820        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
1821        | C2NodeResponse::DeliveryCommitted { .. }
1822        | C2NodeResponse::DeliveryStageAborted { .. } => false,
1823        C2NodeResponse::WorkspaceRegistered { workspace }
1824        | C2NodeResponse::StandaloneWorkspaceCreated { workspace } => {
1825            workspace.canonical_root.as_unix_bytes().is_some()
1826        }
1827        C2NodeResponse::WorktreeCreated { worktree, workspace } => {
1828            worktree.path.as_unix_bytes().is_some()
1829                || workspace.canonical_root.as_unix_bytes().is_some()
1830        }
1831        C2NodeResponse::WorktreeRemoved { target_root, .. } => {
1832            target_root.as_unix_bytes().is_some()
1833        }
1834        C2NodeResponse::Armed { .. }
1835        | C2NodeResponse::Spawned { .. }
1836        | C2NodeResponse::Activated { .. }
1837        | C2NodeResponse::Aborted { .. }
1838        | C2NodeResponse::ReplyChunkAccepted { .. }
1839        | C2NodeResponse::CallRejected { .. }
1840        | C2NodeResponse::Controller { .. }
1841        | C2NodeResponse::SpawnAccepted { .. }
1842        | C2NodeResponse::SpawnSpecAccepted { .. }
1843        | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1844        | C2NodeResponse::ManagedWorktreeCleanup { .. }
1845        | C2NodeResponse::SessionRecordUpdated { .. }
1846        | C2NodeResponse::ProviderSessionIndexed { .. }
1847        | C2NodeResponse::NativeSessionIndexed { .. }
1848        | C2NodeResponse::SessionRecordResumed { .. }
1849        | C2NodeResponse::SessionRecordForgotten { .. }
1850        | C2NodeResponse::NativeSessionsCataloged { .. }
1851        | C2NodeResponse::NativeSessionsPaged { .. }
1852        | C2NodeResponse::NativeSessionPreviewed { .. }
1853        | C2NodeResponse::SessionRecordPreviewed { .. }
1854        | C2NodeResponse::HistoryDiscovered { .. }
1855        | C2NodeResponse::HistoryLoaded { .. }
1856        | C2NodeResponse::ContextPackForSessionRecordExported { .. }
1857        | C2NodeResponse::ContextPackExported { .. }
1858        | C2NodeResponse::ContextPackForgotten { .. }
1859        | C2NodeResponse::DurableContextPackResolved { .. }
1860        | C2NodeResponse::ContextPackBytesRead { .. }
1861        | C2NodeResponse::WorkspaceUnregistered { .. }
1862        | C2NodeResponse::Accepted
1863        | C2NodeResponse::ShuttingDown => false,
1864    }
1865}
1866
1867fn routed_response_has_unix_repository_path(response: &RoutedNodeResponse) -> bool {
1868    response
1869        .response
1870        .as_ref()
1871        .is_ok_and(c2_node_response_has_unix_repository_path)
1872}
1873
1874fn c2_node_response_has_unix_repository_path(response: &C2NodeResponse) -> bool {
1875    match response {
1876        C2NodeResponse::WorkspaceInspected { inspection } => {
1877            inspection.entries.iter().any(|entry| {
1878                entry.relative_path.as_unix_bytes().is_some()
1879            }) || inspection.git.status.iter().any(|entry| {
1880                entry.path.as_unix_bytes().is_some()
1881                    || entry.previous_path.as_ref().is_some_and(|path| {
1882                        path.as_unix_bytes().is_some()
1883                    })
1884            })
1885        }
1886        C2NodeResponse::WorkspaceFileRead { file }
1887        | C2NodeResponse::WorkspaceFileWritten { file }
1888        | C2NodeResponse::WorkspaceFileCreated { file } => file.path.as_unix_bytes().is_some(),
1889        C2NodeResponse::WorkspaceDirectoryCreated { entry, .. } => {
1890            entry.relative_path.as_unix_bytes().is_some()
1891        }
1892        C2NodeResponse::GitDiffRead { diff, .. } => diff
1893            .path
1894            .as_ref()
1895            .is_some_and(|path| path.as_unix_bytes().is_some()),
1896        C2NodeResponse::DurableContextPackResolved { .. }
1897        | C2NodeResponse::ContextPackBytesRead { .. } => false,
1898        C2NodeResponse::Snapshot { .. }
1899        | C2NodeResponse::Resync { .. }
1900        | C2NodeResponse::Armed { .. }
1901        | C2NodeResponse::Spawned { .. }
1902        | C2NodeResponse::Activated { .. }
1903        | C2NodeResponse::Aborted { .. }
1904        | C2NodeResponse::ReplyChunkAccepted { .. }
1905        | C2NodeResponse::CallRejected { .. }
1906        | C2NodeResponse::DeliveryStageBegun { .. }
1907        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
1908        | C2NodeResponse::DeliveryCommitted { .. }
1909        | C2NodeResponse::DeliveryStageAborted { .. }
1910        | C2NodeResponse::HostDirectoriesBrowsed { .. }
1911        | C2NodeResponse::GitHistoryRead { .. }
1912        | C2NodeResponse::Controller { .. }
1913        | C2NodeResponse::SpawnAccepted { .. }
1914        | C2NodeResponse::SpawnSpecAccepted { .. }
1915        | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1916        | C2NodeResponse::ManagedWorktreeCleanup { .. }
1917        | C2NodeResponse::SessionRecordUpdated { .. }
1918        | C2NodeResponse::ProviderSessionIndexed { .. }
1919        | C2NodeResponse::NativeSessionIndexed { .. }
1920        | C2NodeResponse::SessionRecordResumed { .. }
1921        | C2NodeResponse::SessionRecordForgotten { .. }
1922        | C2NodeResponse::NativeSessionsCataloged { .. }
1923        | C2NodeResponse::NativeSessionsPaged { .. }
1924        | C2NodeResponse::NativeSessionPreviewed { .. }
1925        | C2NodeResponse::SessionRecordPreviewed { .. }
1926        | C2NodeResponse::HistoryDiscovered { .. }
1927        | C2NodeResponse::HistoryLoaded { .. }
1928        | C2NodeResponse::ContextPackForSessionRecordExported { .. }
1929        | C2NodeResponse::ContextPackExported { .. }
1930        | C2NodeResponse::ContextPackForgotten { .. }
1931        | C2NodeResponse::WorkspaceRegistered { .. }
1932        | C2NodeResponse::StandaloneWorkspaceCreated { .. }
1933        | C2NodeResponse::WorkspaceUnregistered { .. }
1934        | C2NodeResponse::WorktreeCreated { .. }
1935        | C2NodeResponse::WorktreeRemoved { .. }
1936        | C2NodeResponse::Accepted
1937        | C2NodeResponse::ShuttingDown => false,
1938    }
1939}
1940
1941fn routed_response_requires_workspace_file_read(response: &RoutedNodeResponse) -> bool {
1942    response.response.as_ref().is_ok_and(|response| {
1943        matches!(response, C2NodeResponse::WorkspaceFileRead { .. })
1944    })
1945}
1946
1947fn routed_response_requires_workspace_file_write(response: &RoutedNodeResponse) -> bool {
1948    response.response.as_ref().is_ok_and(|response| {
1949        matches!(response, C2NodeResponse::WorkspaceFileWritten { .. })
1950    })
1951}
1952
1953fn routed_response_requires_workspace_entry_create(response: &RoutedNodeResponse) -> bool {
1954    response.response.as_ref().is_ok_and(|response| {
1955        response.requires_workspace_entry_create_capability()
1956    })
1957}
1958
1959fn routed_response_requires_git_read(response: &RoutedNodeResponse) -> bool {
1960    response.response.as_ref().is_ok_and(|response| {
1961        matches!(response, C2NodeResponse::GitHistoryRead { .. } | C2NodeResponse::GitDiffRead { .. })
1962    })
1963}
1964
1965fn routed_response_requires_host_directory_browse(response: &RoutedNodeResponse) -> bool {
1966    match &response.response {
1967        Ok(response) => response.requires_host_directory_browse_capability(),
1968        Err(failure) => failure.requires_host_directory_browse_capability(),
1969    }
1970}
1971
1972fn routed_response_requires_standalone_workspace_lifecycle(
1973    response: &RoutedNodeResponse,
1974) -> bool {
1975    matches!(
1976        &response.response,
1977        Ok(C2NodeResponse::StandaloneWorkspaceCreated { .. })
1978    )
1979}
1980
1981fn routed_response_requires_provider_session_reference_index(
1982    response: &RoutedNodeResponse,
1983) -> bool {
1984    matches!(
1985        &response.response,
1986        Ok(C2NodeResponse::ProviderSessionIndexed { .. })
1987    )
1988}
1989
1990fn routed_response_requires_spawn_spec(response: &RoutedNodeResponse) -> bool {
1991    response.response.as_ref().is_ok_and(|response| {
1992        matches!(response,
1993            C2NodeResponse::SpawnSpecAccepted { .. }
1994            | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
1995        )
1996    })
1997}
1998
1999fn routed_response_requires_spawn_profile_revision(response: &RoutedNodeResponse) -> bool {
2000    response.response.as_ref().is_ok_and(|response| {
2001        matches!(response,
2002            C2NodeResponse::SpawnSpecAccepted { .. }
2003            | C2NodeResponse::Spawned { .. }
2004            | C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
2005        )
2006    })
2007}
2008
2009fn routed_response_requires_worktree_selection(response: &RoutedNodeResponse) -> bool {
2010    response.response.as_ref().is_ok_and(|response| {
2011        matches!(response, C2NodeResponse::SpawnSpecAccepted { receipt }
2012            if receipt.target.worktree_id.is_some())
2013            || c2_node_response_has_managed_worktree(response)
2014    })
2015}
2016
2017fn routed_response_requires_managed_worktree(response: &RoutedNodeResponse) -> bool {
2018    response
2019        .response
2020        .as_ref()
2021        .is_ok_and(c2_node_response_has_managed_worktree)
2022}
2023
2024fn c2_node_response_has_managed_worktree(response: &C2NodeResponse) -> bool {
2025    match response {
2026        C2NodeResponse::Snapshot { snapshot, .. } => c2_snapshot_has_managed_worktree(snapshot),
2027        C2NodeResponse::Resync { snapshot, events, .. } => {
2028            c2_snapshot_has_managed_worktree(snapshot)
2029                || events
2030                    .iter()
2031                    .any(|event| c2_node_event_is_managed_worktree(&event.event))
2032        }
2033        C2NodeResponse::WorkspaceRegistered { workspace }
2034        | C2NodeResponse::StandaloneWorkspaceCreated { workspace }
2035        | C2NodeResponse::WorktreeCreated { workspace, .. } => {
2036            c2_workspace_has_managed_worktree_metadata(workspace)
2037        }
2038        C2NodeResponse::ManagedWorktreeSpawnAccepted { .. }
2039        | C2NodeResponse::ManagedWorktreeCleanup { .. } => true,
2040        _ => false,
2041    }
2042}
2043
2044fn c2_node_event_is_managed_worktree(event: &C2NodeEvent) -> bool {
2045    match event {
2046        C2NodeEvent::WorkspaceAdded { workspace } => {
2047            c2_workspace_has_managed_worktree_metadata(workspace)
2048        }
2049        C2NodeEvent::ManagedWorktreeUpserted { .. }
2050        | C2NodeEvent::ManagedWorktreeRemoved { .. } => true,
2051        _ => false,
2052    }
2053}
2054
2055fn c2_workspace_has_managed_worktree_metadata(
2056    workspace: &hatchery_core::C2WorkspaceSnapshot,
2057) -> bool {
2058    workspace.worktree_service_mode.is_some()
2059        || workspace.managed_worktree_profiles.is_some()
2060}
2061
2062fn c2_snapshot_has_managed_worktree(
2063    snapshot: &hatchery_core::C2NodeSnapshot,
2064) -> bool {
2065    !snapshot.managed_worktrees.is_empty()
2066        || snapshot
2067            .workspaces
2068            .iter()
2069            .any(c2_workspace_has_managed_worktree_metadata)
2070}
2071
2072fn node_snapshot_has_unix_bytes(snapshot: &hatchery_core::C2NodeSnapshot) -> bool {
2073    snapshot
2074        .workspaces
2075        .iter()
2076        .any(|workspace| workspace.canonical_root.as_unix_bytes().is_some())
2077}
2078
2079fn routed_event_has_unix_bytes(event: &RoutedNodeEvent) -> bool {
2080    c2_node_event_has_unix_bytes(&event.event)
2081}
2082
2083fn c2_node_event_has_unix_bytes(event: &C2NodeEvent) -> bool {
2084    match event {
2085        C2NodeEvent::WorkspaceAdded { workspace } => {
2086            workspace.canonical_root.as_unix_bytes().is_some()
2087        }
2088        C2NodeEvent::HarnessMcpReadCall { .. }
2089        | C2NodeEvent::Control { .. }
2090        | C2NodeEvent::SessionRecordHistorySummarized { .. }
2091        | C2NodeEvent::TerminalFrame { .. }
2092        | C2NodeEvent::ControllerChanged { .. }
2093        | C2NodeEvent::WorkspaceRemoved { .. }
2094        | C2NodeEvent::SessionRecordUpserted { .. }
2095        | C2NodeEvent::SessionRecordRemoved { .. }
2096        | C2NodeEvent::ManagedWorktreeUpserted { .. }
2097        | C2NodeEvent::ManagedWorktreeRemoved { .. }
2098        | C2NodeEvent::AgentStream { .. }
2099        | C2NodeEvent::ResyncRequired { .. } => false,
2100    }
2101}
2102
2103fn provider_text_is_legacy(value: &str) -> bool {
2104    hatchery_core::AgentId::new(value)
2105        .is_ok_and(|provider| provider_id_is_legacy(&provider))
2106}
2107
2108fn status_has_open_provider_id(status: &hatchery_core::StatusResponse) -> bool {
2109    status.nodes.values().filter_map(|node| node.inventory.as_ref()).any(|inventory| {
2110        inventory.enabled_providers.iter().any(|provider| !provider_id_is_legacy(provider))
2111            || inventory.provider_runtime_statuses.iter()
2112                .any(|runtime| !provider_id_is_legacy(runtime.provider()))
2113            || inventory.provider_contracts.iter()
2114                .any(|contract| !provider_id_is_legacy(&contract.provider))
2115            || inventory.provider_adapter_contracts.iter()
2116                .any(|contract| !provider_id_is_legacy(&contract.provider))
2117            || inventory.workspaces.values().any(|workspace| {
2118                workspace.sessions.iter()
2119                    .any(|session| !provider_text_is_legacy(&session.agent_id))
2120            })
2121            || inventory.managed_sessions.iter()
2122                .any(|record| !provider_id_is_legacy(&record.provider))
2123    })
2124}
2125
2126fn status_has_managed_worktree(status: &hatchery_core::StatusResponse) -> bool {
2127    status.nodes.values().any(|node| {
2128        node.inventory
2129            .as_ref()
2130            .is_some_and(|inventory| {
2131                !inventory.managed_worktrees.is_empty()
2132                    || inventory.workspaces.values().any(|workspace| {
2133                        workspace.worktree_service_mode.is_some()
2134                            || workspace.managed_worktree_profiles.is_some()
2135                    })
2136            })
2137    })
2138}
2139
2140fn status_has_child_environment_profile(
2141    status: &hatchery_core::StatusResponse,
2142) -> bool {
2143    status.nodes.values().any(|node| {
2144        node.inventory.as_ref().is_some_and(|inventory| {
2145            inventory.managed_sessions.iter().any(|record| {
2146                record.environment_profile.is_some()
2147            })
2148        })
2149    })
2150}
2151
2152fn status_has_session_bundle_materialization(
2153    status: &hatchery_core::StatusResponse,
2154) -> bool {
2155    status.nodes.values().any(|node| {
2156        node.inventory.as_ref().is_some_and(|inventory| {
2157            inventory.managed_sessions.iter().any(|record| record.bundle.is_some())
2158        })
2159    })
2160}
2161
2162fn topology_has_open_provider_id(topology: &C2Topology) -> bool {
2163    topology.nodes.iter().any(|node| {
2164        node.provider_contracts.iter()
2165            .any(|contract| !provider_id_is_legacy(&contract.provider))
2166            || node.provider_adapter_contracts.iter()
2167                .any(|contract| !provider_id_is_legacy(&contract.provider))
2168            || node.provider_runtime_statuses.iter()
2169                .any(|runtime| !provider_id_is_legacy(runtime.provider()))
2170    })
2171}
2172
2173fn workspace_has_open_provider_id(
2174    workspace: &hatchery_core::C2WorkspaceSnapshot,
2175) -> bool {
2176    workspace.sessions.iter()
2177        .any(|session| !provider_id_is_legacy(&session.agent_id))
2178}
2179
2180fn snapshot_has_open_provider_id(
2181    snapshot: &hatchery_core::C2NodeSnapshot,
2182) -> bool {
2183    snapshot.enabled_providers.iter().any(|provider| !provider_id_is_legacy(provider))
2184        || snapshot.provider_runtime_statuses.iter()
2185            .any(|runtime| !provider_id_is_legacy(runtime.provider()))
2186        || snapshot.workspaces.iter().any(workspace_has_open_provider_id)
2187        || snapshot.session_records.iter()
2188            .any(c2_managed_record_has_open_provider_id)
2189}
2190
2191fn context_receipt_has_open_provider_id(
2192    context: &gate4agent_node_protocol::ResolvedContextPackReceipt,
2193) -> bool {
2194    !provider_id_is_legacy(&context.lineage.source_provider)
2195}
2196
2197fn c2_managed_record_has_open_provider_id(
2198    record: &hatchery_core::C2ManagedSessionRecord,
2199) -> bool {
2200    !provider_id_is_legacy(&record.provider)
2201        || record
2202            .context
2203            .as_ref()
2204            .is_some_and(context_receipt_has_open_provider_id)
2205}
2206
2207fn c2_event_has_open_provider_id(event: &C2NodeEvent) -> bool {
2208    match event {
2209        C2NodeEvent::WorkspaceAdded { workspace } => workspace_has_open_provider_id(workspace),
2210        C2NodeEvent::SessionRecordUpserted { record } => {
2211            c2_managed_record_has_open_provider_id(record)
2212        }
2213        C2NodeEvent::HarnessMcpReadCall { .. }
2214        | C2NodeEvent::Control { .. }
2215        | C2NodeEvent::SessionRecordHistorySummarized { .. }
2216        | C2NodeEvent::TerminalFrame { .. }
2217        | C2NodeEvent::ControllerChanged { .. }
2218        | C2NodeEvent::WorkspaceRemoved { .. }
2219        | C2NodeEvent::SessionRecordRemoved { .. }
2220        | C2NodeEvent::ManagedWorktreeUpserted { .. }
2221        | C2NodeEvent::ManagedWorktreeRemoved { .. }
2222        | C2NodeEvent::AgentStream { .. }
2223        | C2NodeEvent::ResyncRequired { .. } => false,
2224    }
2225}
2226
2227fn routed_event_has_open_provider_id(event: &RoutedNodeEvent) -> bool {
2228    c2_event_has_open_provider_id(&event.event)
2229}
2230
2231fn c2_node_response_has_open_provider_id(response: &C2NodeResponse) -> bool {
2232    match response {
2233        C2NodeResponse::Snapshot { snapshot, .. } => snapshot_has_open_provider_id(snapshot),
2234        C2NodeResponse::Resync { snapshot, events, .. } => {
2235            snapshot_has_open_provider_id(snapshot)
2236                || events.iter().any(|event| c2_event_has_open_provider_id(&event.event))
2237        }
2238        C2NodeResponse::SessionRecordUpdated { record }
2239        | C2NodeResponse::ProviderSessionIndexed { record }
2240        | C2NodeResponse::NativeSessionIndexed { record, .. }
2241        | C2NodeResponse::SessionRecordResumed { record, .. } => {
2242            c2_managed_record_has_open_provider_id(record)
2243        }
2244        C2NodeResponse::SpawnSpecAccepted { receipt }
2245        | C2NodeResponse::Spawned { receipt, .. } => {
2246            !provider_id_is_legacy(&receipt.provider)
2247                || receipt
2248                    .context
2249                    .as_ref()
2250                    .is_some_and(context_receipt_has_open_provider_id)
2251        }
2252        C2NodeResponse::ManagedWorktreeSpawnAccepted { receipt } => {
2253            !provider_id_is_legacy(&receipt.spawn.provider)
2254                || receipt
2255                    .spawn
2256                    .context
2257                    .as_ref()
2258                    .is_some_and(context_receipt_has_open_provider_id)
2259        }
2260        C2NodeResponse::ContextPackExported { context }
2261        | C2NodeResponse::ContextPackForSessionRecordExported { context, .. }
2262        | C2NodeResponse::DurableContextPackResolved { context } => {
2263            context_receipt_has_open_provider_id(context)
2264        }
2265        C2NodeResponse::NativeSessionsCataloged { route, .. }
2266        | C2NodeResponse::NativeSessionsPaged { route, .. } => {
2267            !provider_id_is_legacy(&route.provider)
2268        }
2269        C2NodeResponse::NativeSessionPreviewed { selection, .. } => {
2270            !provider_id_is_legacy(&selection.route.provider)
2271        }
2272        C2NodeResponse::WorkspaceRegistered { workspace }
2273        | C2NodeResponse::StandaloneWorkspaceCreated { workspace }
2274        | C2NodeResponse::WorktreeCreated { workspace, .. } => {
2275            workspace_has_open_provider_id(workspace)
2276        }
2277        C2NodeResponse::Armed { .. }
2278        | C2NodeResponse::Activated { .. }
2279        | C2NodeResponse::Aborted { .. }
2280        | C2NodeResponse::ReplyChunkAccepted { .. }
2281        | C2NodeResponse::CallRejected { .. }
2282        | C2NodeResponse::WorkspaceInspected { .. }
2283        | C2NodeResponse::DeliveryStageBegun { .. }
2284        | C2NodeResponse::DeliveryBlobChunkAccepted { .. }
2285        | C2NodeResponse::DeliveryCommitted { .. }
2286        | C2NodeResponse::DeliveryStageAborted { .. }
2287        | C2NodeResponse::HostDirectoriesBrowsed { .. }
2288        | C2NodeResponse::WorkspaceFileRead { .. }
2289        | C2NodeResponse::WorkspaceFileWritten { .. }
2290        | C2NodeResponse::WorkspaceFileCreated { .. }
2291        | C2NodeResponse::WorkspaceDirectoryCreated { .. }
2292        | C2NodeResponse::GitHistoryRead { .. }
2293        | C2NodeResponse::GitDiffRead { .. }
2294        | C2NodeResponse::Controller { .. }
2295        | C2NodeResponse::SpawnAccepted { .. }
2296        | C2NodeResponse::ManagedWorktreeCleanup { .. }
2297        | C2NodeResponse::SessionRecordForgotten { .. }
2298        | C2NodeResponse::SessionRecordPreviewed { .. }
2299        | C2NodeResponse::HistoryDiscovered { .. }
2300        | C2NodeResponse::HistoryLoaded { .. }
2301        | C2NodeResponse::ContextPackForgotten { .. }
2302        | C2NodeResponse::ContextPackBytesRead { .. }
2303        | C2NodeResponse::WorkspaceUnregistered { .. }
2304        | C2NodeResponse::WorktreeRemoved { .. }
2305        | C2NodeResponse::Accepted
2306        | C2NodeResponse::ShuttingDown => false,
2307    }
2308}
2309
2310fn routed_response_has_open_provider_id(response: &RoutedNodeResponse) -> bool {
2311    response.response.as_ref().is_ok_and(c2_node_response_has_open_provider_id)
2312}
2313
2314async fn read_server_frame<S>(
2315    pipe: &mut S,
2316    limit: usize,
2317) -> Result<C2ServerFrame, C2ControlError>
2318where
2319    S: tokio::io::AsyncRead + Unpin,
2320{
2321    Ok(read_json_frame_limited_body_timeout(pipe, limit, FRAME_BODY_DEADLINE).await?)
2322}
2323
2324async fn control_reader<R>(mut reader: R, owner: mpsc::Sender<OwnerInput>)
2325where
2326    R: tokio::io::AsyncRead + Unpin,
2327{
2328    loop {
2329        match read_json_frame_limited_body_timeout(
2330            &mut reader,
2331            MAX_C2_SERVER_FRAME_BYTES,
2332            FRAME_BODY_DEADLINE,
2333        ).await {
2334            Ok(frame) => if owner.send(OwnerInput::Frame(frame)).await.is_err() { return; },
2335            Err(_) => { let _ = owner.send(OwnerInput::Closed).await; return; }
2336        }
2337    }
2338}
2339
2340async fn control_writer<W>(
2341    mut writer: W,
2342    mut frames: mpsc::Receiver<C2ClientFrame>,
2343    owner: mpsc::Sender<OwnerInput>,
2344) where
2345    W: tokio::io::AsyncWrite + Unpin,
2346{
2347    while let Some(frame) = frames.recv().await {
2348        if !matches!(timeout(FRAME_BODY_DEADLINE, write_json_frame_limited(
2349            &mut writer,
2350            &frame,
2351            MAX_C2_CLIENT_FRAME_BYTES,
2352        )).await, Ok(Ok(()))) {
2353            break;
2354        }
2355    }
2356    let _ = owner.send(OwnerInput::Closed).await;
2357}
2358
2359async fn control_owner<E>(
2360    mut commands: mpsc::Receiver<ControlCommand>,
2361    mut browser_commands: mpsc::Receiver<BrowserControlCommand>,
2362    events: E,
2363    topology: watch::Sender<Arc<C2Topology>>,
2364    writer: mpsc::Sender<C2ClientFrame>,
2365    mut incoming: mpsc::Receiver<OwnerInput>,
2366    path_capabilities: NegotiatedPathCapabilities,
2367) where
2368    E: Into<EventDelivery>,
2369{
2370    let events = events.into();
2371    let mut next_request_id = 1_u64;
2372    let mut pending = BTreeMap::new();
2373    let mut harness_mcp_receiver_gone = false;
2374    // Set the moment a `harness_mcp`/`regular` (terminal-frame) delivery
2375    // first drops an event for a full channel, cleared the moment a send
2376    // succeeds again -- so a sustained burst of drops during one backlog
2377    // episode logs exactly once (at the episode's start) instead of once
2378    // per dropped event, mirroring `harness_mcp_receiver_gone`'s own
2379    // one-shot-per-episode discipline for the channel-closed case right
2380    // next to it.
2381    let mut harness_mcp_channel_full_logged = false;
2382    let mut terminal_frame_channel_full_logged = false;
2383    let mut regular_event_channel_full_logged = false;
2384    let mut loss_reason = C2ConnectionLossReason::Shutdown;
2385    loop {
2386        // Each frame arm pre-sets `Protocol` before validating, so a frame that
2387        // passed must not leave that label behind: a later break for any other
2388        // reason (caller shutdown, closed command queue) was being logged as a
2389        // "protocol violation". Reset per iteration.
2390        loss_reason = C2ConnectionLossReason::Shutdown;
2391        tokio::select! {
2392            browser_cmd = browser_commands.recv() => {
2393                let Some(browser_cmd) = browser_cmd else { break; };
2394                let frame = C2ClientFrame::Browser(browser_cmd.cmd);
2395                if writer.send(frame).await.is_err() {
2396                    let _ = browser_cmd.reply.send(Err(C2ControlError::Closed));
2397                    loss_reason = C2ConnectionLossReason::PipeClosed;
2398                    break;
2399                }
2400                let _ = browser_cmd.reply.send(Ok(()));
2401            }
2402            command = commands.recv() => {
2403                let Some(command) = command else { break; };
2404                if let Err(error) = reject_unnegotiated_outbound_path(
2405                    &command.request,
2406                    path_capabilities,
2407                ) {
2408                    let _ = command.reply.send(Err(error));
2409                    continue;
2410                }
2411                let request_id = C2RequestId(next_request_id);
2412                let Some(next) = next_request_id.checked_add(1) else {
2413                    let _ = command.reply.send(Err(C2ControlError::RequestIdExhausted));
2414                    break;
2415                };
2416                next_request_id = next;
2417                let expected_request = command.request.clone();
2418                let frame = C2ClientFrame::Request(C2RequestEnvelope {
2419                    request_id,
2420                    request: RoutedNodeRequest {
2421                        route: command.route.clone(),
2422                        request: command.request,
2423                    },
2424                    correlation_id: command.correlation_id,
2425                });
2426                pending.insert(
2427                    request_id,
2428                    (command.route, expected_request, command.reply),
2429                );
2430                if writer.send(frame).await.is_err() {
2431                    loss_reason = C2ConnectionLossReason::PipeClosed;
2432                    break;
2433                }
2434            }
2435            input = incoming.recv() => {
2436                match input {
2437                    Some(OwnerInput::Frame(C2ServerFrame::Reply(reply))) => {
2438                        loss_reason = C2ConnectionLossReason::Protocol;
2439                        let Some((expected_route, expected_request, waiter)) =
2440                            pending.remove(&reply.request_id)
2441                        else {
2442                            break;
2443                        };
2444                        if reply.result.as_ref().is_ok_and(|routed| {
2445                            // The all-zero expected incarnation is the operator
2446                            // wildcard: accept whatever live incarnation the node
2447                            // reports (used by HQ spawns that are robust to node
2448                            // restarts). Otherwise the incarnation must match.
2449                            let wildcard = expected_route.expected_incarnation_id
2450                                == hatchery_core::NodeIncarnationId::from_bytes([0; 16]);
2451                            routed.node_id != expected_route.node_id
2452                                || (!wildcard
2453                                    && routed.incarnation_id
2454                                        != expected_route.expected_incarnation_id)
2455                        }) {
2456                            let _ = waiter.send(Err(C2ControlError::Protocol(
2457                                "C2 reply route or node incarnation does not match the request"
2458                                    .to_owned(),
2459                            )));
2460                            break;
2461                        }
2462                        if let Err(message) = validate_provider_session_index_response(
2463                            &expected_request,
2464                            &reply.result,
2465                        ) {
2466                            let _ = waiter.send(Err(C2ControlError::Protocol(
2467                                message.to_owned(),
2468                            )));
2469                            break;
2470                        }
2471                        if let Err(message) = validate_session_record_context_export_response(
2472                            &expected_request,
2473                            &reply.result,
2474                        ) {
2475                            let _ = waiter.send(Err(C2ControlError::Protocol(
2476                                message.to_owned(),
2477                            )));
2478                            break;
2479                        }
2480                        if let Err(message) = validate_native_session_response(
2481                            &expected_request,
2482                            &reply.result,
2483                        ) {
2484                            let _ = waiter.send(Err(C2ControlError::Protocol(
2485                                message.to_owned(),
2486                            )));
2487                            break;
2488                        }
2489                        if let Err(message) = validate_workspace_content_response(
2490                            &expected_request,
2491                            &reply.result,
2492                        ) {
2493                            let _ = waiter.send(Err(C2ControlError::Protocol(
2494                                message.to_owned(),
2495                            )));
2496                            break;
2497                        }
2498                        if let Err(message) = validate_session_task_response(
2499                            &expected_request,
2500                            &reply.result,
2501                        ) {
2502                            let _ = waiter.send(Err(C2ControlError::Protocol(
2503                                message.to_owned(),
2504                            )));
2505                            break;
2506                        }
2507                        if let Err(message) = validate_managed_worktree_spawn_v2_response(
2508                            &expected_request,
2509                            &reply.result,
2510                        ) {
2511                            let _ = waiter.send(Err(C2ControlError::Protocol(
2512                                message.to_owned(),
2513                            )));
2514                            break;
2515                        }
2516                        if let Err(message) = validate_harness_mcp_response(
2517                            &expected_request,
2518                            &reply.result,
2519                        ) {
2520                            let _ = waiter.send(Err(C2ControlError::Protocol(
2521                                message.to_owned(),
2522                            )));
2523                            break;
2524                        }
2525                        if !path_capabilities.terminal_frame_events
2526                            && reply
2527                                .result
2528                                .as_ref()
2529                                .is_ok_and(routed_response_has_terminal_frame_event)
2530                        {
2531                            let _ = waiter.send(Err(C2ControlError::Protocol(
2532                                TERMINAL_FRAME_EVENTS_NOT_NEGOTIATED.to_owned(),
2533                            )));
2534                            break;
2535                        }
2536                        if !path_capabilities.agent_stream_events
2537                            && reply
2538                                .result
2539                                .as_ref()
2540                                .is_ok_and(routed_response_has_agent_stream_event)
2541                        {
2542                            let _ = waiter.send(Err(C2ControlError::Protocol(
2543                                AGENT_STREAM_EVENTS_NOT_NEGOTIATED.to_owned(),
2544                            )));
2545                            break;
2546                        }
2547                        if !path_capabilities.agent_progress_snapshot
2548                            && reply
2549                                .result
2550                                .as_ref()
2551                                .is_ok_and(routed_response_has_agent_progress)
2552                        {
2553                            let _ = waiter.send(Err(C2ControlError::Protocol(
2554                                "agent progress snapshots require negotiated C2 capability"
2555                                    .to_owned(),
2556                            )));
2557                            break;
2558                        }
2559                        if !path_capabilities.provider_ids_open
2560                            && reply
2561                                .result
2562                                .as_ref()
2563                                .is_ok_and(routed_response_has_open_provider_id)
2564                        {
2565                            let _ = waiter.send(Err(C2ControlError::Protocol(
2566                                OPEN_PROVIDER_ID_NOT_NEGOTIATED.to_owned(),
2567                            )));
2568                            break;
2569                        }
2570                        if !path_capabilities.opaque_host_paths
2571                            && reply
2572                                .result
2573                                .as_ref()
2574                                .is_ok_and(routed_response_has_unix_bytes)
2575                        {
2576                            let _ = waiter.send(Err(C2ControlError::Protocol(
2577                                OPAQUE_UNIX_PATH_NOT_NEGOTIATED.to_owned(),
2578                            )));
2579                            break;
2580                        }
2581                        if !path_capabilities.repository_paths
2582                            && reply
2583                                .result
2584                                .as_ref()
2585                                .is_ok_and(routed_response_has_unix_repository_path)
2586                        {
2587                            let _ = waiter.send(Err(C2ControlError::Protocol(
2588                                REPOSITORY_PATH_NOT_NEGOTIATED.to_owned(),
2589                            )));
2590                            break;
2591                        }
2592                        if !path_capabilities.workspace_file_read
2593                            && reply
2594                                .result
2595                                .as_ref()
2596                                .is_ok_and(routed_response_requires_workspace_file_read)
2597                        {
2598                            let _ = waiter.send(Err(C2ControlError::Protocol(
2599                                WORKSPACE_FILE_READ_NOT_NEGOTIATED.to_owned(),
2600                            )));
2601                            break;
2602                        }
2603                        if !path_capabilities.workspace_file_write
2604                            && reply
2605                                .result
2606                                .as_ref()
2607                                .is_ok_and(routed_response_requires_workspace_file_write)
2608                        {
2609                            let _ = waiter.send(Err(C2ControlError::Protocol(
2610                                "workspace file writes require negotiated C2 capability".to_owned(),
2611                            )));
2612                            break;
2613                        }
2614                        if !path_capabilities.workspace_entry_create
2615                            && reply
2616                                .result
2617                                .as_ref()
2618                                .is_ok_and(routed_response_requires_workspace_entry_create)
2619                        {
2620                            let _ = waiter.send(Err(C2ControlError::Protocol(
2621                                WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED.to_owned(),
2622                            )));
2623                            break;
2624                        }
2625                        if !path_capabilities.git_read
2626                            && reply
2627                                .result
2628                                .as_ref()
2629                                .is_ok_and(routed_response_requires_git_read)
2630                        {
2631                            let _ = waiter.send(Err(C2ControlError::Protocol(
2632                                "git reads require negotiated C2 capability".to_owned(),
2633                            )));
2634                            break;
2635                        }
2636                        if !path_capabilities.host_directory_browse
2637                            && reply
2638                                .result
2639                                .as_ref()
2640                                .is_ok_and(routed_response_requires_host_directory_browse)
2641                        {
2642                            let _ = waiter.send(Err(C2ControlError::Protocol(
2643                                HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED.to_owned(),
2644                            )));
2645                            break;
2646                        }
2647                        if !path_capabilities.standalone_workspace_lifecycle
2648                            && reply.result.as_ref().is_ok_and(
2649                                routed_response_requires_standalone_workspace_lifecycle,
2650                            )
2651                        {
2652                            let _ = waiter.send(Err(C2ControlError::Protocol(
2653                                STANDALONE_WORKSPACE_LIFECYCLE_NOT_NEGOTIATED.to_owned(),
2654                            )));
2655                            break;
2656                        }
2657                        if !path_capabilities.provider_session_reference_index
2658                            && matches!(
2659                                expected_request,
2660                                NodeRequest::IndexProviderSession { .. }
2661                            )
2662                            && reply.result.as_ref().is_ok_and(
2663                                routed_response_requires_provider_session_reference_index,
2664                            )
2665                        {
2666                            let _ = waiter.send(Err(C2ControlError::Protocol(
2667                                PROVIDER_SESSION_REFERENCE_INDEX_NOT_NEGOTIATED.to_owned(),
2668                            )));
2669                            break;
2670                        }
2671                        if !path_capabilities.spawn_spec_defaults_overrides
2672                            && reply
2673                                .result
2674                                .as_ref()
2675                                .is_ok_and(routed_response_requires_spawn_spec)
2676                        {
2677                            let _ = waiter.send(Err(C2ControlError::Protocol(
2678                                SPAWN_SPEC_NOT_NEGOTIATED.to_owned(),
2679                            )));
2680                            break;
2681                        }
2682                        if !path_capabilities.spawn_profile_revision
2683                            && reply
2684                                .result
2685                                .as_ref()
2686                                .is_ok_and(routed_response_requires_spawn_profile_revision)
2687                        {
2688                            let _ = waiter.send(Err(C2ControlError::Protocol(
2689                                SPAWN_PROFILE_REVISION_NOT_NEGOTIATED.to_owned(),
2690                            )));
2691                            break;
2692                        }
2693                        if !path_capabilities.worktree_selection
2694                            && reply
2695                                .result
2696                                .as_ref()
2697                                .is_ok_and(routed_response_requires_worktree_selection)
2698                        {
2699                            let _ = waiter.send(Err(C2ControlError::Protocol(
2700                                WORKTREE_SELECTION_NOT_NEGOTIATED.to_owned(),
2701                            )));
2702                            break;
2703                        }
2704                        if !path_capabilities.managed_worktree_lifecycle
2705                            && reply
2706                                .result
2707                                .as_ref()
2708                                .is_ok_and(routed_response_requires_managed_worktree)
2709                        {
2710                            let _ = waiter.send(Err(C2ControlError::Protocol(
2711                                MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED.to_owned(),
2712                            )));
2713                            break;
2714                        }
2715                        if !path_capabilities.child_environment_profile
2716                            && reply.result.as_ref().is_ok_and(|routed| {
2717                                routed.response.as_ref().is_ok_and(
2718                                    C2NodeResponse::requires_child_environment_profile_capability,
2719                                )
2720                            })
2721                        {
2722                            let _ = waiter.send(Err(C2ControlError::Protocol(
2723                                CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED.to_owned(),
2724                            )));
2725                            break;
2726                        }
2727                        if !path_capabilities.session_bundle_materialization
2728                            && reply.result.as_ref().is_ok_and(|routed| {
2729                                routed.response.as_ref().is_ok_and(
2730                                    C2NodeResponse::requires_session_bundle_materialization_capability,
2731                                )
2732                            })
2733                        {
2734                            let _ = waiter.send(Err(C2ControlError::Protocol(
2735                                SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED.to_owned(),
2736                            )));
2737                            break;
2738                        }
2739                        if !path_capabilities.history_context_pack
2740                            && reply
2741                                .result
2742                                .as_ref()
2743                                .is_ok_and(routed_response_requires_history_context_pack)
2744                        {
2745                            let _ = waiter.send(Err(C2ControlError::Protocol(
2746                                HISTORY_CONTEXT_PACK_NOT_NEGOTIATED.to_owned(),
2747                            )));
2748                            break;
2749                        }
2750                        if !path_capabilities.session_record_context_export
2751                            && reply.result.as_ref().is_ok_and(|routed| {
2752                                routed.response.as_ref().is_ok_and(
2753                                    C2NodeResponse::requires_session_record_context_export_capability,
2754                                )
2755                            })
2756                        {
2757                            let _ = waiter.send(Err(C2ControlError::Protocol(
2758                                SESSION_RECORD_CONTEXT_EXPORT_NOT_NEGOTIATED.to_owned(),
2759                            )));
2760                            break;
2761                        }
2762                        if !path_capabilities.control_detail
2763                            && reply.result.as_ref().is_ok_and(|routed| {
2764                                routed.response.as_ref().is_ok_and(
2765                                    C2NodeResponse::requires_control_detail_capability,
2766                                )
2767                            })
2768                        {
2769                            let _ = waiter.send(Err(C2ControlError::Protocol(
2770                                CONTROL_DETAIL_NOT_NEGOTIATED.to_owned(),
2771                            )));
2772                            break;
2773                        }
2774                        if !path_capabilities.session_task_correlation
2775                            && reply.result.as_ref().is_ok_and(|routed| {
2776                                routed.response.as_ref().is_ok_and(
2777                                    C2NodeResponse::requires_session_task_correlation_capability,
2778                                )
2779                            })
2780                        {
2781                            let _ = waiter.send(Err(C2ControlError::Protocol(
2782                                SESSION_TASK_CORRELATION_NOT_NEGOTIATED.to_owned(),
2783                            )));
2784                            break;
2785                        }
2786                        if !path_capabilities.native_session_catalog
2787                            && reply
2788                                .result
2789                                .as_ref()
2790                                .is_ok_and(routed_response_requires_native_session_catalog)
2791                        {
2792                            let _ = waiter.send(Err(C2ControlError::Protocol(
2793                                "native session catalog capability was not negotiated".to_owned(),
2794                            )));
2795                            break;
2796                        }
2797                        if !path_capabilities.native_session_catalog_paging
2798                            && reply
2799                                .result
2800                                .as_ref()
2801                                .is_ok_and(routed_response_requires_native_session_catalog_paging)
2802                        {
2803                            let _ = waiter.send(Err(C2ControlError::Protocol(
2804                                "native session catalog paging capability was not negotiated".to_owned(),
2805                            )));
2806                            break;
2807                        }
2808                        if !path_capabilities.native_session_preview
2809                            && reply
2810                                .result
2811                                .as_ref()
2812                                .is_ok_and(routed_response_requires_native_session_preview)
2813                        {
2814                            let _ = waiter.send(Err(C2ControlError::Protocol(
2815                                "native session preview capability was not negotiated".to_owned(),
2816                            )));
2817                            break;
2818                        }
2819                        if !path_capabilities.native_session_index
2820                            && reply
2821                                .result
2822                                .as_ref()
2823                                .is_ok_and(routed_response_requires_native_session_index)
2824                        {
2825                            let _ = waiter.send(Err(C2ControlError::Protocol(
2826                                "native session index capability was not negotiated".to_owned(),
2827                            )));
2828                            break;
2829                        }
2830                        if !path_capabilities.harness_mcp_read_proxy
2831                            && reply
2832                                .result
2833                                .as_ref()
2834                                .is_ok_and(routed_response_requires_harness_mcp_proxy)
2835                        {
2836                            let _ = waiter.send(Err(C2ControlError::Protocol(
2837                                "harness MCP read proxy capability was not negotiated".to_owned(),
2838                            )));
2839                            break;
2840                        }
2841                        let _ = waiter.send(reply.result.map_err(C2ControlError::Relay));
2842                    }
2843                    Some(OwnerInput::Frame(C2ServerFrame::Event(event))) => {
2844                        loss_reason = C2ConnectionLossReason::Protocol;
2845                        if event.event.requires_harness_mcp_proxy_capability()
2846                            && (!path_capabilities.harness_mcp_read_proxy
2847                                || !event.event.harness_mcp_contract_is_valid_at(
2848                                    match current_unix_ms() { Ok(value) => value, Err(_) => break },
2849                                ))
2850                        {
2851                            break;
2852                        }
2853                        if !path_capabilities.terminal_frame_events
2854                            && c2_node_event_is_terminal_frame(&event.event)
2855                        {
2856                            tracing::warn!(
2857                                kind = c2_node_event_kind_label(&event.event),
2858                                sequence = event.cursor.sequence,
2859                                cursor = ?event.cursor,
2860                                reason = "terminal frame events capability not negotiated",
2861                                "dropping event and tearing down the C2 control connection",
2862                            );
2863                            break;
2864                        }
2865                        if !path_capabilities.agent_stream_events
2866                            && c2_node_event_is_agent_stream(&event.event)
2867                        {
2868                            tracing::warn!(
2869                                kind = c2_node_event_kind_label(&event.event),
2870                                sequence = event.cursor.sequence,
2871                                cursor = ?event.cursor,
2872                                reason = "agent stream events capability not negotiated",
2873                                "dropping event and tearing down the C2 control connection",
2874                            );
2875                            break;
2876                        }
2877                        if !path_capabilities.provider_ids_open
2878                            && routed_event_has_open_provider_id(&event)
2879                        {
2880                            break;
2881                        }
2882                        if !path_capabilities.opaque_host_paths
2883                            && routed_event_has_unix_bytes(&event)
2884                        {
2885                            break;
2886                        }
2887                        if !path_capabilities.managed_worktree_lifecycle
2888                            && c2_node_event_is_managed_worktree(&event.event)
2889                        {
2890                            break;
2891                        }
2892                        if !path_capabilities.worktree_selection
2893                            && c2_node_event_is_managed_worktree(&event.event)
2894                        {
2895                            break;
2896                        }
2897                        if !path_capabilities.child_environment_profile
2898                            && event
2899                                .event
2900                                .requires_child_environment_profile_capability()
2901                        {
2902                            break;
2903                        }
2904                        if !path_capabilities.session_bundle_materialization
2905                            && event
2906                                .event
2907                                .requires_session_bundle_materialization_capability()
2908                        {
2909                            break;
2910                        }
2911                        if !path_capabilities.history_context_pack
2912                            && event.event.requires_history_context_pack_capability()
2913                        {
2914                            break;
2915                        }
2916                        if !path_capabilities.session_task_correlation
2917                            && event.event.requires_session_task_correlation_capability()
2918                        {
2919                            break;
2920                        }
2921                        if !path_capabilities.control_detail
2922                            && event.event.requires_control_detail_capability()
2923                        {
2924                            break;
2925                        }
2926                        if event.event.requires_harness_mcp_proxy_capability() {
2927                            // A full or gone harness-MCP receiver must never
2928                            // tear down the whole C2 connection -- every
2929                            // other in-flight request (Arm, Activate,
2930                            // Abort, ...) would otherwise be answered
2931                            // `Closed` seconds later with no log line
2932                            // anywhere naming why.
2933                            let sequence = event.cursor.sequence;
2934                            let cursor = event.cursor;
2935                            match events.harness_mcp.try_send(event) {
2936                                Ok(()) => {
2937                                    reset_channel_full_drop_episode(&mut harness_mcp_channel_full_logged);
2938                                }
2939                                Err(mpsc::error::TrySendError::Full(_)) => {
2940                                    if should_log_channel_full_drop(&mut harness_mcp_channel_full_logged) {
2941                                        tracing::warn!(
2942                                            kind = "harness-mcp-read-call",
2943                                            sequence,
2944                                            cursor = ?cursor,
2945                                            reason = "harness_mcp event channel full",
2946                                            "dropped harness MCP event; C2 control connection stays open",
2947                                        );
2948                                    }
2949                                }
2950                                Err(mpsc::error::TrySendError::Closed(_)) => {
2951                                    if !harness_mcp_receiver_gone {
2952                                        harness_mcp_receiver_gone = true;
2953                                        tracing::warn!(
2954                                            kind = "harness-mcp-read-call",
2955                                            sequence,
2956                                            cursor = ?cursor,
2957                                            reason = "harness_mcp event receiver dropped",
2958                                            "no harness MCP event subscriber; further harness MCP events on this connection are dropped silently",
2959                                        );
2960                                    }
2961                                }
2962                            }
2963                        } else if c2_node_event_is_terminal_frame(&event.event) {
2964                            let kind = c2_node_event_kind_label(&event.event);
2965                            let sequence = event.cursor.sequence;
2966                            let cursor = event.cursor;
2967                            match events.regular.try_send(event) {
2968                                Ok(()) => {
2969                                    reset_channel_full_drop_episode(&mut terminal_frame_channel_full_logged);
2970                                }
2971                                Err(mpsc::error::TrySendError::Full(_)) => {
2972                                    if should_log_channel_full_drop(&mut terminal_frame_channel_full_logged) {
2973                                        tracing::warn!(
2974                                            kind,
2975                                            sequence,
2976                                            cursor = ?cursor,
2977                                            reason = "regular event channel full",
2978                                            "dropped terminal frame event; C2 control connection stays open",
2979                                        );
2980                                    }
2981                                }
2982                                Err(mpsc::error::TrySendError::Closed(_)) => {
2983                                    tracing::warn!(
2984                                        kind,
2985                                        sequence,
2986                                        cursor = ?cursor,
2987                                        reason = "regular event receiver dropped",
2988                                        "dropping event and tearing down the C2 control connection",
2989                                    );
2990                                    loss_reason = C2ConnectionLossReason::RegularEventBackpressure;
2991                                    break;
2992                                }
2993                            }
2994                        } else {
2995                            let kind = c2_node_event_kind_label(&event.event);
2996                            let sequence = event.cursor.sequence;
2997                            let cursor = event.cursor;
2998                            match events.regular.try_send(event) {
2999                                Ok(()) => {
3000                                    reset_channel_full_drop_episode(&mut regular_event_channel_full_logged);
3001                                }
3002                                Err(mpsc::error::TrySendError::Full(_)) => {
3003                                    if should_log_channel_full_drop(&mut regular_event_channel_full_logged) {
3004                                        tracing::warn!(
3005                                            kind,
3006                                            sequence,
3007                                            cursor = ?cursor,
3008                                            reason = "regular event channel full",
3009                                            "dropped regular event; C2 control connection stays open",
3010                                        );
3011                                    }
3012                                }
3013                                Err(mpsc::error::TrySendError::Closed(_)) => {
3014                                    tracing::warn!(
3015                                        kind,
3016                                        sequence,
3017                                        cursor = ?cursor,
3018                                        reason = "regular event receiver dropped",
3019                                        "dropping event and tearing down the C2 control connection",
3020                                    );
3021                                    loss_reason = C2ConnectionLossReason::RegularEventBackpressure;
3022                                    break;
3023                                }
3024                            }
3025                        }
3026                    }
3027                    Some(OwnerInput::Frame(C2ServerFrame::Browser(ev))) => {
3028                        loss_reason = C2ConnectionLossReason::Protocol;
3029                        match events.browser.try_send(ev) {
3030                            Ok(()) => {}
3031                            Err(mpsc::error::TrySendError::Full(_)) => {
3032                                tracing::warn!("dropped browser frame; browser event channel full");
3033                            }
3034                            Err(mpsc::error::TrySendError::Closed(_)) => {
3035                                loss_reason = C2ConnectionLossReason::RegularEventBackpressure;
3036                                break;
3037                            }
3038                        }
3039                    }
3040                    Some(OwnerInput::Frame(C2ServerFrame::Topology(next))) => {
3041                        loss_reason = C2ConnectionLossReason::Protocol;
3042                        if !path_capabilities.provider_ids_open
3043                            && topology_has_open_provider_id(&next)
3044                        {
3045                            break;
3046                        }
3047                        if topology.borrow().as_ref() != &next {
3048                            topology.send_replace(Arc::new(next));
3049                        }
3050                    }
3051                    Some(OwnerInput::Frame(C2ServerFrame::Challenge(_) | C2ServerFrame::Hello(_) | C2ServerFrame::Rejected(_))) => {
3052                        loss_reason = C2ConnectionLossReason::Protocol;
3053                        break;
3054                    }
3055                    Some(OwnerInput::Closed) | None => {
3056                        loss_reason = C2ConnectionLossReason::PipeClosed;
3057                        break;
3058                    }
3059                }
3060            }
3061        }
3062    }
3063    if !pending.is_empty() {
3064        tracing::warn!(
3065            reason = %loss_reason,
3066            pending = pending.len(),
3067            "C2 control connection lost: failing every request still pending",
3068        );
3069    }
3070    for (_, (_, _, waiter)) in pending {
3071        let _ = waiter.send(Err(C2ControlError::ConnectionLost { reason: loss_reason }));
3072    }
3073}
3074
3075pub(crate) fn c2_proof(
3076    token: &str,
3077    direction: C2AuthDirection,
3078    client_nonce: &[u8; C2_AUTH_NONCE_BYTES],
3079    server_nonce: &[u8; C2_AUTH_NONCE_BYTES],
3080    compatibility: Option<(&ClientCompatibilityOffer, &NegotiatedC2ControlCompatibility)>,
3081) -> Result<[u8; 32], C2ControlError> {
3082    let transcript = match compatibility {
3083        Some((offer, selected)) => c2_bound_auth_transcript(
3084            direction,
3085            client_nonce,
3086            server_nonce,
3087            offer,
3088            selected,
3089        ).map_err(|error| C2ControlError::Authentication(error.to_string()))?,
3090        None => c2_auth_transcript(direction, client_nonce, server_nonce),
3091    };
3092    local_hmac_sha256(token.as_bytes(), &transcript)
3093        .map_err(C2ControlError::Authentication)
3094}
3095
3096#[cfg(windows)]
3097fn validate_endpoint(endpoint: &str) -> Result<(), C2ControlError> {
3098    if !endpoint.starts_with(r"\\.\pipe\") || endpoint.len() <= r"\\.\pipe\".len() || endpoint.len() > 1024 {
3099        return Err(C2ControlError::InvalidEndpoint);
3100    }
3101    Ok(())
3102}
3103
3104#[cfg(unix)]
3105fn validate_endpoint(endpoint: &str) -> Result<(), C2ControlError> {
3106    use std::os::unix::ffi::OsStrExt;
3107    use std::path::Path;
3108
3109    let path = Path::new(endpoint);
3110    if endpoint.is_empty() || path.as_os_str().as_bytes().len() > 103 || !path.is_absolute()
3111        || path.file_name().is_none()
3112    {
3113        return Err(C2ControlError::InvalidEndpoint);
3114    }
3115    Ok(())
3116}
3117
3118/// Tunnel control target for HQ. Not a substitute for [`validate_endpoint`]:
3119/// a raw `host:port` string is still [`C2ControlError::InvalidEndpoint`] there.
3120fn validate_tunnel_control_target(address: std::net::SocketAddr) -> Result<(), C2ControlError> {
3121    if address.port() == 0 || address.ip().is_unspecified() || address.ip().is_multicast() {
3122        return Err(C2ControlError::InvalidTunnelControl);
3123    }
3124    Ok(())
3125}
3126
3127fn validate_token(token: &str) -> Result<(), C2ControlError> {
3128    if token.is_empty() || token.len() > 4096 || !token.bytes().all(|byte| matches!(byte, 0x21..=0x7e)) {
3129        return Err(C2ControlError::InvalidToken);
3130    }
3131    Ok(())
3132}
3133
3134#[derive(Debug, Error)]
3135pub enum C2ControlError {
3136    #[cfg_attr(windows, error("C2 control endpoint is not a bounded local named pipe"))]
3137    #[cfg_attr(unix, error("C2 control endpoint is not a bounded absolute local endpoint"))]
3138    InvalidEndpoint,
3139    #[error("C2 tunnel control address must be a concrete host and a nonzero port")]
3140    InvalidTunnelControl,
3141    #[error("C2 token must contain 1..=4096 visible ASCII bytes without whitespace")]
3142    InvalidToken,
3143    #[error("C2 control I/O failed: {0}")]
3144    Io(#[from] io::Error),
3145    #[error(transparent)]
3146    Frame(#[from] FrameError),
3147    #[error("C2 authentication timed out")]
3148    AuthenticationTimedOut,
3149    #[error("C2 authentication failed: {0}")]
3150    Authentication(String),
3151    #[error("C2 control protocol failed: {0}")]
3152    Protocol(String),
3153    #[error("build stamp mismatch: local={local} remote={remote}")]
3154    BuildStampMismatch { local: String, remote: String },
3155    #[error("C2 relay rejected request: {0:?}")]
3156    Relay(C2RelayFailure),
3157    #[error("C2 control connection closed")]
3158    Closed,
3159    /// The `control_owner` loop tore the connection down for a NAMED
3160    /// reason (as opposed to `Closed`, which also covers a local caller
3161    /// dropping its own handle/queue -- never emitted by `control_owner`
3162    /// itself). Every request pending at teardown gets this, once, with
3163    /// the reason logged alongside the pending count.
3164    #[error("C2 control connection lost: {reason}")]
3165    ConnectionLost { reason: C2ConnectionLossReason },
3166    #[error("C2 control request queue is full")]
3167    QueueFull,
3168    #[error("C2 request ID space exhausted")]
3169    RequestIdExhausted,
3170}
3171
3172/// Why `control_owner` tore its connection down, attached to every request
3173/// still pending at that moment (`C2ControlError::ConnectionLost`). Logged
3174/// once at the teardown point instead of being silently swallowed.
3175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3176pub enum C2ConnectionLossReason {
3177    /// The reader or writer half of the physical pipe ended (I/O error,
3178    /// EOF, or a downstream send failure into the writer task).
3179    PipeClosed,
3180    /// The relay sent a frame or reply that violates the negotiated
3181    /// protocol: an unmatched request id, a route/incarnation mismatch, an
3182    /// unnegotiated-capability leak, an invalid topology frame, or a
3183    /// repeated handshake frame after authentication.
3184    Protocol,
3185    /// The `regular` event consumer is gone: its channel closed because
3186    /// the receiver was dropped. A merely full (but alive) `regular`
3187    /// channel never reaches this reason -- the offending event is
3188    /// dropped and the connection stays open instead.
3189    RegularEventBackpressure,
3190    /// The local caller side shut the connection down deliberately (every
3191    /// `C2ControlHandle` clone dropped) or the request id space was
3192    /// exhausted.
3193    Shutdown,
3194}
3195
3196impl std::fmt::Display for C2ConnectionLossReason {
3197    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3198        formatter.write_str(match self {
3199            Self::PipeClosed => "pipe closed",
3200            Self::Protocol => "protocol violation",
3201            Self::RegularEventBackpressure => "regular event consumer fell behind",
3202            Self::Shutdown => "local caller shut the connection down",
3203        })
3204    }
3205}
3206
3207#[cfg(all(test, windows))]
3208mod tests {
3209    use super::*;
3210
3211    #[test]
3212    fn harness_mcp_c2_client_capability_and_correlation_are_exact() {
3213        let reservation_id = gate4agent_node_protocol::HarnessMcpReservationId::new(
3214            format!("hmcpres_{}", "a".repeat(24)),
3215        ).unwrap();
3216        let activation_digest = gate4agent_node_protocol::HarnessMcpActivationDigest::new(
3217            format!("sha256:{}", "b".repeat(64)),
3218        ).unwrap();
3219        let request = NodeRequest::AbortHarnessMcpReservation {
3220            reservation_id: reservation_id.clone(),
3221            activation_digest: activation_digest.clone(),
3222        };
3223        assert!(reject_unnegotiated_outbound_path(
3224            &request,
3225            NegotiatedPathCapabilities::default(),
3226        ).is_err());
3227        let capabilities = NegotiatedPathCapabilities {
3228            harness_mcp_read_proxy: true,
3229            spawn_spec_defaults_overrides: true,
3230            ..NegotiatedPathCapabilities::default()
3231        };
3232        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
3233
3234        let routed = RoutedNodeResponse {
3235            node_id: gate4agent_node_protocol::NodeId::new("node-a").unwrap(),
3236            incarnation_id: gate4agent_node_protocol::NodeIncarnationId::from_bytes([1; 16]),
3237            response: Ok(C2NodeResponse::Aborted {
3238                reservation_id: reservation_id.clone(),
3239                activation_digest: activation_digest.clone(),
3240            }),
3241        };
3242        assert!(validate_harness_mcp_response(&request, &Ok(routed.clone())).is_ok());
3243        let mut mismatch = routed;
3244        mismatch.response = Ok(C2NodeResponse::Aborted {
3245            reservation_id: gate4agent_node_protocol::HarnessMcpReservationId::new(
3246                format!("hmcpres_{}", "c".repeat(24)),
3247            ).unwrap(),
3248            activation_digest,
3249        });
3250        assert!(validate_harness_mcp_response(&request, &Ok(mismatch)).is_err());
3251    }
3252
3253    #[test]
3254    fn acp_control_verbs_are_admitted_only_when_negotiated_and_refuse_by_own_name() {
3255        let session = gate4agent_node_protocol::SessionAddress {
3256            workspace_id: WorkspaceId::new("primary").unwrap(),
3257            session: gate4agent_node_protocol::SessionKey {
3258                instance_id: AgentInstanceId(3),
3259                generation: SessionGeneration(1),
3260            },
3261        };
3262        let requests = [
3263            NodeRequest::ResolveInteraction {
3264                session: session.clone(),
3265                correlation_id: "acp-correlation-1".to_owned(),
3266                response: ProviderInteractionResponse::ApproveOnce,
3267            },
3268            NodeRequest::SetSessionMode {
3269                session: session.clone(),
3270                mode_id: "plan".to_owned(),
3271            },
3272            NodeRequest::SetSessionConfigOption {
3273                session: session.clone(),
3274                option_id: "reasoning-effort".to_owned(),
3275                value_json: "\"high\"".to_owned(),
3276            },
3277            NodeRequest::SetSessionModel {
3278                session: session.clone(),
3279                model_id: "claude-opus".to_owned(),
3280            },
3281        ];
3282        for request in &requests {
3283            assert_eq!(
3284                request.required_capability(),
3285                Some(C2_ACP_CONTROL_CAPABILITY),
3286            );
3287            let refusal = reject_unnegotiated_outbound_path(
3288                request,
3289                NegotiatedPathCapabilities::default(),
3290            )
3291            .unwrap_err();
3292            assert!(
3293                matches!(
3294                    &refusal,
3295                    C2ControlError::Protocol(message)
3296                        if message == ACP_CONTROL_NOT_NEGOTIATED
3297                ),
3298                "unexpected refusal for {request:?}: {refusal:?}",
3299            );
3300            let capabilities = NegotiatedPathCapabilities {
3301                acp_control: true,
3302                ..NegotiatedPathCapabilities::default()
3303            };
3304            assert!(reject_unnegotiated_outbound_path(request, capabilities).is_ok());
3305        }
3306    }
3307
3308    #[test]
3309    fn unrecognized_required_capability_refuses_naming_itself_not_workspace_file_read() {
3310        // Every capability `NodeRequest::required_capability()` can return
3311        // today has a dedicated arm now, so this reaches straight for the
3312        // fallback that stands in for tomorrow's capability -- exactly the
3313        // shape of the defect that shipped: a capability string with no
3314        // dedicated arm yet must never borrow another capability's message.
3315        let message = unnegotiated_capability_refusal(Some("future-control-v9"));
3316        assert_ne!(message, WORKSPACE_FILE_READ_NOT_NEGOTIATED);
3317        assert!(
3318            message.contains("future-control-v9"),
3319            "refusal must name the unrecognised capability, got: {message}",
3320        );
3321
3322        // The one capability the fallback must never again be mistaken for.
3323        let workspace_file_read_message =
3324            unnegotiated_capability_refusal(Some(C2_WORKSPACE_FILE_READ_CAPABILITY));
3325        assert_eq!(workspace_file_read_message, WORKSPACE_FILE_READ_NOT_NEGOTIATED);
3326        assert_ne!(workspace_file_read_message, message);
3327    }
3328
3329    use hatchery_core::{
3330        ArchitectureId, C2GitSnapshot, C2WorkspaceInspection, C2WorkspaceSnapshot,
3331        C2ServerChallenge, HostDescriptor, HostDirectoryEntry, HostDirectoryListing,
3332        NodeCursor, NodeId, OpaqueHostPath,
3333        OperatingSystemId, PathEncoding, PathSemantics, PathStyle, RepositoryPath,
3334        ResolvedSpawnReceipt, SpawnDeadlineMs, SpawnFieldProvenance,
3335        SpawnIdempotencyKey, SpawnOverrides, SpawnProfileId, SpawnProfileRevision,
3336        SpawnPromptMetadata, SpawnRequiredCapabilities, SpawnResolutionProvenance,
3337        SpawnSpec, SpawnTarget, WorkspaceFileContent, WorkspaceFileRead,
3338        WorktreeServiceMode,
3339    };
3340    use gate4agent_node_protocol::{
3341        GitStatusEntry, NodeIncarnationId, SessionMode, WorkspaceEntry, WorkspaceEntryKind,
3342        WorkspaceId,
3343    };
3344    use gate4agent_types::{
3345        AgentInstanceId, ProviderInteractionResponse, ProviderSessionIdentity,
3346        ProviderSessionKey, PtyScreenState, SessionGeneration, TerminalFrame, TerminalSize,
3347    };
3348    use std::sync::atomic::{AtomicU64, Ordering};
3349    use std::time::{SystemTime, UNIX_EPOCH};
3350    use tokio::net::windows::named_pipe::ServerOptions;
3351
3352    fn unique_control_endpoint() -> String {
3353        static NEXT: AtomicU64 = AtomicU64::new(1);
3354        let now = SystemTime::now()
3355            .duration_since(UNIX_EPOCH)
3356            .unwrap()
3357            .as_nanos();
3358        format!(
3359            r"\\.\pipe\hatchery-c2-client-strict-{}-{now}-{}",
3360            std::process::id(),
3361            NEXT.fetch_add(1, Ordering::Relaxed),
3362        )
3363    }
3364
3365    fn routed_event(sequence: u64) -> RoutedNodeEvent {
3366        RoutedNodeEvent {
3367            node_id: NodeId::new("node-a").unwrap(),
3368            cursor: NodeCursor {
3369                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3370                sequence,
3371            },
3372            event: hatchery_core::C2NodeEvent::ResyncRequired {
3373                oldest_available_sequence: sequence,
3374            },
3375        }
3376    }
3377
3378    fn event(sequence: u64) -> OwnerInput {
3379        OwnerInput::Frame(C2ServerFrame::Event(routed_event(sequence)))
3380    }
3381
3382    fn ordinary_state_event(sequence: u64) -> OwnerInput {
3383        OwnerInput::Frame(C2ServerFrame::Event(RoutedNodeEvent {
3384            node_id: NodeId::new("node-a").unwrap(),
3385            cursor: NodeCursor {
3386                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3387                sequence,
3388            },
3389            event: C2NodeEvent::WorkspaceRemoved {
3390                workspace_id: WorkspaceId::new("retired").unwrap(),
3391            },
3392        }))
3393    }
3394
3395    fn terminal_event(sequence: u64) -> OwnerInput {
3396        OwnerInput::Frame(C2ServerFrame::Event(RoutedNodeEvent {
3397            node_id: NodeId::new("node-a").unwrap(),
3398            cursor: NodeCursor {
3399                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3400                sequence,
3401            },
3402            event: terminal_node_event(sequence),
3403        }))
3404    }
3405
3406    fn harness_mcp_event(call_index: usize, deadline_unix_ms: u64) -> OwnerInput {
3407        OwnerInput::Frame(C2ServerFrame::Event(RoutedNodeEvent {
3408            node_id: NodeId::new("node-a").unwrap(),
3409            cursor: NodeCursor {
3410                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3411                sequence: 41,
3412            },
3413            event: C2NodeEvent::HarnessMcpReadCall {
3414                reservation_id: gate4agent_node_protocol::HarnessMcpReservationId::new(
3415                    format!("hmcpres_{:024x}", 1),
3416                ).unwrap(),
3417                activation_digest: gate4agent_node_protocol::HarnessMcpActivationDigest::new(
3418                    format!("sha256:{}", "b".repeat(64)),
3419                ).unwrap(),
3420                record_id: gate4agent_node_protocol::SessionRecordId::new(
3421                    "session-001",
3422                ).unwrap(),
3423                session: gate4agent_node_protocol::SessionAddress {
3424                    workspace_id: WorkspaceId::new("primary").unwrap(),
3425                    session: gate4agent_node_protocol::SessionKey {
3426                        instance_id: AgentInstanceId(7),
3427                        generation: SessionGeneration(2),
3428                    },
3429                },
3430                call_id: gate4agent_node_protocol::HarnessMcpCallId::new(
3431                    format!("hmcpcall_{call_index:024x}"),
3432                ).unwrap(),
3433                request: gate4agent_node_protocol::HarnessMcpOpaquePayloadV1 {
3434                    content_type:
3435                        gate4agent_node_protocol::HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
3436                    body: br#"{"kind":"context-get"}"#.to_vec(),
3437                },
3438                deadline_unix_ms,
3439            },
3440        }))
3441    }
3442
3443    fn terminal_node_event(sequence: u64) -> C2NodeEvent {
3444        C2NodeEvent::TerminalFrame {
3445            address: gate4agent_node_protocol::SessionAddress {
3446                workspace_id: WorkspaceId::new("primary").unwrap(),
3447                session: gate4agent_node_protocol::SessionKey {
3448                    instance_id: AgentInstanceId(7),
3449                    generation: SessionGeneration(2),
3450                },
3451            },
3452            frame: TerminalFrame {
3453                sequence,
3454                size: TerminalSize { rows: 24, columns: 80 },
3455                cursor_row: 1,
3456                cursor_column: 2,
3457                contents: "ready".to_owned(),
3458                formatted: b"ready".to_vec(),
3459                scrollback_formatted: Vec::new(),
3460                alternate_screen: false,
3461                mouse_protocol_enabled: false,
3462                mouse_protocol_encoding: Default::default(),
3463                produced_at_unix_ms: 0,
3464                screen_state: PtyScreenState::default(),
3465                bracketed_paste: None,
3466            },
3467        }
3468    }
3469
3470    fn route() -> NodeRoute {
3471        NodeRoute {
3472            node_id: NodeId::new("node-a").unwrap(),
3473            expected_incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3474        }
3475    }
3476
3477    #[tokio::test]
3478    async fn linearized_start_request_is_bounded_closed_and_exactly_correlated() {
3479        let (commands, mut receiver) = mpsc::channel(1);
3480        let first = start_control_request(
3481            &commands,
3482            all_path_capabilities(),
3483            route(),
3484            NodeRequest::Snapshot,
3485        ).unwrap();
3486        let full = start_control_request(
3487            &commands,
3488            all_path_capabilities(),
3489            route(),
3490            NodeRequest::Snapshot,
3491        );
3492        assert!(matches!(full, Err(C2ControlError::QueueFull)));
3493
3494        let queued = receiver.recv().await.unwrap();
3495        assert_eq!(queued.route, route());
3496        assert!(matches!(queued.request, NodeRequest::Snapshot));
3497        let exact = RoutedNodeResponse {
3498            node_id: route().node_id,
3499            incarnation_id: route().expected_incarnation_id,
3500            response: Ok(C2NodeResponse::Accepted),
3501        };
3502        queued.reply.send(Ok(exact.clone())).unwrap();
3503        assert_eq!(first.finish().await.unwrap(), exact);
3504
3505        let (closed_commands, closed_receiver) = mpsc::channel(1);
3506        drop(closed_receiver);
3507        let closed = start_control_request(
3508            &closed_commands,
3509            all_path_capabilities(),
3510            route(),
3511            NodeRequest::Snapshot,
3512        );
3513        assert!(matches!(closed, Err(C2ControlError::Closed)));
3514    }
3515
3516    fn spawn_spec(node_id: &str) -> SpawnSpec {
3517        SpawnSpec {
3518            target: SpawnTarget {
3519                node_id: NodeId::new(node_id).unwrap(),
3520                workspace_id: WorkspaceId::new("primary").unwrap(),
3521                worktree_id: None,
3522            },
3523            profile_id: SpawnProfileId::new("default").unwrap(),
3524            expected_profile_revision:
3525                SpawnProfileRevision::new("default.r1").unwrap(),
3526            overrides: SpawnOverrides::default(),
3527            deadline_ms: SpawnDeadlineMs::new(5_000).unwrap(),
3528            idempotency_key: SpawnIdempotencyKey::new("spawn-1").unwrap(),
3529            required_capabilities: SpawnRequiredCapabilities::default(),
3530        }
3531    }
3532
3533    fn spawn_receipt() -> ResolvedSpawnReceipt {
3534        ResolvedSpawnReceipt {
3535            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3536            session: gate4agent_node_protocol::SessionAddress {
3537                workspace_id: WorkspaceId::new("primary").unwrap(),
3538                session: gate4agent_node_protocol::SessionKey {
3539                    instance_id: AgentInstanceId(7),
3540                    generation: SessionGeneration(2),
3541                },
3542            },
3543            target: spawn_spec("node-a").target,
3544            profile_id: SpawnProfileId::new("default").unwrap(),
3545            profile_revision: SpawnProfileRevision::new("r1").unwrap(),
3546            provider: hatchery_core::AgentId::new("claude").unwrap(),
3547            mode: SessionMode::Pty,
3548            terminal_size: TerminalSize { rows: 24, columns: 80 },
3549            prompt: SpawnPromptMetadata { present: false, byte_len: 0 },
3550            bundle_id: None,
3551            bundle: None,
3552            context_id: None,
3553            context: None,
3554            environment_profile: None,
3555            deadline_ms: SpawnDeadlineMs::new(5_000).unwrap(),
3556            idempotency_key: SpawnIdempotencyKey::new("spawn-1").unwrap(),
3557            required_capabilities: SpawnRequiredCapabilities::default(),
3558            provenance: SpawnResolutionProvenance {
3559                provider: SpawnFieldProvenance::Profile,
3560                mode: SpawnFieldProvenance::Profile,
3561                terminal_size: SpawnFieldProvenance::Profile,
3562                prompt: SpawnFieldProvenance::Profile,
3563                bundle_id: SpawnFieldProvenance::Profile,
3564                context_id: SpawnFieldProvenance::Profile,
3565                environment_profile_id: SpawnFieldProvenance::Profile,
3566            },
3567            harness_mcp_proxy: None,
3568        }
3569    }
3570
3571    fn no_path_capabilities() -> NegotiatedPathCapabilities {
3572        NegotiatedPathCapabilities {
3573            opaque_host_paths: false,
3574            repository_paths: false,
3575            workspace_file_read: false,
3576            host_directory_browse: false,
3577            standalone_workspace_lifecycle: false,
3578            provider_session_reference_index: false,
3579            provider_ids_open: false,
3580            spawn_spec_defaults_overrides: false,
3581            worktree_selection: false,
3582            managed_worktree_lifecycle: false,
3583            managed_worktree_spawn_v2: false,
3584            child_environment_profile: false,
3585            session_bundle_materialization: false,
3586            history_context_pack: false,
3587            native_session_catalog: false,
3588            native_session_catalog_paging: false,
3589            native_session_index: false,
3590            native_session_preview: false,
3591            terminal_frame_events: false,
3592            ..Default::default()
3593        }
3594    }
3595
3596    fn all_path_capabilities() -> NegotiatedPathCapabilities {
3597        NegotiatedPathCapabilities {
3598            opaque_host_paths: true,
3599            repository_paths: true,
3600            workspace_file_read: true,
3601            host_directory_browse: true,
3602            standalone_workspace_lifecycle: true,
3603            provider_session_reference_index: true,
3604            provider_ids_open: true,
3605            spawn_spec_defaults_overrides: true,
3606            spawn_profile_revision: true,
3607            worktree_selection: true,
3608            managed_worktree_lifecycle: true,
3609            managed_worktree_spawn_v2: true,
3610            child_environment_profile: true,
3611            session_bundle_materialization: true,
3612            history_context_pack: true,
3613            session_record_context_export: true,
3614            native_session_catalog: true,
3615            native_session_catalog_paging: true,
3616            native_session_index: true,
3617            native_session_preview: true,
3618            terminal_frame_events: true,
3619            agent_progress_snapshot: true,
3620            ..Default::default()
3621        }
3622    }
3623
3624    #[test]
3625    fn session_record_context_export_requires_both_capabilities_and_exact_echo() {
3626        let session = gate4agent_node_protocol::SessionAddress {
3627            workspace_id: WorkspaceId::new("primary").unwrap(),
3628            session: gate4agent_node_protocol::SessionKey {
3629                instance_id: AgentInstanceId(41),
3630                generation: SessionGeneration(3),
3631            },
3632        };
3633        let record_id = gate4agent_node_protocol::SessionRecordId::new(
3634            "record-context-41",
3635        )
3636        .unwrap();
3637        let request = NodeRequest::ExportContextPackForSessionRecord {
3638            record_id: record_id.clone(),
3639            session: session.clone(),
3640        };
3641        let mut capabilities = NegotiatedPathCapabilities {
3642            session_record_context_export: true,
3643            ..NegotiatedPathCapabilities::default()
3644        };
3645        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_err());
3646        capabilities.history_context_pack = true;
3647        capabilities.session_record_context_export = false;
3648        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_err());
3649        capabilities.session_record_context_export = true;
3650        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
3651
3652        let context = gate4agent_node_protocol::ResolvedContextPackReceipt {
3653            id: gate4agent_node_protocol::SpawnContextId::new("context-record-41").unwrap(),
3654            digest: gate4agent_node_protocol::SpawnContextDigest::new(format!(
3655                "sha256:{}",
3656                "a".repeat(64),
3657            ))
3658            .unwrap(),
3659            lineage: gate4agent_node_protocol::ContextPackLineageReceipt {
3660                source_node_id: NodeId::new("node-a").unwrap(),
3661                source_session: session.clone(),
3662                source_provider: gate4agent_types::AgentId::new("claude").unwrap(),
3663            },
3664            source_message_count: 2,
3665            retained_message_count: 2,
3666            byte_len: 64,
3667            truncated: false,
3668        };
3669        let response = Ok(RoutedNodeResponse {
3670            node_id: NodeId::new("node-a").unwrap(),
3671            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3672            response: Ok(C2NodeResponse::ContextPackForSessionRecordExported {
3673                record_id: record_id.clone(),
3674                session: session.clone(),
3675                context: context.clone(),
3676            }),
3677        });
3678        assert!(validate_session_record_context_export_response(&request, &response).is_ok());
3679        let mismatch = Ok(RoutedNodeResponse {
3680            node_id: NodeId::new("node-a").unwrap(),
3681            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3682            response: Ok(C2NodeResponse::ContextPackForSessionRecordExported {
3683                record_id: gate4agent_node_protocol::SessionRecordId::new("record-other")
3684                    .unwrap(),
3685                session,
3686                context,
3687            }),
3688        });
3689        assert!(validate_session_record_context_export_response(&request, &mismatch).is_err());
3690    }
3691
3692    #[test]
3693    fn native_session_response_correlation_is_fail_closed() {
3694        let node_id = NodeId::new("node-a").unwrap();
3695        let incarnation_id = NodeIncarnationId::from_bytes([7; 16]);
3696        let route = gate4agent_node_protocol::NativeSessionCatalogRoute::workspace(
3697            WorkspaceId::new("primary").unwrap(),
3698            gate4agent_types::AgentId::new("codex").unwrap(),
3699        );
3700        let selection = gate4agent_node_protocol::NativeSessionSelection {
3701            route: route.clone(),
3702            catalog_revision: 7,
3703            recent_cutoff_unix_ms: 70,
3704            selection_id: "selection-7".to_owned(),
3705        };
3706        let routed = |response| {
3707            Ok(RoutedNodeResponse {
3708                node_id: node_id.clone(),
3709                incarnation_id,
3710                response: Ok(response),
3711            })
3712        };
3713
3714        let catalog = NodeRequest::CatalogNativeSessions {
3715            route: route.clone(),
3716            limit: 10,
3717        };
3718        assert!(validate_native_session_response(
3719            &catalog,
3720            &routed(C2NodeResponse::NativeSessionsCataloged {
3721                route: route.clone(),
3722                entries: Vec::new(),
3723                summary: None,
3724            }),
3725        )
3726        .is_ok());
3727        let wrong_route = gate4agent_node_protocol::NativeSessionCatalogRoute::workspace(
3728            WorkspaceId::new("other").unwrap(),
3729            gate4agent_types::AgentId::new("codex").unwrap(),
3730        );
3731        assert!(validate_native_session_response(
3732            &catalog,
3733            &routed(C2NodeResponse::NativeSessionsCataloged {
3734                route: wrong_route,
3735                entries: Vec::new(),
3736                summary: None,
3737            }),
3738        )
3739        .is_err());
3740
3741        let page = NodeRequest::PageNativeSessions {
3742            route: route.clone(),
3743            window: gate4agent_node_protocol::NativeSessionCatalogWindow::Recent,
3744            catalog_revision: 7,
3745            recent_cutoff_unix_ms: 70,
3746            after_selection_id: None,
3747            limit: 10,
3748        };
3749        assert!(validate_native_session_response(
3750            &page,
3751            &routed(C2NodeResponse::NativeSessionsPaged {
3752                route: route.clone(),
3753                page: gate4agent_node_protocol::NativeSessionCatalogPage {
3754                    window: gate4agent_node_protocol::NativeSessionCatalogWindow::Recent,
3755                    revision: 8,
3756                    entries: Vec::new(),
3757                    next_after_selection_id: None,
3758                    remaining_count: 0,
3759                    has_more: false,
3760                },
3761            }),
3762        )
3763        .is_err());
3764
3765        let preview = NodeRequest::PreviewNativeSession {
3766            selection: selection.clone(),
3767            message_limit: 10,
3768        };
3769        let mut wrong_selection = selection.clone();
3770        wrong_selection.catalog_revision = 8;
3771        assert!(validate_native_session_response(
3772            &preview,
3773            &routed(C2NodeResponse::NativeSessionPreviewed {
3774                selection: wrong_selection,
3775                preview: gate4agent_node_protocol::SessionRecordPreview {
3776                    title: None,
3777                    modified_at_unix_ms: None,
3778                    model: None,
3779                    message_count: 0,
3780                    message_count_exact: true,
3781                    completed_turn_count: None,
3782                    total_tokens: None,
3783                    truncated: false,
3784                    messages: Vec::new(),
3785                },
3786            }),
3787        )
3788        .is_err());
3789
3790        let index = NodeRequest::IndexNativeSession {
3791            selection: selection.clone(),
3792            display_name: "Indexed".to_owned(),
3793        };
3794        let record = hatchery_core::C2ManagedSessionRecord {
3795            record_id: gate4agent_node_protocol::SessionRecordId::new("record-7").unwrap(),
3796            display_name: "Indexed".to_owned(),
3797            provider: gate4agent_types::AgentId::new("codex").unwrap(),
3798            mode: SessionMode::Pty,
3799            state: gate4agent_node_protocol::ManagedSessionState::Dormant,
3800            workspace_id: WorkspaceId::new("primary").unwrap(),
3801            active_session: None,
3802            environment_profile: None,
3803            bundle: None,
3804            context_id: None,
3805            context: None,
3806            exported_context: None,
3807            task_binding: None,
3808            provider_identity_present: true,
3809            created_at_unix_ms: 1,
3810            updated_at_unix_ms: 1,
3811        };
3812        assert!(validate_native_session_response(
3813            &index,
3814            &routed(C2NodeResponse::NativeSessionIndexed {
3815                selection: selection.clone(),
3816                record: record.clone(),
3817            }),
3818        )
3819        .is_ok());
3820        assert!(validate_native_session_response(
3821            &index,
3822            &routed(C2NodeResponse::ProviderSessionIndexed {
3823                record: record.clone(),
3824            }),
3825        )
3826        .is_err());
3827        let mut wrong_echo = selection.clone();
3828        wrong_echo.catalog_revision = 8;
3829        assert!(validate_native_session_response(
3830            &index,
3831            &routed(C2NodeResponse::NativeSessionIndexed {
3832                selection: wrong_echo,
3833                record: record.clone(),
3834            }),
3835        )
3836        .is_err());
3837        let mut wrong_provider = record.clone();
3838        wrong_provider.provider = gate4agent_types::AgentId::new("claude").unwrap();
3839        assert!(validate_native_session_response(
3840            &index,
3841            &routed(C2NodeResponse::NativeSessionIndexed {
3842                selection: selection.clone(),
3843                record: wrong_provider,
3844            }),
3845        )
3846        .is_err());
3847        let mut wrong_workspace = record.clone();
3848        wrong_workspace.workspace_id = WorkspaceId::new("other").unwrap();
3849        assert!(validate_native_session_response(
3850            &index,
3851            &routed(C2NodeResponse::NativeSessionIndexed {
3852                selection: selection.clone(),
3853                record: wrong_workspace,
3854            }),
3855        )
3856        .is_err());
3857
3858        let provider_index = NodeRequest::IndexProviderSession {
3859            workspace_id: WorkspaceId::new("primary").unwrap(),
3860            provider: gate4agent_types::AgentId::new("codex").unwrap(),
3861            identity: gate4agent_types::ProviderSessionIdentity {
3862                key: gate4agent_types::ProviderSessionKey::SessionId,
3863                id: "provider-session-7".to_owned(),
3864                transcript_path: None,
3865            },
3866            display_name: "Indexed".to_owned(),
3867        };
3868        assert!(validate_provider_session_index_response(
3869            &provider_index,
3870            &routed(C2NodeResponse::ProviderSessionIndexed {
3871                record: record.clone(),
3872            }),
3873        )
3874        .is_ok());
3875        assert!(validate_provider_session_index_response(
3876            &provider_index,
3877            &routed(C2NodeResponse::NativeSessionIndexed {
3878                selection,
3879                record: record.clone(),
3880            }),
3881        )
3882        .is_err());
3883        assert!(validate_provider_session_index_response(
3884            &catalog,
3885            &routed(C2NodeResponse::ProviderSessionIndexed { record }),
3886        )
3887        .is_err());
3888    }
3889
3890    fn reply(request_id: u64) -> OwnerInput {
3891        OwnerInput::Frame(C2ServerFrame::Reply(hatchery_core::C2ReplyEnvelope {
3892            request_id: C2RequestId(request_id),
3893            result: Ok(RoutedNodeResponse {
3894                node_id: NodeId::new("node-a").unwrap(),
3895                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3896                response: Ok(hatchery_core::C2NodeResponse::Accepted),
3897            }),
3898            correlation_id: None,
3899        }))
3900    }
3901
3902    fn terminal_resync_reply(request_id: u64, sequence: u64) -> OwnerInput {
3903        OwnerInput::Frame(C2ServerFrame::Reply(hatchery_core::C2ReplyEnvelope {
3904            request_id: C2RequestId(request_id),
3905            result: Ok(RoutedNodeResponse {
3906                node_id: NodeId::new("node-a").unwrap(),
3907                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3908                response: Ok(C2NodeResponse::Resync {
3909                    event_sequence: sequence,
3910                    oldest_available_sequence: 1,
3911                    snapshot: hatchery_core::C2NodeSnapshot {
3912                        node_id: NodeId::new("node-a").unwrap(),
3913                        enabled_providers: Vec::new(),
3914                        provider_runtime_statuses: Default::default(),
3915                        workspaces: Vec::new(),
3916                        session_records: Vec::new(),
3917                        agent_progress: Vec::new(),
3918                        managed_worktrees: Vec::new(),
3919                        launch_inventory: None,
3920
3921                    machine: None,},
3922                    events: vec![hatchery_core::C2NodeEventEnvelope {
3923                        sequence,
3924                        event: terminal_node_event(sequence),
3925                    }],
3926                }),
3927            }),
3928            correlation_id: None,
3929        }))
3930    }
3931
3932    fn unix_path() -> OpaqueHostPath {
3933        OpaqueHostPath::unix_bytes(vec![b'/', b's', b'r', b'v', b'/', 0xff]).unwrap()
3934    }
3935
3936    fn unix_path_reply(request_id: u64) -> OwnerInput {
3937        OwnerInput::Frame(C2ServerFrame::Reply(hatchery_core::C2ReplyEnvelope {
3938            request_id: C2RequestId(request_id),
3939            result: Ok(RoutedNodeResponse {
3940                node_id: NodeId::new("node-a").unwrap(),
3941                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3942                response: Ok(C2NodeResponse::WorktreeRemoved {
3943                    target_root: unix_path(),
3944                    workspace_id: None,
3945                }),
3946            }),
3947            correlation_id: None,
3948        }))
3949    }
3950
3951    fn repository_path(value: &str) -> RepositoryPath {
3952        RepositoryPath::utf8(value.to_owned()).unwrap()
3953    }
3954
3955    fn unix_repository_path(value: &[u8]) -> RepositoryPath {
3956        RepositoryPath::unix_bytes(value.to_vec()).unwrap()
3957    }
3958
3959    fn repository_inspection_response(
3960        entry_path: RepositoryPath,
3961        status_path: RepositoryPath,
3962        previous_path: Option<RepositoryPath>,
3963    ) -> C2NodeResponse {
3964        C2NodeResponse::WorkspaceInspected {
3965            inspection: C2WorkspaceInspection {
3966                workspace_id: WorkspaceId::new("foreign").unwrap(),
3967                entries: vec![WorkspaceEntry {
3968                    relative_path: entry_path,
3969                    kind: WorkspaceEntryKind::File,
3970                }],
3971                tree_truncated: false,
3972                git: C2GitSnapshot {
3973                    is_repository: true,
3974                    branch: Some("main".to_owned()),
3975                    status: vec![GitStatusEntry {
3976                        index_status: " ".to_owned(),
3977                        worktree_status: "M".to_owned(),
3978                        path: status_path,
3979                        previous_path,
3980                    }],
3981                    recent_commits: Vec::new(),
3982                    worktrees: Vec::new(),
3983                    managed_worktree: None,
3984                    truncated: false,
3985                    diagnostic_present: false,
3986                },
3987                truncation: None,
3988            },
3989        }
3990    }
3991
3992    fn unix_repository_path_reply(request_id: u64) -> OwnerInput {
3993        OwnerInput::Frame(C2ServerFrame::Reply(hatchery_core::C2ReplyEnvelope {
3994            request_id: C2RequestId(request_id),
3995            result: Ok(RoutedNodeResponse {
3996                node_id: NodeId::new("node-a").unwrap(),
3997                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3998                response: Ok(repository_inspection_response(
3999                    unix_repository_path(b"src/\xff"),
4000                    repository_path("src/main.rs"),
4001                    None,
4002                )),
4003            }),
4004            correlation_id: None,
4005        }))
4006    }
4007
4008    fn unix_path_event(sequence: u64) -> OwnerInput {
4009        OwnerInput::Frame(C2ServerFrame::Event(RoutedNodeEvent {
4010            node_id: NodeId::new("node-a").unwrap(),
4011            cursor: NodeCursor {
4012                incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
4013                sequence,
4014            },
4015            event: C2NodeEvent::WorkspaceAdded {
4016                workspace: C2WorkspaceSnapshot {
4017                    workspace_id: WorkspaceId::new("foreign").unwrap(),
4018                    canonical_root: unix_path(),
4019                    sessions: Vec::new(),
4020                    worktree_service_mode: None,
4021                    managed_worktree_profiles: None,
4022                },
4023            },
4024        }))
4025    }
4026
4027    #[test]
4028    fn c2_control_client_offer_is_exact_v2_with_authenticated_opt_ins() {
4029        let offer = client_compatibility_offer().unwrap();
4030
4031        assert_eq!(offer.build_stamp, BUILD_STAMP);
4032        assert_eq!(
4033            offer.capabilities,
4034            vec![
4035                CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).unwrap(),
4036                CapabilityId::new(C2_OPAQUE_UNIX_PATH_CAPABILITY).unwrap(),
4037                CapabilityId::new(C2_REPOSITORY_PATH_CAPABILITY).unwrap(),
4038                CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY).unwrap(),
4039                CapabilityId::new(C2_WORKSPACE_FILE_WRITE_CAPABILITY).unwrap(),
4040                CapabilityId::new(C2_WORKSPACE_ENTRY_CREATE_CAPABILITY).unwrap(),
4041                CapabilityId::new(C2_GIT_READ_CAPABILITY).unwrap(),
4042                CapabilityId::new(C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap(),
4043                CapabilityId::new(C2_PROVIDER_RUNTIME_STATUS_CAPABILITY).unwrap(),
4044                CapabilityId::new(C2_PROVIDER_ID_OPEN_CAPABILITY).unwrap(),
4045                CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap(),
4046                CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap(),
4047                CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).unwrap(),
4048                CapabilityId::new(C2_AGENT_STREAM_EVENTS_CAPABILITY).unwrap(),
4049                CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).unwrap(),
4050                CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).unwrap(),
4051                CapabilityId::new(C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY).unwrap(),
4052                CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).unwrap(),
4053                CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap(),
4054                CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap(),
4055                CapabilityId::new(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).unwrap(),
4056                CapabilityId::new(C2_NATIVE_SESSION_CATALOG_CAPABILITY).unwrap(),
4057                CapabilityId::new(C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY).unwrap(),
4058                CapabilityId::new(C2_NATIVE_SESSION_INDEX_CAPABILITY).unwrap(),
4059                CapabilityId::new(C2_NATIVE_SESSION_PREVIEW_CAPABILITY).unwrap(),
4060                CapabilityId::new(C2_HOST_DIRECTORY_BROWSE_CAPABILITY).unwrap(),
4061                CapabilityId::new(C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY).unwrap(),
4062                CapabilityId::new(C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY).unwrap(),
4063                CapabilityId::new(C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY).unwrap(),
4064                CapabilityId::new(C2_SESSION_TASK_CORRELATION_CAPABILITY).unwrap(),
4065                CapabilityId::new(C2_CONTROL_DETAIL_CAPABILITY).unwrap(),
4066                CapabilityId::new(C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY).unwrap(),
4067                CapabilityId::new(C2_HARNESS_MCP_READ_PROXY_CAPABILITY).unwrap(),
4068                CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).unwrap(),
4069            ],
4070        );
4071        assert_eq!(offer.state_schema, None);
4072        assert!(matches!(
4073            validate_selected_compatibility(&offer, None),
4074            Err(C2ControlError::Protocol(_)),
4075        ));
4076    }
4077
4078    #[tokio::test]
4079    async fn host_directory_browse_preserves_route_and_requires_negotiated_capability() {
4080        let request = NodeRequest::BrowseHostDirectories {
4081            directory: Some(OpaqueHostPath::utf8(r"C:\Users".to_owned()).unwrap()),
4082            after: Some(OpaqueHostPath::utf8(r"C:\Users\Public".to_owned()).unwrap()),
4083        };
4084        let (commands_tx, commands_rx) = mpsc::channel(1);
4085        let (events_tx, _events_rx) = mpsc::channel(1);
4086        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4087        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4088        let (topology_tx, _topology_rx) =
4089            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4090        let owner = tokio::spawn(control_owner(
4091            commands_rx,
4092            events_tx,
4093            topology_tx,
4094            writer_tx,
4095            incoming_rx,
4096            all_path_capabilities(),
4097        ));
4098        let (reply_tx, reply_rx) = oneshot::channel();
4099        commands_tx.send(ControlCommand {
4100            route: route(),
4101            request: request.clone(),
4102            reply: reply_tx,
4103            correlation_id: None,
4104        }).await.unwrap();
4105        let Some(C2ClientFrame::Request(envelope)) = writer_rx.recv().await else {
4106            panic!("host directory browse was not written");
4107        };
4108        assert_eq!(envelope.request.route, route());
4109        assert_eq!(envelope.request.request, request);
4110        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
4111            hatchery_core::C2ReplyEnvelope {
4112                request_id: envelope.request_id,
4113                result: Ok(RoutedNodeResponse {
4114                    node_id: route().node_id,
4115                    incarnation_id: route().expected_incarnation_id,
4116                    response: Ok(C2NodeResponse::HostDirectoriesBrowsed {
4117                        listing: HostDirectoryListing {
4118                            directory: None,
4119                            parent: None,
4120                            entries: vec![HostDirectoryEntry {
4121                                path: OpaqueHostPath::utf8(r"C:\Users".to_owned()).unwrap(),
4122                                display_name: "Users".to_owned(),
4123                                is_link: false,
4124                            }],
4125                            next_after: None,
4126                            incomplete: false,
4127                        },
4128                    }),
4129                }),
4130                correlation_id: None,
4131            },
4132        ))).await.unwrap();
4133        assert!(matches!(
4134            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4135            Ok(RoutedNodeResponse {
4136                response: Ok(C2NodeResponse::HostDirectoriesBrowsed { .. }),
4137                ..
4138            })
4139        ));
4140        drop(commands_tx);
4141        drop(incoming_tx);
4142        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4143
4144        let (commands_tx, commands_rx) = mpsc::channel(1);
4145        let (events_tx, _events_rx) = mpsc::channel(1);
4146        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4147        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4148        let (topology_tx, _topology_rx) =
4149            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4150        let owner = tokio::spawn(control_owner(
4151            commands_rx,
4152            events_tx,
4153            topology_tx,
4154            writer_tx,
4155            incoming_rx,
4156            no_path_capabilities(),
4157        ));
4158        let (reply_tx, reply_rx) = oneshot::channel();
4159        commands_tx.send(ControlCommand {
4160            route: route(),
4161            request: NodeRequest::Snapshot,
4162            reply: reply_tx,
4163            correlation_id: None,
4164        }).await.unwrap();
4165        let Some(C2ClientFrame::Request(envelope)) = writer_rx.recv().await else {
4166            panic!("snapshot request was not written");
4167        };
4168        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
4169            hatchery_core::C2ReplyEnvelope {
4170                request_id: envelope.request_id,
4171                result: Ok(RoutedNodeResponse {
4172                    node_id: route().node_id,
4173                    incarnation_id: route().expected_incarnation_id,
4174                    response: Ok(C2NodeResponse::HostDirectoriesBrowsed {
4175                        listing: HostDirectoryListing {
4176                            directory: None,
4177                            parent: None,
4178                            entries: Vec::new(),
4179                            next_after: None,
4180                            incomplete: false,
4181                        },
4182                    }),
4183                }),
4184                correlation_id: None,
4185            },
4186        ))).await.unwrap();
4187        assert!(matches!(
4188            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4189            Err(C2ControlError::Protocol(ref message))
4190                if message == HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED
4191        ));
4192        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4193
4194        let mut capabilities = all_path_capabilities();
4195        capabilities.host_directory_browse = false;
4196        assert!(matches!(
4197            reject_unnegotiated_outbound_path(
4198                &NodeRequest::BrowseHostDirectories {
4199                    directory: None,
4200                    after: None,
4201                },
4202                capabilities,
4203            ),
4204            Err(C2ControlError::Protocol(ref message))
4205                if message == HOST_DIRECTORY_BROWSE_NOT_NEGOTIATED
4206        ));
4207        assert!(node_request_has_unix_bytes(&NodeRequest::BrowseHostDirectories {
4208            directory: None,
4209            after: Some(OpaqueHostPath::unix_bytes(b"/srv/\xff".to_vec()).unwrap()),
4210        }));
4211    }
4212
4213    #[tokio::test]
4214    async fn standalone_workspace_roundtrip_preserves_route_snapshot_and_fails_closed() {
4215        let request = NodeRequest::CreateStandaloneWorkspace {
4216            workspace_id: WorkspaceId::new("standalone").unwrap(),
4217            root: OpaqueHostPath::utf8(r"C:\standalone".to_owned()).unwrap(),
4218            initial_branch: Some("main".to_owned()),
4219        };
4220        assert_eq!(control_request_deadline(&request), Duration::from_secs(245));
4221        let mut legacy = all_path_capabilities();
4222        legacy.standalone_workspace_lifecycle = false;
4223        assert!(matches!(
4224            reject_unnegotiated_outbound_path(&request, legacy),
4225            Err(C2ControlError::Protocol(ref message))
4226                if message == STANDALONE_WORKSPACE_LIFECYCLE_NOT_NEGOTIATED
4227        ));
4228
4229        let (commands_tx, commands_rx) = mpsc::channel(1);
4230        let (events_tx, _events_rx) = mpsc::channel(1);
4231        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4232        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4233        let (topology_tx, _topology_rx) =
4234            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4235        let owner = tokio::spawn(control_owner(
4236            commands_rx,
4237            events_tx,
4238            topology_tx,
4239            writer_tx,
4240            incoming_rx,
4241            all_path_capabilities(),
4242        ));
4243        let (reply_tx, reply_rx) = oneshot::channel();
4244        commands_tx.send(ControlCommand {
4245            route: route(),
4246            request: request.clone(),
4247            reply: reply_tx,
4248            correlation_id: None,
4249        }).await.unwrap();
4250        let Some(C2ClientFrame::Request(envelope)) = writer_rx.recv().await else {
4251            panic!("standalone workspace request was not written");
4252        };
4253        assert_eq!(envelope.request.route, route());
4254        assert_eq!(envelope.request.request, request);
4255        let workspace = C2WorkspaceSnapshot {
4256            workspace_id: WorkspaceId::new("standalone").unwrap(),
4257            canonical_root: OpaqueHostPath::utf8(r"C:\standalone".to_owned()).unwrap(),
4258            sessions: Vec::new(),
4259            worktree_service_mode: Some(WorktreeServiceMode::Manual),
4260            managed_worktree_profiles: None,
4261        };
4262        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
4263            hatchery_core::C2ReplyEnvelope {
4264                request_id: envelope.request_id,
4265                result: Ok(RoutedNodeResponse {
4266                    node_id: route().node_id,
4267                    incarnation_id: route().expected_incarnation_id,
4268                    response: Ok(C2NodeResponse::StandaloneWorkspaceCreated {
4269                        workspace: workspace.clone(),
4270                    }),
4271                }),
4272                correlation_id: None,
4273            },
4274        ))).await.unwrap();
4275        let response = timeout(Duration::from_secs(1), reply_rx)
4276            .await.unwrap().unwrap().unwrap();
4277        assert_eq!(response.node_id, route().node_id);
4278        assert_eq!(response.incarnation_id, route().expected_incarnation_id);
4279        assert!(matches!(
4280            response.response,
4281            Ok(C2NodeResponse::StandaloneWorkspaceCreated {
4282                workspace: observed,
4283            }) if observed == workspace
4284        ));
4285        drop(commands_tx);
4286        drop(incoming_tx);
4287        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4288
4289        assert!(routed_response_requires_standalone_workspace_lifecycle(
4290            &RoutedNodeResponse {
4291                node_id: route().node_id,
4292                incarnation_id: route().expected_incarnation_id,
4293                response: Ok(C2NodeResponse::StandaloneWorkspaceCreated { workspace }),
4294            },
4295        ));
4296    }
4297
4298    #[tokio::test]
4299    async fn control_owner_fails_closed_on_unnegotiated_terminal_frame_event() {
4300        let (commands_tx, commands_rx) = mpsc::channel(1);
4301        let (events_tx, mut events_rx) = mpsc::channel(1);
4302        let (writer_tx, _writer_rx) = mpsc::channel(1);
4303        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4304        let (topology_tx, _topology_rx) =
4305            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4306        let owner = tokio::spawn(control_owner(
4307            commands_rx,
4308            events_tx,
4309            topology_tx,
4310            writer_tx,
4311            incoming_rx,
4312            no_path_capabilities(),
4313        ));
4314
4315        incoming_tx.send(terminal_event(8)).await.unwrap();
4316        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4317
4318        assert!(commands_tx.is_closed());
4319        assert!(matches!(
4320            events_rx.try_recv(),
4321            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected),
4322        ));
4323    }
4324
4325    #[tokio::test]
4326    async fn control_owner_rejects_unnegotiated_terminal_frame_resync_reply_and_closes() {
4327        let (commands_tx, commands_rx) = mpsc::channel(1);
4328        let (events_tx, mut events_rx) = mpsc::channel(1);
4329        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4330        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4331        let (topology_tx, _topology_rx) =
4332            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4333        let owner = tokio::spawn(control_owner(
4334            commands_rx,
4335            events_tx,
4336            topology_tx,
4337            writer_tx,
4338            incoming_rx,
4339            no_path_capabilities(),
4340        ));
4341        let (reply_tx, reply_rx) = oneshot::channel();
4342        commands_tx.send(ControlCommand {
4343            route: route(),
4344            request: NodeRequest::Resync { after_sequence: 7 },
4345            reply: reply_tx,
4346            correlation_id: None,
4347        }).await.unwrap();
4348        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4349
4350        incoming_tx.send(terminal_resync_reply(1, 8)).await.unwrap();
4351        assert!(matches!(
4352            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4353            Err(C2ControlError::Protocol(ref message))
4354                if message == TERMINAL_FRAME_EVENTS_NOT_NEGOTIATED
4355        ));
4356        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4357
4358        assert!(commands_tx.is_closed());
4359        assert!(matches!(
4360            events_rx.try_recv(),
4361            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected),
4362        ));
4363    }
4364
4365    #[tokio::test]
4366    async fn saturated_terminal_event_channel_does_not_block_following_reply() {
4367        let (commands_tx, commands_rx) = mpsc::channel(1);
4368        let (events_tx, mut events_rx) = mpsc::channel(1);
4369        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4370        let (incoming_tx, incoming_rx) = mpsc::channel(1);
4371        let (topology_tx, _topology_rx) =
4372            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4373        let owner = tokio::spawn(control_owner(
4374            commands_rx,
4375            events_tx,
4376            topology_tx,
4377            writer_tx,
4378            incoming_rx,
4379            all_path_capabilities(),
4380        ));
4381
4382        incoming_tx.send(terminal_event(8)).await.unwrap();
4383        incoming_tx.send(terminal_event(9)).await.unwrap();
4384        let (reply_tx, reply_rx) = oneshot::channel();
4385        commands_tx.send(ControlCommand {
4386            route: route(),
4387            request: NodeRequest::Snapshot,
4388            reply: reply_tx,
4389            correlation_id: None,
4390        }).await.unwrap();
4391        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4392        incoming_tx.send(reply(1)).await.unwrap();
4393
4394        let response = timeout(Duration::from_secs(1), reply_rx)
4395            .await
4396            .unwrap()
4397            .unwrap()
4398            .unwrap();
4399        assert!(matches!(response.response, Ok(C2NodeResponse::Accepted)));
4400        assert!(matches!(
4401            events_rx.try_recv(),
4402            Ok(RoutedNodeEvent {
4403                event: C2NodeEvent::TerminalFrame { .. },
4404                ..
4405            })
4406        ));
4407
4408        drop(commands_tx);
4409        drop(incoming_tx);
4410        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4411    }
4412
4413    #[tokio::test]
4414    async fn harness_mcp_burst_uses_dedicated_128_event_capacity_without_cursor_change() {
4415        let (commands_tx, commands_rx) = mpsc::channel(1);
4416        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
4417        let (harness_tx, mut harness_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
4418        let (writer_tx, _writer_rx) = mpsc::channel(1);
4419        let (incoming_tx, incoming_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY + 1);
4420        let (topology_tx, _topology_rx) =
4421            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4422        let mut capabilities = all_path_capabilities();
4423        capabilities.harness_mcp_read_proxy = true;
4424        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4425        for call_index in 0..=HARNESS_MCP_EVENT_CAPACITY {
4426            incoming_tx.send(harness_mcp_event(call_index, deadline_unix_ms)).await.unwrap();
4427        }
4428        // The overflow (129th) event must not tear the connection down --
4429        // it is dropped with a warning and the connection stays open. This
4430        // test ends the connection by closing the reader input instead, so
4431        // the 128-capacity assertion below still exercises a genuinely
4432        // full (not closed) harness-MCP receiver.
4433        drop(incoming_tx);
4434        let owner = tokio::spawn(control_owner(
4435            commands_rx,
4436            mpsc::channel(1).1,
4437            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4438            topology_tx,
4439            writer_tx,
4440            incoming_rx,
4441            capabilities,
4442        ));
4443
4444        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4445        assert!(commands_tx.is_closed());
4446        let mut delivered = Vec::new();
4447        while let Ok(event) = harness_rx.try_recv() {
4448            delivered.push(event);
4449        }
4450        assert_eq!(delivered.len(), HARNESS_MCP_EVENT_CAPACITY);
4451        assert!(delivered.iter().all(|event| {
4452            event.cursor.sequence == 41
4453                && matches!(event.event, C2NodeEvent::HarnessMcpReadCall { .. })
4454        }));
4455    }
4456
4457    /// Item 3's direct proof: a sustained full-channel backlog -- a "gap
4458    /// episode" of dropped events -- logs exactly once at its start, not
4459    /// once per dropped event, and a fresh episode after recovery earns its
4460    /// own log line again. `control_owner`'s `harness_mcp`/terminal-frame
4461    /// arms both gate their warn through this exact function.
4462    #[test]
4463    fn channel_full_drop_logs_once_per_gap_episode_then_resets() {
4464        let mut logged = false;
4465        assert!(
4466            should_log_channel_full_drop(&mut logged),
4467            "the first drop of a new episode must log",
4468        );
4469        assert!(
4470            !should_log_channel_full_drop(&mut logged),
4471            "a second drop in the same still-unreported episode must not log again",
4472        );
4473        assert!(
4474            !should_log_channel_full_drop(&mut logged),
4475            "nor a third, no matter how long the episode runs",
4476        );
4477
4478        reset_channel_full_drop_episode(&mut logged);
4479
4480        assert!(
4481            should_log_channel_full_drop(&mut logged),
4482            "a drop in a NEW episode, after the channel recovered, logs again",
4483        );
4484    }
4485
4486    #[tokio::test]
4487    async fn harness_mcp_full_channel_drops_the_event_but_a_pending_request_still_completes() {
4488        let (commands_tx, commands_rx) = mpsc::channel(1);
4489        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
4490        // Capacity 1, never drained: the very first harness-MCP event
4491        // fills it, so the second one exercises the `Full` branch.
4492        let (harness_tx, _harness_rx) = mpsc::channel(1);
4493        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4494        let (incoming_tx, incoming_rx) = mpsc::channel(4);
4495        let (topology_tx, _topology_rx) =
4496            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4497        let mut capabilities = all_path_capabilities();
4498        capabilities.harness_mcp_read_proxy = true;
4499        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4500        let owner = tokio::spawn(control_owner(
4501            commands_rx,
4502            mpsc::channel(1).1,
4503            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4504            topology_tx,
4505            writer_tx,
4506            incoming_rx,
4507            capabilities,
4508        ));
4509
4510        incoming_tx.send(harness_mcp_event(1, deadline_unix_ms)).await.unwrap();
4511        incoming_tx.send(harness_mcp_event(2, deadline_unix_ms)).await.unwrap();
4512        tokio::task::yield_now().await;
4513
4514        // A request enqueued (and answered) AFTER the channel-full event
4515        // still gets served: the owner loop never broke.
4516        let (reply_tx, reply_rx) = oneshot::channel();
4517        commands_tx.send(ControlCommand {
4518            route: route(),
4519            request: NodeRequest::Snapshot,
4520            reply: reply_tx,
4521            correlation_id: None,
4522        }).await.unwrap();
4523        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4524        incoming_tx.send(reply(1)).await.unwrap();
4525        assert!(matches!(
4526            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4527            Ok(RoutedNodeResponse { response: Ok(C2NodeResponse::Accepted), .. })
4528        ));
4529
4530        drop(commands_tx);
4531        drop(incoming_tx);
4532        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4533    }
4534
4535    #[tokio::test]
4536    async fn harness_mcp_dropped_receiver_stops_that_class_but_a_pending_request_still_completes() {
4537        let (commands_tx, commands_rx) = mpsc::channel(1);
4538        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
4539        let (harness_tx, harness_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
4540        // Nobody ever subscribes to harness-MCP events on this connection.
4541        drop(harness_rx);
4542        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4543        let (incoming_tx, incoming_rx) = mpsc::channel(4);
4544        let (topology_tx, _topology_rx) =
4545            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4546        let mut capabilities = all_path_capabilities();
4547        capabilities.harness_mcp_read_proxy = true;
4548        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4549        let owner = tokio::spawn(control_owner(
4550            commands_rx,
4551            mpsc::channel(1).1,
4552            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4553            topology_tx,
4554            writer_tx,
4555            incoming_rx,
4556            capabilities,
4557        ));
4558
4559        incoming_tx.send(harness_mcp_event(1, deadline_unix_ms)).await.unwrap();
4560
4561        let (reply_tx, reply_rx) = oneshot::channel();
4562        commands_tx.send(ControlCommand {
4563            route: route(),
4564            request: NodeRequest::Snapshot,
4565            reply: reply_tx,
4566            correlation_id: None,
4567        }).await.unwrap();
4568        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4569        incoming_tx.send(reply(1)).await.unwrap();
4570        assert!(matches!(
4571            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4572            Ok(RoutedNodeResponse { response: Ok(C2NodeResponse::Accepted), .. })
4573        ));
4574
4575        drop(commands_tx);
4576        drop(incoming_tx);
4577        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4578    }
4579
4580    #[tokio::test]
4581    async fn genuine_pipe_loss_answers_every_pending_request_with_the_named_reason() {
4582        let (commands_tx, commands_rx) = mpsc::channel(4);
4583        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
4584        let (writer_tx, mut writer_rx) = mpsc::channel(4);
4585        let (incoming_tx, incoming_rx) = mpsc::channel(4);
4586        let (topology_tx, _topology_rx) =
4587            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4588        let owner = tokio::spawn(control_owner(
4589            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, all_path_capabilities(),
4590        ));
4591
4592        let (reply_tx, reply_rx) = oneshot::channel();
4593        commands_tx.send(ControlCommand {
4594            route: route(),
4595            request: NodeRequest::Snapshot,
4596            reply: reply_tx,
4597            correlation_id: None,
4598        }).await.unwrap();
4599        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
4600
4601        // The reader half signals a genuine pipe loss -- no reply for the
4602        // pending Snapshot request will ever arrive.
4603        incoming_tx.send(OwnerInput::Closed).await.unwrap();
4604        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4605
4606        assert!(matches!(
4607            reply_rx.await.unwrap(),
4608            Err(C2ControlError::ConnectionLost { reason: C2ConnectionLossReason::PipeClosed }),
4609        ));
4610    }
4611
4612    #[tokio::test]
4613    async fn harness_mcp_mixed_pending_burst_preserves_durable_event_without_cursor_pollution_or_replay() {
4614        let (commands_tx, commands_rx) = mpsc::channel(1);
4615        let (events_tx, events_rx) = mpsc::channel(EVENT_CAPACITY);
4616        let (harness_tx, harness_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
4617        let (writer_tx, _writer_rx) = mpsc::channel(1);
4618        let (incoming_tx, incoming_rx) = mpsc::channel(2);
4619        let (topology_tx, _topology_rx) =
4620            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4621        let mut capabilities = all_path_capabilities();
4622        capabilities.harness_mcp_read_proxy = true;
4623        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4624        incoming_tx.send(harness_mcp_event(1, deadline_unix_ms)).await.unwrap();
4625        incoming_tx.send(ordinary_state_event(42)).await.unwrap();
4626        let owner = tokio::spawn(control_owner(
4627            commands_rx,
4628            mpsc::channel(1).1,
4629            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4630            topology_tx,
4631            writer_tx,
4632            incoming_rx,
4633            capabilities,
4634        ));
4635        let mut receiver = C2EventReceiver {
4636            events: events_rx,
4637            harness_mcp_events: harness_rx,
4638        };
4639        timeout(Duration::from_secs(1), async {
4640            while receiver.events.len() != 1 || receiver.harness_mcp_events.len() != 1 {
4641                tokio::task::yield_now().await;
4642            }
4643        }).await.unwrap();
4644
4645        let transient = receiver.recv().await.unwrap();
4646        assert_eq!(transient.cursor.sequence, 41);
4647        assert!(matches!(transient.event, C2NodeEvent::HarnessMcpReadCall { .. }));
4648        let durable = receiver.recv().await.unwrap();
4649        assert_eq!(durable.cursor.sequence, 42);
4650        assert!(matches!(
4651            durable.event,
4652            C2NodeEvent::WorkspaceRemoved { ref workspace_id }
4653                if workspace_id.as_str() == "retired"
4654        ));
4655        assert!(matches!(
4656            receiver.harness_mcp_events.try_recv(),
4657            Err(mpsc::error::TryRecvError::Empty),
4658        ));
4659        assert!(matches!(
4660            receiver.events.try_recv(),
4661            Err(mpsc::error::TryRecvError::Empty),
4662        ));
4663
4664        drop(commands_tx);
4665        drop(incoming_tx);
4666        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4667    }
4668
4669    /// `split_mut` (the Stage 1 accessor the reconnect bridge's
4670    /// `pump_one_connection` needs to race both channels in one `select!`)
4671    /// must hand out two receivers that each drain only their own class,
4672    /// regardless of enqueue order -- proving the two channels are
4673    /// genuinely independent streams, not merely `recv()`'s internal
4674    /// biased-merge order.
4675    #[tokio::test]
4676    async fn split_mut_drains_each_channel_independently_of_enqueue_order() {
4677        let (commands_tx, commands_rx) = mpsc::channel(1);
4678        let (events_tx, events_rx) = mpsc::channel(EVENT_CAPACITY);
4679        let (harness_tx, harness_rx) = mpsc::channel(HARNESS_MCP_EVENT_CAPACITY);
4680        let (writer_tx, _writer_rx) = mpsc::channel(1);
4681        let (incoming_tx, incoming_rx) = mpsc::channel(2);
4682        let (topology_tx, _topology_rx) =
4683            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4684        let mut capabilities = all_path_capabilities();
4685        capabilities.harness_mcp_read_proxy = true;
4686        let deadline_unix_ms = current_unix_ms().unwrap() + 3_000;
4687        // The regular event is enqueued FIRST -- if the two split
4688        // receivers were secretly still merged, a naive FIFO reading of the
4689        // harness_mcp side first would see nothing ready and this test
4690        // would hang (caught by the outer `timeout`) or, worse, silently
4691        // return the regular event.
4692        incoming_tx.send(ordinary_state_event(42)).await.unwrap();
4693        incoming_tx.send(harness_mcp_event(1, deadline_unix_ms)).await.unwrap();
4694        let owner = tokio::spawn(control_owner(
4695            commands_rx,
4696            mpsc::channel(1).1,
4697            EventDelivery { regular: events_tx, harness_mcp: harness_tx, browser: mpsc::channel(1).0 },
4698            topology_tx,
4699            writer_tx,
4700            incoming_rx,
4701            capabilities,
4702        ));
4703        let mut receiver = C2EventReceiver {
4704            events: events_rx,
4705            harness_mcp_events: harness_rx,
4706        };
4707        timeout(Duration::from_secs(1), async {
4708            while receiver.events.len() != 1 || receiver.harness_mcp_events.len() != 1 {
4709                tokio::task::yield_now().await;
4710            }
4711        }).await.unwrap();
4712
4713        let (regular_events, harness_mcp_events, _browser_events) = receiver.split_mut();
4714
4715        let mcp_event = timeout(Duration::from_secs(1), harness_mcp_events.recv())
4716            .await
4717            .expect("the harness_mcp receiver must not block on the regular channel")
4718            .unwrap();
4719        assert!(matches!(mcp_event.event, C2NodeEvent::HarnessMcpReadCall { .. }));
4720
4721        let regular_event = timeout(Duration::from_secs(1), regular_events.recv())
4722            .await
4723            .expect("the regular receiver must not block on the harness_mcp channel")
4724            .unwrap();
4725        assert_eq!(regular_event.cursor.sequence, 42);
4726        assert!(matches!(
4727            regular_event.event,
4728            C2NodeEvent::WorkspaceRemoved { ref workspace_id }
4729                if workspace_id.as_str() == "retired"
4730        ));
4731
4732        drop(commands_tx);
4733        drop(incoming_tx);
4734        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4735    }
4736
4737    #[test]
4738    fn c2_repository_path_gate_covers_entries_status_and_previous_path() {
4739        let utf8 = || repository_path("src/main.rs");
4740        let tagged = || unix_repository_path(b"src/\xff");
4741        for response in [
4742            repository_inspection_response(tagged(), utf8(), None),
4743            repository_inspection_response(utf8(), tagged(), None),
4744            repository_inspection_response(utf8(), utf8(), Some(tagged())),
4745        ] {
4746            assert!(c2_node_response_has_unix_repository_path(&response));
4747        }
4748        assert!(!c2_node_response_has_unix_repository_path(
4749            &repository_inspection_response(utf8(), utf8(), Some(utf8())),
4750        ));
4751    }
4752
4753    #[test]
4754    fn c2_control_client_rejects_selection_outside_offer() {
4755        let offer = client_compatibility_offer().unwrap();
4756        let selected = NegotiatedC2ControlCompatibility {
4757            build_stamp: format!("{}-tampered", offer.build_stamp),
4758            capabilities: offer.capabilities.clone(),
4759            host: HostDescriptor {
4760                operating_system: OperatingSystemId::new("windows").unwrap(),
4761                architecture: ArchitectureId::new("x86_64").unwrap(),
4762            },
4763            path_semantics: PathSemantics {
4764                style: PathStyle::Windows,
4765                encoding: PathEncoding::Utf8,
4766            },
4767        };
4768
4769        assert!(matches!(
4770            validate_selected_compatibility(&offer, Some(&selected)),
4771            Err(C2ControlError::BuildStampMismatch { .. }),
4772        ));
4773    }
4774
4775    #[test]
4776    fn c2_control_client_requires_authenticated_compatibility_metadata() {
4777        let offer = client_compatibility_offer().unwrap();
4778        let selected = NegotiatedC2ControlCompatibility {
4779            build_stamp: offer.build_stamp.clone(),
4780            capabilities: vec![
4781                CapabilityId::new(C2_WORKSPACE_FILE_READ_CAPABILITY).unwrap(),
4782            ],
4783            host: HostDescriptor {
4784                operating_system: OperatingSystemId::new("windows").unwrap(),
4785                architecture: ArchitectureId::new("x86_64").unwrap(),
4786            },
4787            path_semantics: PathSemantics {
4788                style: PathStyle::Windows,
4789                encoding: PathEncoding::Utf8,
4790            },
4791        };
4792
4793        assert!(matches!(
4794            validate_selected_compatibility(&offer, Some(&selected)),
4795            Err(C2ControlError::Protocol(_)),
4796        ));
4797    }
4798
4799    #[tokio::test]
4800    async fn negotiating_c2_client_rejects_hmac_valid_legacy_challenge_before_authenticate() {
4801        let endpoint = unique_control_endpoint();
4802        let token = "strict-negotiation-token";
4803        let mut server = ServerOptions::new()
4804            .first_pipe_instance(true)
4805            .create(&endpoint)
4806            .unwrap();
4807        let server_task = tokio::spawn({
4808            let token = token.to_owned();
4809            async move {
4810                server.connect().await.unwrap();
4811                let frame = read_json_frame_limited_body_timeout::<_, C2ClientFrame>(
4812                    &mut server,
4813                    MAX_C2_AUTH_FRAME_BYTES,
4814                    Duration::from_secs(1),
4815                )
4816                .await
4817                .unwrap();
4818                let C2ClientFrame::Hello(hello) = frame else {
4819                    panic!("client did not send hello");
4820                };
4821                assert!(hello.compatibility.is_some());
4822                let server_nonce = [7; C2_AUTH_NONCE_BYTES];
4823                let server_proof = c2_proof(
4824                    &token,
4825                    C2AuthDirection::Server,
4826                    &hello.client_nonce,
4827                    &server_nonce,
4828                    None,
4829                )
4830                .unwrap();
4831                write_json_frame_limited(
4832                    &mut server,
4833                    &C2ServerFrame::Challenge(C2ServerChallenge {
4834                        build_stamp: BUILD_STAMP.to_owned(),
4835                        server_nonce,
4836                        server_proof,
4837                        compatibility: None,
4838                    }),
4839                    MAX_C2_AUTH_FRAME_BYTES,
4840                )
4841                .await
4842                .unwrap();
4843                timeout(
4844                    Duration::from_secs(1),
4845                    read_json_frame_limited_body_timeout::<_, C2ClientFrame>(
4846                        &mut server,
4847                        MAX_C2_AUTH_FRAME_BYTES,
4848                        Duration::from_secs(1),
4849                    ),
4850                )
4851                .await
4852            }
4853        });
4854
4855        let error = match connect_local(&endpoint, token).await {
4856            Ok(_) => panic!("negotiated client accepted a legacy challenge"),
4857            Err(error) => error,
4858        };
4859        assert!(matches!(
4860            error,
4861            C2ControlError::Protocol(ref message)
4862                if message.contains("omitted the required authenticated compatibility selection")
4863        ));
4864        match server_task.await.unwrap() {
4865            Ok(Ok(frame)) => panic!("client sent a post-challenge frame: {frame:?}"),
4866            Ok(Err(_)) | Err(_) => {}
4867        }
4868    }
4869
4870    #[test]
4871    fn c2_control_bound_proof_rejects_tampered_selection() {
4872        let offer = client_compatibility_offer().unwrap();
4873        let selected = NegotiatedC2ControlCompatibility {
4874            build_stamp: offer.build_stamp.clone(),
4875            capabilities: offer.capabilities.clone(),
4876            host: HostDescriptor {
4877                operating_system: OperatingSystemId::new("windows").unwrap(),
4878                architecture: ArchitectureId::new("x86_64").unwrap(),
4879            },
4880            path_semantics: PathSemantics {
4881                style: PathStyle::Windows,
4882                encoding: PathEncoding::Utf8,
4883            },
4884        };
4885        let expected = c2_proof(
4886            "test-token",
4887            C2AuthDirection::Server,
4888            &[1; C2_AUTH_NONCE_BYTES],
4889            &[2; C2_AUTH_NONCE_BYTES],
4890            Some((&offer, &selected)),
4891        ).unwrap();
4892        let mut tampered = selected;
4893        tampered.path_semantics.style = PathStyle::Posix;
4894        let received = c2_proof(
4895            "test-token",
4896            C2AuthDirection::Server,
4897            &[1; C2_AUTH_NONCE_BYTES],
4898            &[2; C2_AUTH_NONCE_BYTES],
4899            Some((&offer, &tampered)),
4900        ).unwrap();
4901
4902        assert!(!proofs_match(&received, &expected));
4903    }
4904
4905    #[tokio::test]
4906    async fn control_owner_rejects_unnegotiated_unix_path_before_write() {
4907        let (commands_tx, commands_rx) = mpsc::channel(1);
4908        let (events_tx, _events_rx) = mpsc::channel(1);
4909        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4910        let (_incoming_tx, incoming_rx) = mpsc::channel(1);
4911        let (topology_tx, _topology_rx) =
4912            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4913        let owner = tokio::spawn(control_owner(
4914            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
4915        ));
4916        let request = NodeRequest::RegisterWorkspace {
4917            workspace_id: WorkspaceId::new("foreign").unwrap(),
4918            root: unix_path(),
4919        };
4920        assert!(reject_unnegotiated_outbound_path(&request, all_path_capabilities()).is_ok());
4921        let (reply_tx, reply_rx) = oneshot::channel();
4922        commands_tx
4923            .send(ControlCommand { route: route(), request, reply: reply_tx,  correlation_id: None, })
4924            .await
4925            .unwrap();
4926
4927        assert!(matches!(
4928            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4929            Err(C2ControlError::Protocol(ref message))
4930                if message == OPAQUE_UNIX_PATH_NOT_NEGOTIATED
4931        ));
4932        assert!(writer_rx.try_recv().is_err());
4933        drop(commands_tx);
4934        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4935    }
4936
4937    #[tokio::test]
4938    async fn control_owner_rejects_unnegotiated_open_provider_spawn_before_write() {
4939        let (commands_tx, commands_rx) = mpsc::channel(1);
4940        let (events_tx, _events_rx) = mpsc::channel(1);
4941        let (writer_tx, mut writer_rx) = mpsc::channel(1);
4942        let (_incoming_tx, incoming_rx) = mpsc::channel(1);
4943        let (topology_tx, _topology_rx) =
4944            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
4945        let owner = tokio::spawn(control_owner(
4946            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
4947        ));
4948        let (reply_tx, reply_rx) = oneshot::channel();
4949        commands_tx.send(ControlCommand {
4950            route: route(),
4951            request: NodeRequest::Spawn {
4952                workspace_id: WorkspaceId::new("primary").unwrap(),
4953                provider: hatchery_core::AgentId::new("third-party-agent").unwrap(),
4954                mode: SessionMode::Pty,
4955                terminal_size: TerminalSize { rows: 40, columns: 120 },
4956                initial_prompt: None,
4957            },
4958            reply: reply_tx,
4959            correlation_id: None,
4960        }).await.unwrap();
4961
4962        assert!(matches!(
4963            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
4964            Err(C2ControlError::Protocol(ref message))
4965                if message == OPEN_PROVIDER_ID_NOT_NEGOTIATED
4966        ));
4967        assert!(writer_rx.try_recv().is_err());
4968        drop(commands_tx);
4969        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
4970    }
4971
4972    #[test]
4973    fn spawn_profile_revision_is_advertised_and_rejected_before_relay_on_all_spawn_paths() {
4974        let offer = client_compatibility_offer().unwrap();
4975        assert!(offer.capabilities.iter().any(|capability| {
4976            capability.as_str() == C2_SPAWN_PROFILE_REVISION_CAPABILITY
4977        }));
4978
4979        let spec = spawn_spec("node-a");
4980        let reservation_id = gate4agent_node_protocol::HarnessMcpReservationId::new(
4981            format!("hmcpres_{}", "a".repeat(24)),
4982        ).unwrap();
4983        let activation_digest = gate4agent_node_protocol::HarnessMcpActivationDigest::new(
4984            format!("sha256:{}", "b".repeat(64)),
4985        ).unwrap();
4986        let deadline_unix_ms = current_unix_ms().unwrap().saturating_add(60_000);
4987        let requests = [
4988            NodeRequest::SpawnSpec { spec: spec.clone() },
4989            NodeRequest::SpawnManagedWorktree {
4990                request: gate4agent_node_protocol::ManagedWorktreeSpawnRequest {
4991                    spawn_spec: spec.clone(),
4992                    worktree_profile_id:
4993                        gate4agent_node_protocol::WorktreeProfileId::new("review").unwrap(),
4994                },
4995            },
4996            NodeRequest::ArmHarnessMcpReservation {
4997                reservation_id: reservation_id.clone(),
4998                activation_digest: activation_digest.clone(),
4999                spawn_spec: spec.clone(),
5000                launch: gate4agent_node_protocol::HarnessMcpLaunchV1 {
5001                    server_name: "fixture-harness".to_owned(),
5002                    args: vec!["--session-proxy".to_owned()],
5003                    endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
5004                    token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
5005                    program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
5006                    trace: None,
5007                    scrub_env: Vec::new(),
5008                },
5009                expires_at_unix_ms: deadline_unix_ms,
5010            },
5011            NodeRequest::SpawnSpecWithHarnessMcp {
5012                reservation_id,
5013                activation_digest,
5014                spec,
5015                deadline_unix_ms,
5016            },
5017        ];
5018        let mut capabilities = all_path_capabilities();
5019        capabilities.harness_mcp_read_proxy = true;
5020        capabilities.spawn_profile_revision = false;
5021        for request in &requests {
5022            assert!(matches!(
5023                reject_unnegotiated_outbound_path(request, capabilities),
5024                Err(C2ControlError::Protocol(ref message))
5025                    if message == SPAWN_PROFILE_REVISION_NOT_NEGOTIATED
5026            ));
5027        }
5028
5029        let response = RoutedNodeResponse {
5030            node_id: NodeId::new("node-a").unwrap(),
5031            incarnation_id: gate4agent_node_protocol::NodeIncarnationId::from_bytes([1; 16]),
5032            response: Ok(C2NodeResponse::SpawnSpecAccepted {
5033                receipt: spawn_receipt(),
5034            }),
5035        };
5036        assert!(routed_response_requires_spawn_profile_revision(&response));
5037    }
5038
5039    #[tokio::test]
5040    async fn control_owner_rejects_unnegotiated_spawn_spec_before_write() {
5041        let (commands_tx, commands_rx) = mpsc::channel(1);
5042        let (events_tx, _events_rx) = mpsc::channel(1);
5043        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5044        let (_incoming_tx, incoming_rx) = mpsc::channel(1);
5045        let (topology_tx, _topology_rx) =
5046            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5047        let mut capabilities = all_path_capabilities();
5048        capabilities.spawn_spec_defaults_overrides = false;
5049        let owner = tokio::spawn(control_owner(
5050            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx,
5051            capabilities,
5052        ));
5053        let (reply_tx, reply_rx) = oneshot::channel();
5054        commands_tx.send(ControlCommand {
5055            route: route(),
5056            request: NodeRequest::SpawnSpec { spec: spawn_spec("node-a") },
5057            reply: reply_tx,
5058            correlation_id: None,
5059        }).await.unwrap();
5060
5061        assert!(matches!(
5062            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5063            Err(C2ControlError::Protocol(ref message))
5064                if message == SPAWN_SPEC_NOT_NEGOTIATED
5065        ));
5066        assert!(writer_rx.try_recv().is_err());
5067        drop(commands_tx);
5068        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5069    }
5070
5071    #[tokio::test]
5072    async fn control_owner_rejects_unnegotiated_spawn_spec_reply_and_closes() {
5073        let (commands_tx, commands_rx) = mpsc::channel(1);
5074        let (events_tx, _events_rx) = mpsc::channel(1);
5075        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5076        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5077        let (topology_tx, _topology_rx) =
5078            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5079        let mut capabilities = all_path_capabilities();
5080        capabilities.spawn_spec_defaults_overrides = false;
5081        let owner = tokio::spawn(control_owner(
5082            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, capabilities,
5083        ));
5084        let (reply_tx, reply_rx) = oneshot::channel();
5085        commands_tx.send(ControlCommand {
5086            route: route(),
5087            request: NodeRequest::Snapshot,
5088            reply: reply_tx,
5089            correlation_id: None,
5090        }).await.unwrap();
5091        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5092        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
5093            hatchery_core::C2ReplyEnvelope {
5094                request_id: C2RequestId(1),
5095                result: Ok(RoutedNodeResponse {
5096                    node_id: NodeId::new("node-a").unwrap(),
5097                    incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5098                    response: Ok(C2NodeResponse::SpawnSpecAccepted {
5099                        receipt: spawn_receipt(),
5100                    }),
5101                }),
5102                correlation_id: None,
5103            },
5104        ))).await.unwrap();
5105
5106        assert!(matches!(
5107            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5108            Err(C2ControlError::Protocol(ref message))
5109                if message == SPAWN_SPEC_NOT_NEGOTIATED
5110        ));
5111        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5112        assert!(commands_tx.send(ControlCommand {
5113            route: route(),
5114            request: NodeRequest::Snapshot,
5115            reply: oneshot::channel().0,
5116            correlation_id: None,
5117        }).await.is_err());
5118    }
5119
5120    #[tokio::test]
5121    async fn control_owner_conditionally_gates_worktree_spawn_before_write() {
5122        let (commands_tx, commands_rx) = mpsc::channel(2);
5123        let (events_tx, _events_rx) = mpsc::channel(1);
5124        let (writer_tx, mut writer_rx) = mpsc::channel(2);
5125        let (_incoming_tx, incoming_rx) = mpsc::channel(1);
5126        let (topology_tx, _topology_rx) =
5127            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5128        let mut capabilities = all_path_capabilities();
5129        capabilities.worktree_selection = false;
5130        let owner = tokio::spawn(control_owner(
5131            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, capabilities,
5132        ));
5133
5134        let (legacy_reply_tx, _legacy_reply_rx) = oneshot::channel();
5135        commands_tx.send(ControlCommand {
5136            route: route(),
5137            request: NodeRequest::SpawnSpec { spec: spawn_spec("node-a") },
5138            reply: legacy_reply_tx,
5139            correlation_id: None,
5140        }).await.unwrap();
5141        assert!(matches!(
5142            writer_rx.recv().await,
5143            Some(C2ClientFrame::Request(_)),
5144        ));
5145
5146        let mut spec = spawn_spec("node-a");
5147        spec.target.worktree_id = Some(WorkspaceId::new("review-tree").unwrap());
5148        let (reply_tx, reply_rx) = oneshot::channel();
5149        commands_tx.send(ControlCommand {
5150            route: route(),
5151            request: NodeRequest::SpawnSpec { spec },
5152            reply: reply_tx,
5153            correlation_id: None,
5154        }).await.unwrap();
5155        assert!(matches!(
5156            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5157            Err(C2ControlError::Protocol(ref message))
5158                if message == WORKTREE_SELECTION_NOT_NEGOTIATED
5159        ));
5160        assert!(writer_rx.try_recv().is_err());
5161        drop(commands_tx);
5162        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5163    }
5164
5165    #[tokio::test]
5166    async fn control_owner_rejects_unnegotiated_worktree_receipt_and_closes() {
5167        let (commands_tx, commands_rx) = mpsc::channel(1);
5168        let (events_tx, _events_rx) = mpsc::channel(1);
5169        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5170        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5171        let (topology_tx, _topology_rx) =
5172            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5173        let mut capabilities = all_path_capabilities();
5174        capabilities.worktree_selection = false;
5175        let owner = tokio::spawn(control_owner(
5176            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, capabilities,
5177        ));
5178        let (reply_tx, reply_rx) = oneshot::channel();
5179        commands_tx.send(ControlCommand {
5180            route: route(),
5181            request: NodeRequest::Snapshot,
5182            reply: reply_tx,
5183            correlation_id: None,
5184        }).await.unwrap();
5185        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5186        let mut receipt = spawn_receipt();
5187        receipt.target.worktree_id = Some(WorkspaceId::new("review-tree").unwrap());
5188        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
5189            hatchery_core::C2ReplyEnvelope {
5190                request_id: C2RequestId(1),
5191                result: Ok(RoutedNodeResponse {
5192                    node_id: NodeId::new("node-a").unwrap(),
5193                    incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5194                    response: Ok(C2NodeResponse::SpawnSpecAccepted { receipt }),
5195                }),
5196                correlation_id: None,
5197            },
5198        ))).await.unwrap();
5199        assert!(matches!(
5200            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5201            Err(C2ControlError::Protocol(ref message))
5202                if message == WORKTREE_SELECTION_NOT_NEGOTIATED
5203        ));
5204        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5205    }
5206
5207    #[tokio::test]
5208    async fn control_owner_rejects_unnegotiated_environment_receipt_and_closes() {
5209        let (commands_tx, commands_rx) = mpsc::channel(1);
5210        let (events_tx, _events_rx) = mpsc::channel(1);
5211        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5212        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5213        let (topology_tx, _topology_rx) =
5214            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5215        let mut capabilities = all_path_capabilities();
5216        capabilities.child_environment_profile = false;
5217        let owner = tokio::spawn(control_owner(
5218            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, capabilities,
5219        ));
5220        let (reply_tx, reply_rx) = oneshot::channel();
5221        commands_tx.send(ControlCommand {
5222            route: route(),
5223            request: NodeRequest::Snapshot,
5224            reply: reply_tx,
5225            correlation_id: None,
5226        }).await.unwrap();
5227        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5228        let mut receipt = spawn_receipt();
5229        receipt.environment_profile = Some(
5230            hatchery_core::ResolvedEnvironmentProfileReceipt {
5231                profile_id: hatchery_core::SpawnEnvironmentProfileId::new(
5232                    "local-default",
5233                )
5234                .unwrap(),
5235                profile_revision:
5236                    hatchery_core::SpawnEnvironmentProfileRevision::new(
5237                        "local-default.r1",
5238                    )
5239                    .unwrap(),
5240                    network_allowlist: None,
5241                    browser_profile_id: None,
5242            },
5243        );
5244        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
5245            hatchery_core::C2ReplyEnvelope {
5246                request_id: C2RequestId(1),
5247                result: Ok(RoutedNodeResponse {
5248                    node_id: NodeId::new("node-a").unwrap(),
5249                    incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5250                    response: Ok(C2NodeResponse::SpawnSpecAccepted { receipt }),
5251                }),
5252                correlation_id: None,
5253            },
5254        ))).await.unwrap();
5255        assert!(matches!(
5256            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5257            Err(C2ControlError::Protocol(ref message))
5258                if message == CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED
5259        ));
5260        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5261    }
5262
5263    #[tokio::test]
5264    async fn control_owner_rejects_unnegotiated_open_provider_reply_and_closes() {
5265        let (commands_tx, commands_rx) = mpsc::channel(1);
5266        let (events_tx, _events_rx) = mpsc::channel(1);
5267        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5268        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5269        let (topology_tx, _topology_rx) =
5270            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5271        let owner = tokio::spawn(control_owner(
5272            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5273        ));
5274        let (reply_tx, reply_rx) = oneshot::channel();
5275        commands_tx.send(ControlCommand {
5276            route: route(),
5277            request: NodeRequest::Snapshot,
5278            reply: reply_tx,
5279            correlation_id: None,
5280        }).await.unwrap();
5281        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5282        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Reply(
5283            hatchery_core::C2ReplyEnvelope {
5284                request_id: C2RequestId(1),
5285                result: Ok(RoutedNodeResponse {
5286                    node_id: NodeId::new("node-a").unwrap(),
5287                    incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5288                    response: Ok(C2NodeResponse::Snapshot {
5289                        event_sequence: 1,
5290                        controller: None,
5291                        snapshot: hatchery_core::C2NodeSnapshot {
5292                            node_id: NodeId::new("node-a").unwrap(),
5293                            enabled_providers: vec![
5294                                hatchery_core::AgentId::new("third-party-agent").unwrap(),
5295                            ],
5296                            provider_runtime_statuses: Default::default(),
5297                            workspaces: Vec::new(),
5298                            agent_progress: Vec::new(),
5299                            session_records: Vec::new(),
5300                            managed_worktrees: Vec::new(),
5301                            launch_inventory: None,
5302
5303                        machine: None,},
5304                    }),
5305                }),
5306                correlation_id: None,
5307            },
5308        ))).await.unwrap();
5309
5310        assert!(matches!(
5311            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5312            Err(C2ControlError::Protocol(ref message))
5313                if message == OPEN_PROVIDER_ID_NOT_NEGOTIATED
5314        ));
5315        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5316        assert!(commands_tx.is_closed());
5317    }
5318
5319    #[tokio::test]
5320    async fn control_owner_rejects_unnegotiated_workspace_file_read_before_write_and_stays_healthy() {
5321        let (commands_tx, commands_rx) = mpsc::channel(2);
5322        let (events_tx, _events_rx) = mpsc::channel(1);
5323        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5324        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5325        let (topology_tx, _topology_rx) =
5326            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5327        let owner = tokio::spawn(control_owner(
5328            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5329        ));
5330        let (read_tx, read_rx) = oneshot::channel();
5331        commands_tx.send(ControlCommand {
5332            route: route(),
5333            request: NodeRequest::ReadWorkspaceFile {
5334                workspace_id: WorkspaceId::new("primary").unwrap(),
5335                path: repository_path("src/lib.rs"),
5336            },
5337            reply: read_tx,
5338            correlation_id: None,
5339        }).await.unwrap();
5340
5341        assert!(matches!(
5342            timeout(Duration::from_secs(1), read_rx).await.unwrap().unwrap(),
5343            Err(C2ControlError::Protocol(ref message))
5344                if message == WORKSPACE_FILE_READ_NOT_NEGOTIATED
5345        ));
5346        assert!(writer_rx.try_recv().is_err());
5347
5348        let (snapshot_tx, snapshot_rx) = oneshot::channel();
5349        commands_tx.send(ControlCommand {
5350            route: route(),
5351            request: NodeRequest::Snapshot,
5352            reply: snapshot_tx,
5353            correlation_id: None,
5354        }).await.unwrap();
5355        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5356        incoming_tx.send(reply(1)).await.unwrap();
5357        assert!(matches!(
5358            timeout(Duration::from_secs(1), snapshot_rx).await.unwrap().unwrap(),
5359            Ok(RoutedNodeResponse { response: Ok(C2NodeResponse::Accepted), .. })
5360        ));
5361
5362        drop(commands_tx);
5363        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5364    }
5365
5366    #[tokio::test]
5367    async fn control_owner_rejects_unnegotiated_workspace_entry_create_before_write_and_stays_healthy() {
5368        let (commands_tx, commands_rx) = mpsc::channel(2);
5369        let (events_tx, _events_rx) = mpsc::channel(1);
5370        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5371        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5372        let (topology_tx, _topology_rx) =
5373            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5374        let owner = tokio::spawn(control_owner(
5375            commands_rx,
5376            events_tx,
5377            topology_tx,
5378            writer_tx,
5379            incoming_rx,
5380            no_path_capabilities(),
5381        ));
5382        let request = NodeRequest::CreateWorkspaceDirectory {
5383            workspace_id: WorkspaceId::new("primary").unwrap(),
5384            path: repository_path("src/new"),
5385        };
5386        assert_eq!(control_request_deadline(&request), Duration::from_secs(20));
5387        assert!(
5388            control_request_deadline(&request)
5389                > CONTROLLER_ACQUIRE_RELAY_DEADLINE
5390                    .saturating_add(WORKSPACE_ENTRY_CREATE_RELAY_DEADLINE),
5391        );
5392        let (create_tx, create_rx) = oneshot::channel();
5393        commands_tx
5394            .send(ControlCommand {
5395                route: route(),
5396                request,
5397                reply: create_tx,
5398                correlation_id: None,
5399            })
5400            .await
5401            .unwrap();
5402        assert!(matches!(
5403            timeout(Duration::from_secs(1), create_rx).await.unwrap().unwrap(),
5404            Err(C2ControlError::Protocol(ref message))
5405                if message == WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED
5406        ));
5407        assert!(writer_rx.try_recv().is_err());
5408
5409        let (snapshot_tx, snapshot_rx) = oneshot::channel();
5410        commands_tx
5411            .send(ControlCommand {
5412                route: route(),
5413                request: NodeRequest::Snapshot,
5414                reply: snapshot_tx,
5415                correlation_id: None,
5416            })
5417            .await
5418            .unwrap();
5419        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5420        incoming_tx.send(reply(1)).await.unwrap();
5421        assert!(matches!(
5422            timeout(Duration::from_secs(1), snapshot_rx).await.unwrap().unwrap(),
5423            Ok(RoutedNodeResponse {
5424                response: Ok(C2NodeResponse::Accepted),
5425                ..
5426            })
5427        ));
5428
5429        drop(commands_tx);
5430        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5431    }
5432
5433    #[test]
5434    fn workspace_entry_create_requires_both_create_and_repository_capabilities() {
5435        let request = NodeRequest::CreateWorkspaceFile {
5436            workspace_id: WorkspaceId::new("primary").unwrap(),
5437            path: unix_repository_path(b"src/\xff"),
5438        };
5439        let mut capabilities = all_path_capabilities();
5440        capabilities.workspace_entry_create = false;
5441        assert!(matches!(
5442            reject_unnegotiated_outbound_path(&request, capabilities),
5443            Err(C2ControlError::Protocol(ref message))
5444                if message == WORKSPACE_ENTRY_CREATE_NOT_NEGOTIATED
5445        ));
5446        capabilities.workspace_entry_create = true;
5447        capabilities.repository_paths = false;
5448        assert!(matches!(
5449            reject_unnegotiated_outbound_path(&request, capabilities),
5450            Err(C2ControlError::Protocol(ref message))
5451                if message == REPOSITORY_PATH_NOT_NEGOTIATED
5452        ));
5453        capabilities.repository_paths = true;
5454        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
5455    }
5456
5457    #[test]
5458    fn provider_session_reference_index_request_is_c2_capability_gated() {
5459        let request = NodeRequest::IndexProviderSession {
5460            workspace_id: WorkspaceId::new("primary").unwrap(),
5461            provider: hatchery_core::AgentId::new("claude").unwrap(),
5462            identity: ProviderSessionIdentity {
5463                key: ProviderSessionKey::SessionId,
5464                id: "provider-session-42".to_owned(),
5465                transcript_path: None,
5466            },
5467            display_name: "release shepherd".to_owned(),
5468        };
5469        let mut capabilities = all_path_capabilities();
5470        capabilities.provider_session_reference_index = false;
5471        assert!(matches!(
5472            reject_unnegotiated_outbound_path(&request, capabilities),
5473            Err(C2ControlError::Protocol(ref message))
5474                if message == PROVIDER_SESSION_REFERENCE_INDEX_NOT_NEGOTIATED
5475        ));
5476        assert!(reject_unnegotiated_outbound_path(
5477            &request,
5478            all_path_capabilities(),
5479        )
5480        .is_ok());
5481    }
5482
5483    #[test]
5484    fn tagged_workspace_file_read_requires_both_file_and_repository_capabilities() {
5485        let request = NodeRequest::ReadWorkspaceFile {
5486            workspace_id: WorkspaceId::new("primary").unwrap(),
5487            path: unix_repository_path(b"src/\xff"),
5488        };
5489        let missing_repository = NegotiatedPathCapabilities {
5490            opaque_host_paths: true,
5491            repository_paths: false,
5492            workspace_file_read: true,
5493            host_directory_browse: true,
5494            standalone_workspace_lifecycle: true,
5495            provider_session_reference_index: true,
5496            provider_ids_open: true,
5497            spawn_spec_defaults_overrides: true,
5498            worktree_selection: true,
5499            managed_worktree_lifecycle: true,
5500            child_environment_profile: true,
5501            session_bundle_materialization: true,
5502            history_context_pack: true,
5503            native_session_catalog: true,
5504            native_session_catalog_paging: true,
5505            native_session_index: true,
5506            native_session_preview: true,
5507            terminal_frame_events: true,
5508            ..Default::default()
5509        };
5510        assert!(matches!(
5511            reject_unnegotiated_outbound_path(&request, missing_repository),
5512            Err(C2ControlError::Protocol(ref message))
5513                if message == REPOSITORY_PATH_NOT_NEGOTIATED
5514        ));
5515        let missing_file_read = NegotiatedPathCapabilities {
5516            opaque_host_paths: true,
5517            repository_paths: true,
5518            workspace_file_read: false,
5519            host_directory_browse: true,
5520            standalone_workspace_lifecycle: true,
5521            provider_session_reference_index: true,
5522            provider_ids_open: true,
5523            spawn_spec_defaults_overrides: true,
5524            worktree_selection: true,
5525            managed_worktree_lifecycle: true,
5526            child_environment_profile: true,
5527            session_bundle_materialization: true,
5528            history_context_pack: true,
5529            native_session_catalog: true,
5530            native_session_catalog_paging: true,
5531            native_session_index: true,
5532            native_session_preview: true,
5533            terminal_frame_events: true,
5534            ..Default::default()
5535        };
5536        assert!(matches!(
5537            reject_unnegotiated_outbound_path(&request, missing_file_read),
5538            Err(C2ControlError::Protocol(ref message))
5539                if message == WORKSPACE_FILE_READ_NOT_NEGOTIATED
5540        ));
5541        assert!(reject_unnegotiated_outbound_path(
5542            &request,
5543            all_path_capabilities(),
5544        ).is_ok());
5545    }
5546
5547    #[test]
5548    fn workspace_file_read_response_is_gated_and_path_checked() {
5549        let response = RoutedNodeResponse {
5550            node_id: NodeId::new("node-a").unwrap(),
5551            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5552            response: Ok(C2NodeResponse::WorkspaceFileRead {
5553                file: WorkspaceFileRead {
5554                    workspace_id: WorkspaceId::new("primary").unwrap(),
5555                    path: unix_repository_path(b"src/\xff"),
5556                    content: WorkspaceFileContent::NonUtf8 { byte_len: 3 },
5557                    revision: None,
5558                },
5559            }),
5560        };
5561        assert!(routed_response_requires_workspace_file_read(&response));
5562        assert!(routed_response_has_unix_repository_path(&response));
5563        assert!(!routed_response_has_unix_bytes(&response));
5564    }
5565
5566    #[test]
5567    fn workspace_entry_create_response_correlation_rejects_content_and_kind_mismatches() {
5568        let workspace_id = WorkspaceId::new("primary").unwrap();
5569        let file_path = repository_path("src/new.rs");
5570        let request = NodeRequest::CreateWorkspaceFile {
5571            workspace_id: workspace_id.clone(),
5572            path: file_path.clone(),
5573        };
5574        let response = Ok(RoutedNodeResponse {
5575            node_id: NodeId::new("node-a").unwrap(),
5576            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5577            response: Ok(C2NodeResponse::WorkspaceFileCreated {
5578                file: WorkspaceFileRead {
5579                    workspace_id: workspace_id.clone(),
5580                    path: file_path,
5581                    content: WorkspaceFileContent::Utf8 {
5582                        text: String::new(),
5583                        byte_len: 0,
5584                    },
5585                    revision: Some(
5586                        gate4agent_node_protocol::WorkspaceFileRevision::new(
5587                            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
5588                                .to_owned(),
5589                        )
5590                        .unwrap(),
5591                    ),
5592                },
5593            }),
5594        });
5595        assert!(validate_workspace_content_response(&request, &response).is_ok());
5596
5597        let wrong_content = Ok(RoutedNodeResponse {
5598            node_id: NodeId::new("node-a").unwrap(),
5599            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5600            response: Ok(C2NodeResponse::WorkspaceFileCreated {
5601                file: WorkspaceFileRead {
5602                    workspace_id: workspace_id.clone(),
5603                    path: repository_path("src/new.rs"),
5604                    content: WorkspaceFileContent::Utf8 {
5605                        text: "unexpected".to_owned(),
5606                        byte_len: 10,
5607                    },
5608                    revision: None,
5609                },
5610            }),
5611        });
5612        assert!(validate_workspace_content_response(&request, &wrong_content).is_err());
5613
5614        let directory_path = repository_path("src/new");
5615        let directory_request = NodeRequest::CreateWorkspaceDirectory {
5616            workspace_id: workspace_id.clone(),
5617            path: directory_path.clone(),
5618        };
5619        let wrong_kind = Ok(RoutedNodeResponse {
5620            node_id: NodeId::new("node-a").unwrap(),
5621            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5622            response: Ok(C2NodeResponse::WorkspaceDirectoryCreated {
5623                workspace_id,
5624                entry: WorkspaceEntry {
5625                    relative_path: directory_path,
5626                    kind: WorkspaceEntryKind::File,
5627                },
5628            }),
5629        });
5630        assert!(validate_workspace_content_response(&directory_request, &wrong_kind).is_err());
5631    }
5632
5633    #[tokio::test]
5634    async fn control_owner_rejects_unnegotiated_unix_path_reply_and_closes() {
5635        let (commands_tx, commands_rx) = mpsc::channel(1);
5636        let (events_tx, _events_rx) = mpsc::channel(1);
5637        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5638        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5639        let (topology_tx, _topology_rx) =
5640            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5641        let owner = tokio::spawn(control_owner(
5642            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5643        ));
5644        let (reply_tx, reply_rx) = oneshot::channel();
5645        commands_tx
5646            .send(ControlCommand {
5647                route: route(),
5648                request: NodeRequest::Snapshot,
5649                reply: reply_tx,
5650                correlation_id: None,
5651            })
5652            .await
5653            .unwrap();
5654        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5655        incoming_tx.send(unix_path_reply(1)).await.unwrap();
5656
5657        assert!(matches!(
5658            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5659            Err(C2ControlError::Protocol(ref message))
5660                if message == OPAQUE_UNIX_PATH_NOT_NEGOTIATED
5661        ));
5662        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5663        assert!(commands_tx.is_closed());
5664    }
5665
5666    #[tokio::test]
5667    async fn control_owner_rejects_unnegotiated_tagged_repository_path_reply_and_closes() {
5668        let (commands_tx, commands_rx) = mpsc::channel(1);
5669        let (events_tx, _events_rx) = mpsc::channel(1);
5670        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5671        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5672        let (topology_tx, _topology_rx) =
5673            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5674        let owner = tokio::spawn(control_owner(
5675            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5676        ));
5677        let (reply_tx, reply_rx) = oneshot::channel();
5678        commands_tx
5679            .send(ControlCommand {
5680                route: route(),
5681                request: NodeRequest::InspectWorkspace {
5682                    workspace_id: WorkspaceId::new("foreign").unwrap(),
5683                },
5684                reply: reply_tx,
5685                correlation_id: None,
5686            })
5687            .await
5688            .unwrap();
5689        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5690        incoming_tx.send(unix_repository_path_reply(1)).await.unwrap();
5691
5692        assert!(matches!(
5693            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5694            Err(C2ControlError::Protocol(ref message))
5695                if message == REPOSITORY_PATH_NOT_NEGOTIATED
5696        ));
5697        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5698        assert!(commands_tx.is_closed());
5699    }
5700
5701    #[tokio::test]
5702    async fn control_owner_rejects_unnegotiated_unix_path_event_and_closes() {
5703        let (commands_tx, commands_rx) = mpsc::channel(1);
5704        let (events_tx, mut events_rx) = mpsc::channel(1);
5705        let (writer_tx, _writer_rx) = mpsc::channel(1);
5706        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5707        let (topology_tx, _topology_rx) =
5708            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5709        let owner = tokio::spawn(control_owner(
5710            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5711        ));
5712        incoming_tx.send(unix_path_event(1)).await.unwrap();
5713
5714        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5715        assert!(events_rx.try_recv().is_err());
5716        assert!(commands_tx.is_closed());
5717    }
5718
5719    #[tokio::test]
5720    async fn late_reply_after_timed_waiter_does_not_desynchronize_following_request() {
5721        let (commands_tx, commands_rx) = mpsc::channel(2);
5722        let (events_tx, _events_rx) = mpsc::channel(1);
5723        let (writer_tx, mut writer_rx) = mpsc::channel(2);
5724        let (incoming_tx, incoming_rx) = mpsc::channel(2);
5725        let (topology_tx, _topology_rx) = watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5726        let owner = tokio::spawn(control_owner(
5727            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5728        ));
5729
5730        let (expired_tx, expired_rx) = oneshot::channel();
5731        commands_tx.send(ControlCommand {
5732            route: route(),
5733            request: NodeRequest::Snapshot,
5734            reply: expired_tx,
5735            correlation_id: None,
5736        }).await.unwrap();
5737        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5738        drop(expired_rx);
5739        incoming_tx.send(reply(1)).await.unwrap();
5740
5741        let (live_tx, live_rx) = oneshot::channel();
5742        commands_tx.send(ControlCommand {
5743            route: route(),
5744            request: NodeRequest::Snapshot,
5745            reply: live_tx,
5746            correlation_id: None,
5747        }).await.unwrap();
5748        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5749        incoming_tx.send(reply(2)).await.unwrap();
5750        assert!(matches!(
5751            timeout(Duration::from_secs(1), live_rx).await.unwrap().unwrap(),
5752            Ok(RoutedNodeResponse { response: Ok(hatchery_core::C2NodeResponse::Accepted), .. })
5753        ));
5754
5755        drop(incoming_tx);
5756        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5757    }
5758
5759    #[tokio::test]
5760    async fn dropped_event_receiver_closes_control_owner() {
5761        let (commands_tx, commands_rx) = mpsc::channel(1);
5762        let (events_tx, events_rx) = mpsc::channel(EVENT_CAPACITY);
5763        drop(events_rx);
5764        let (writer_tx, _writer_rx) = mpsc::channel(1);
5765        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5766        let (topology_tx, _topology_rx) = watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5767        let owner = tokio::spawn(control_owner(
5768            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5769        ));
5770
5771        incoming_tx.send(event(1)).await.unwrap();
5772        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5773        assert!(commands_tx.is_closed());
5774    }
5775
5776    #[tokio::test]
5777    async fn full_regular_channel_drops_event_and_still_serves_following_reply() {
5778        let (commands_tx, commands_rx) = mpsc::channel(1);
5779        let (events_tx, _events_rx) = mpsc::channel(EVENT_CAPACITY);
5780        let mut seq = 1u64;
5781        while events_tx.try_send(routed_event(seq)).is_ok() {
5782            seq += 1;
5783        }
5784        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5785        let (incoming_tx, incoming_rx) = mpsc::channel(2);
5786        let (topology_tx, _topology_rx) = watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5787        let owner = tokio::spawn(control_owner(
5788            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5789        ));
5790
5791        let (reply_tx, reply_rx) = oneshot::channel();
5792        commands_tx.send(ControlCommand {
5793            route: route(),
5794            request: NodeRequest::Snapshot,
5795            reply: reply_tx,
5796            correlation_id: None,
5797        }).await.unwrap();
5798        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5799        // The `regular` channel is full but its receiver is alive -- this
5800        // event must be dropped, not tear the connection down.
5801        incoming_tx.send(event(3)).await.unwrap();
5802        incoming_tx.send(reply(1)).await.unwrap();
5803
5804        assert!(matches!(
5805            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5806            Ok(RoutedNodeResponse {
5807                response: Ok(hatchery_core::C2NodeResponse::Accepted),
5808                ..
5809            })
5810        ));
5811        assert!(!commands_tx.is_closed());
5812
5813        drop(incoming_tx);
5814        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5815    }
5816
5817    #[tokio::test]
5818    async fn active_event_consumer_drains_regular_burst_and_owner_serves_following_reply() {
5819        let burst_len = EVENT_CAPACITY + 2;
5820        let (commands_tx, commands_rx) = mpsc::channel(1);
5821        let (events_tx, mut events_rx) =
5822            mpsc::channel::<RoutedNodeEvent>(EVENT_CAPACITY);
5823        let (writer_tx, mut writer_rx) = mpsc::channel(1);
5824        let (incoming_tx, incoming_rx) = mpsc::channel(burst_len + 1);
5825        let (topology_tx, _topology_rx) = watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
5826
5827        for sequence in 1..=burst_len as u64 {
5828            incoming_tx.send(event(sequence)).await.unwrap();
5829        }
5830        let collector = tokio::spawn(async move {
5831            let mut sequences = Vec::with_capacity(burst_len);
5832            while sequences.len() < burst_len {
5833                let event = events_rx.recv().await.expect("event stream must stay open");
5834                sequences.push(event.cursor.sequence);
5835            }
5836            sequences
5837        });
5838        tokio::task::yield_now().await;
5839        let owner = tokio::spawn(control_owner(
5840            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5841        ));
5842
5843        let sequences = timeout(Duration::from_secs(1), collector)
5844            .await
5845            .unwrap()
5846            .unwrap();
5847        assert_eq!(sequences, (1..=burst_len as u64).collect::<Vec<_>>());
5848
5849        let (reply_tx, reply_rx) = oneshot::channel();
5850        commands_tx.send(ControlCommand {
5851            route: route(),
5852            request: NodeRequest::Snapshot,
5853            reply: reply_tx,
5854            correlation_id: None,
5855        }).await.unwrap();
5856        assert!(matches!(writer_rx.recv().await, Some(C2ClientFrame::Request(_))));
5857        incoming_tx.send(reply(1)).await.unwrap();
5858        assert!(matches!(
5859            timeout(Duration::from_secs(1), reply_rx).await.unwrap().unwrap(),
5860            Ok(RoutedNodeResponse {
5861                response: Ok(C2NodeResponse::Accepted),
5862                ..
5863            })
5864        ));
5865
5866        drop(incoming_tx);
5867        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5868    }
5869
5870    #[tokio::test]
5871    async fn c2_client_topology_replaces_old_incarnation_status() {
5872        use hatchery_core::{C2TopologyNode, NodeTransportState};
5873
5874        let (commands_tx, commands_rx) = mpsc::channel(1);
5875        let (events_tx, mut events_rx) = mpsc::channel(1);
5876        let (writer_tx, _writer_rx) = mpsc::channel(1);
5877        let (incoming_tx, incoming_rx) = mpsc::channel(1);
5878        let old_runtime_status = hatchery_core::ProviderRuntimeStatuses::new([
5879            hatchery_core::ProviderRuntimeStatus::raw_passthrough(
5880                hatchery_core::AgentId::new("claude").unwrap(),
5881                Some(hatchery_core::ProviderRuntimeVersion::new("1.0.0").unwrap()),
5882            ),
5883        ])
5884        .unwrap();
5885        let offline = Arc::new(C2Topology { nodes: vec![C2TopologyNode {
5886            node_id: NodeId::new("node-a").unwrap(),
5887            endpoint: r"\\.\pipe\node-a".to_owned(),
5888            relay_route: hatchery_core::C2RelayRoute::LocalIpc,
5889            transport: NodeTransportState::Offline,
5890            current_incarnation_id: Some(NodeIncarnationId::from_bytes([8; 16])),
5891            machine: None,
5892            provider_contracts: Vec::new(),
5893            provider_adapter_contracts: Vec::new(),
5894            provider_runtime_statuses: old_runtime_status,
5895        }] });
5896        let (topology_tx, mut topology_rx) = watch::channel(offline);
5897        let owner = tokio::spawn(control_owner(
5898            commands_rx, events_tx, topology_tx, writer_tx, incoming_rx, no_path_capabilities(),
5899        ));
5900        let incarnation_id = NodeIncarnationId::from_bytes([9; 16]);
5901        incoming_tx.send(OwnerInput::Frame(C2ServerFrame::Topology(C2Topology {
5902            nodes: vec![C2TopologyNode {
5903                node_id: NodeId::new("node-a").unwrap(),
5904                endpoint: r"\\.\pipe\node-a".to_owned(),
5905                relay_route: hatchery_core::C2RelayRoute::LocalIpc,
5906                transport: NodeTransportState::Online,
5907                current_incarnation_id: Some(incarnation_id),
5908                machine: None,
5909                provider_contracts: Vec::new(),
5910                provider_adapter_contracts: Vec::new(),
5911                provider_runtime_statuses: hatchery_core::ProviderRuntimeStatuses::new([
5912                    hatchery_core::ProviderRuntimeStatus::raw_passthrough(
5913                        hatchery_core::AgentId::new("codex").unwrap(),
5914                        Some(hatchery_core::ProviderRuntimeVersion::new("2.0.0").unwrap()),
5915                    ),
5916                ])
5917                .unwrap(),
5918            }],
5919        }))).await.unwrap();
5920
5921        timeout(Duration::from_secs(1), topology_rx.changed()).await.unwrap().unwrap();
5922        assert_eq!(topology_rx.borrow().nodes[0].current_incarnation_id, Some(incarnation_id));
5923        let topology = topology_rx.borrow().clone();
5924        let statuses = &topology.nodes[0].provider_runtime_statuses;
5925        assert_eq!(statuses.as_slice().len(), 1);
5926        assert_eq!(
5927            statuses.as_slice()[0].provider(),
5928            &hatchery_core::AgentId::new("codex").unwrap(),
5929        );
5930        assert_eq!(statuses.as_slice()[0].version().unwrap().as_str(), "2.0.0");
5931        assert!(events_rx.try_recv().is_err());
5932        drop(incoming_tx);
5933        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
5934        assert!(commands_tx.is_closed());
5935    }
5936
5937    #[test]
5938    fn child_environment_profile_set_and_inherit_require_capability_before_write() {
5939        let mut explicit = spawn_spec("node-a");
5940        explicit.overrides.environment_profile_id =
5941            gate4agent_node_protocol::SpawnOverride::Set {
5942                value: hatchery_core::SpawnEnvironmentProfileId::new(
5943                    "local-default",
5944                )
5945                .unwrap(),
5946            };
5947        let mut capabilities = all_path_capabilities();
5948        capabilities.child_environment_profile = false;
5949        assert!(matches!(
5950            reject_unnegotiated_outbound_path(
5951                &NodeRequest::SpawnSpec { spec: explicit },
5952                capabilities,
5953            ),
5954            Err(C2ControlError::Protocol(ref message))
5955                if message == CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED
5956        ));
5957
5958        let inherited = spawn_spec("node-a");
5959        assert!(matches!(
5960            reject_unnegotiated_outbound_path(
5961                &NodeRequest::SpawnSpec {
5962                    spec: inherited.clone(),
5963                },
5964                capabilities,
5965            ),
5966            Err(C2ControlError::Protocol(ref message))
5967                if message == CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED
5968        ));
5969        let managed = NodeRequest::SpawnManagedWorktree {
5970            request: hatchery_core::ManagedWorktreeSpawnRequest {
5971                spawn_spec: inherited,
5972                worktree_profile_id:
5973                    hatchery_core::WorktreeProfileId::new("review").unwrap(),
5974            },
5975        };
5976        assert!(matches!(
5977            reject_unnegotiated_outbound_path(&managed, capabilities),
5978            Err(C2ControlError::Protocol(ref message))
5979                if message == CHILD_ENVIRONMENT_PROFILE_NOT_NEGOTIATED
5980        ));
5981
5982        let mut cleared = spawn_spec("node-a");
5983        cleared.overrides.environment_profile_id =
5984            gate4agent_node_protocol::SpawnOverride::Clear;
5985        assert!(reject_unnegotiated_outbound_path(
5986            &NodeRequest::SpawnSpec { spec: cleared },
5987            capabilities,
5988        )
5989        .is_ok());
5990    }
5991
5992    #[test]
5993    fn session_bundle_inherit_and_set_require_capability_but_clear_does_not() {
5994        let mut capabilities = all_path_capabilities();
5995        capabilities.session_bundle_materialization = false;
5996
5997        let inherited = spawn_spec("node-a");
5998        assert!(matches!(
5999            reject_unnegotiated_outbound_path(
6000                &NodeRequest::SpawnSpec {
6001                    spec: inherited.clone(),
6002                },
6003                capabilities,
6004            ),
6005            Err(C2ControlError::Protocol(ref message))
6006                if message == SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED
6007        ));
6008
6009        let mut explicit = inherited.clone();
6010        explicit.overrides.bundle_id = gate4agent_node_protocol::SpawnOverride::Set {
6011            value: hatchery_core::SpawnBundleId::new("review-bundle").unwrap(),
6012        };
6013        assert!(matches!(
6014            reject_unnegotiated_outbound_path(
6015                &NodeRequest::SpawnSpec { spec: explicit },
6016                capabilities,
6017            ),
6018            Err(C2ControlError::Protocol(ref message))
6019                if message == SESSION_BUNDLE_MATERIALIZATION_NOT_NEGOTIATED
6020        ));
6021
6022        let mut cleared = inherited;
6023        cleared.overrides.bundle_id = gate4agent_node_protocol::SpawnOverride::Clear;
6024        assert!(reject_unnegotiated_outbound_path(
6025            &NodeRequest::SpawnSpec { spec: cleared },
6026            capabilities,
6027        )
6028        .is_ok());
6029    }
6030
6031    #[test]
6032    fn managed_worktree_partial_capabilities_fail_closed() {
6033        let request = NodeRequest::SpawnManagedWorktree {
6034            request: hatchery_core::ManagedWorktreeSpawnRequest {
6035                spawn_spec: spawn_spec("node-a"),
6036                worktree_profile_id:
6037                    hatchery_core::WorktreeProfileId::new("review").unwrap(),
6038            },
6039        };
6040
6041        let mut capabilities = all_path_capabilities();
6042        capabilities.managed_worktree_lifecycle = false;
6043        assert!(matches!(
6044            reject_unnegotiated_outbound_path(&request, capabilities),
6045            Err(C2ControlError::Protocol(ref message))
6046                if message == MANAGED_WORKTREE_LIFECYCLE_NOT_NEGOTIATED
6047        ));
6048
6049        capabilities.managed_worktree_lifecycle = true;
6050        capabilities.worktree_selection = false;
6051        assert!(matches!(
6052            reject_unnegotiated_outbound_path(&request, capabilities),
6053            Err(C2ControlError::Protocol(ref message))
6054                if message == WORKTREE_SELECTION_NOT_NEGOTIATED
6055        ));
6056
6057        capabilities.worktree_selection = true;
6058        capabilities.spawn_spec_defaults_overrides = false;
6059        assert!(matches!(
6060            reject_unnegotiated_outbound_path(&request, capabilities),
6061            Err(C2ControlError::Protocol(ref message))
6062                if message == SPAWN_SPEC_NOT_NEGOTIATED
6063        ));
6064
6065        let cleanup = NodeRequest::CleanupManagedWorktree {
6066            lease_id: hatchery_core::ManagedWorktreeLeaseId::new("lease-a").unwrap(),
6067        };
6068        capabilities.spawn_spec_defaults_overrides = false;
6069        capabilities.worktree_selection = true;
6070        assert!(reject_unnegotiated_outbound_path(&cleanup, capabilities).is_ok());
6071        capabilities.worktree_selection = false;
6072        assert!(reject_unnegotiated_outbound_path(&cleanup, capabilities).is_err());
6073
6074        let event = C2NodeEvent::ManagedWorktreeRemoved {
6075            lease_id: hatchery_core::ManagedWorktreeLeaseId::new("lease-a").unwrap(),
6076        };
6077        assert!(c2_node_event_is_managed_worktree(&event));
6078
6079        let workspace = C2WorkspaceSnapshot {
6080            workspace_id: WorkspaceId::new("repo").unwrap(),
6081            canonical_root: OpaqueHostPath::utf8(r"C:\repo".to_owned()).unwrap(),
6082            sessions: Vec::new(),
6083            worktree_service_mode: Some(WorktreeServiceMode::Manual),
6084            managed_worktree_profiles: None,
6085        };
6086        assert!(c2_workspace_has_managed_worktree_metadata(&workspace));
6087        assert!(routed_response_requires_managed_worktree(&RoutedNodeResponse {
6088            node_id: NodeId::new("node-a").unwrap(),
6089            incarnation_id: NodeIncarnationId::from_bytes([1; 16]),
6090            response: Ok(C2NodeResponse::WorkspaceRegistered {
6091                workspace: workspace.clone(),
6092            }),
6093        }));
6094        assert!(c2_node_event_is_managed_worktree(
6095            &C2NodeEvent::WorkspaceAdded { workspace },
6096        ));
6097
6098        let legacy = C2WorkspaceSnapshot {
6099            workspace_id: WorkspaceId::new("legacy").unwrap(),
6100            canonical_root: OpaqueHostPath::utf8(r"C:\legacy".to_owned()).unwrap(),
6101            sessions: Vec::new(),
6102            worktree_service_mode: None,
6103            managed_worktree_profiles: None,
6104        };
6105        assert!(!c2_workspace_has_managed_worktree_metadata(&legacy));
6106        let profile_only = C2WorkspaceSnapshot {
6107            managed_worktree_profiles: Some(
6108                hatchery_core::WorktreeProfileInventory {
6109                    profiles: Vec::new(),
6110                },
6111            ),
6112            ..legacy
6113        };
6114        assert!(c2_workspace_has_managed_worktree_metadata(&profile_only));
6115    }
6116
6117    #[test]
6118    fn managed_worktree_v2_is_capability_gated_and_receipt_correlated() {
6119        let offer = client_compatibility_offer().unwrap();
6120        assert!(offer.capabilities.iter().any(|capability| {
6121            capability.as_str() == C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY
6122        }));
6123        let request = NodeRequest::SpawnManagedWorktreeV2 {
6124            request: hatchery_core::ManagedWorktreeSpawnRequestV2 {
6125                spawn_spec: spawn_spec("node-a"),
6126                worktree_profile_id:
6127                    hatchery_core::WorktreeProfileId::new("review").unwrap(),
6128                expected_profile_revision:
6129                    hatchery_core::WorktreeProfileRevision::new("wr1").unwrap(),
6130            },
6131        };
6132        let mut capabilities = all_path_capabilities();
6133        capabilities.managed_worktree_spawn_v2 = false;
6134        assert!(matches!(
6135            reject_unnegotiated_outbound_path(&request, capabilities),
6136            Err(C2ControlError::Protocol(ref message))
6137                if message == MANAGED_WORKTREE_SPAWN_V2_NOT_NEGOTIATED
6138        ));
6139
6140        let managed_workspace_id = WorkspaceId::new("managed-v2").unwrap();
6141        let mut spawn = spawn_receipt();
6142        spawn.profile_revision = SpawnProfileRevision::new("default.r1").unwrap();
6143        spawn.target.worktree_id = Some(managed_workspace_id.clone());
6144        spawn.session.workspace_id = managed_workspace_id.clone();
6145        let receipt = hatchery_core::ManagedWorktreeSpawnReceipt {
6146            spawn,
6147            lease: hatchery_core::ManagedWorktreeLeaseSnapshot {
6148                lease_id:
6149                    hatchery_core::ManagedWorktreeLeaseId::new("lease-v2").unwrap(),
6150                source_workspace_id: WorkspaceId::new("primary").unwrap(),
6151                workspace_id: managed_workspace_id,
6152                profile_id:
6153                    hatchery_core::WorktreeProfileId::new("review").unwrap(),
6154                profile_revision:
6155                    hatchery_core::WorktreeProfileRevision::new("wr1").unwrap(),
6156                retention: hatchery_core::ManagedWorktreeRetention::RemoveWhenReleased,
6157                state: hatchery_core::ManagedWorktreeLeaseState::InUse,
6158                active_session_count: 1,
6159                managed_record_count: 1,
6160                cleanup_failure: None,
6161                created_at_unix_ms: 1,
6162                updated_at_unix_ms: 2,
6163            },
6164        };
6165        let routed = RoutedNodeResponse {
6166            node_id: NodeId::new("node-a").unwrap(),
6167            incarnation_id: receipt.spawn.incarnation_id,
6168            response: Ok(C2NodeResponse::ManagedWorktreeSpawnAccepted {
6169                receipt: receipt.clone(),
6170            }),
6171        };
6172        assert!(validate_managed_worktree_spawn_v2_response(
6173            &request,
6174            &Ok(routed),
6175        )
6176        .is_ok());
6177
6178        let mut mismatched = receipt;
6179        mismatched.lease.profile_revision =
6180            hatchery_core::WorktreeProfileRevision::new("wr2").unwrap();
6181        let routed = RoutedNodeResponse {
6182            node_id: NodeId::new("node-a").unwrap(),
6183            incarnation_id: mismatched.spawn.incarnation_id,
6184            response: Ok(C2NodeResponse::ManagedWorktreeSpawnAccepted {
6185                receipt: mismatched,
6186            }),
6187        };
6188        assert!(validate_managed_worktree_spawn_v2_response(
6189            &request,
6190            &Ok(routed),
6191        )
6192        .is_err());
6193    }
6194
6195    #[test]
6196    fn history_context_pack_requests_and_failures_are_capability_bound() {
6197        let request = NodeRequest::DiscoverHistory {
6198            session: spawn_receipt().session,
6199            limit: 1,
6200        };
6201        let mut capabilities = all_path_capabilities();
6202        capabilities.history_context_pack = false;
6203        assert!(matches!(
6204            reject_unnegotiated_outbound_path(&request, capabilities),
6205            Err(C2ControlError::Protocol(ref message))
6206                if message == HISTORY_CONTEXT_PACK_NOT_NEGOTIATED
6207        ));
6208        capabilities.history_context_pack = true;
6209        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
6210
6211        let invalid = NodeRequest::DiscoverHistory {
6212            session: spawn_receipt().session,
6213            limit: 0,
6214        };
6215        assert!(matches!(
6216            reject_unnegotiated_outbound_path(&invalid, capabilities),
6217            Err(C2ControlError::Protocol(ref message))
6218                if message == INVALID_HISTORY_CONTEXT_PACK_REQUEST
6219        ));
6220
6221        capabilities.provider_ids_open = false;
6222        let forget = NodeRequest::ForgetContextPack {
6223            context_id: gate4agent_node_protocol::SpawnContextId::new("context-a").unwrap(),
6224        };
6225        assert!(matches!(
6226            reject_unnegotiated_outbound_path(&forget, capabilities),
6227            Err(C2ControlError::Protocol(ref message))
6228                if message == OPEN_PROVIDER_ID_NOT_NEGOTIATED
6229        ));
6230        capabilities.provider_ids_open = true;
6231        assert!(reject_unnegotiated_outbound_path(&forget, capabilities).is_ok());
6232
6233        let failure = RoutedNodeResponse {
6234            node_id: NodeId::new("node-a").unwrap(),
6235            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
6236            response: Err(hatchery_core::C2NodeFailure {
6237                code: gate4agent_node_protocol::NodeFailureCode::UnknownContextPack,
6238                message: "context pack unavailable".to_owned(),
6239            }),
6240        };
6241        assert!(routed_response_requires_history_context_pack(&failure));
6242        let response = RoutedNodeResponse {
6243            node_id: NodeId::new("node-a").unwrap(),
6244            incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
6245            response: Ok(C2NodeResponse::HistoryLoaded {
6246                session: spawn_receipt().session,
6247                session_id: "session-1".to_owned(),
6248                message_count: 2,
6249                completed_turn_count: None,
6250            }),
6251        };
6252        assert!(routed_response_requires_history_context_pack(&response));
6253    }
6254
6255    #[test]
6256    fn native_session_catalog_request_is_capability_bound_and_read_only() {
6257        let route = gate4agent_node_protocol::NativeSessionCatalogRoute::workspace(
6258            gate4agent_node_protocol::WorkspaceId::new("primary").unwrap(),
6259            hatchery_core::AgentId::new("codex").unwrap(),
6260        );
6261        let request = NodeRequest::CatalogNativeSessions {
6262            route: route.clone(),
6263            limit: 8,
6264        };
6265        let mut capabilities = all_path_capabilities();
6266        capabilities.native_session_catalog = false;
6267        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_err());
6268        capabilities.native_session_catalog = true;
6269        assert!(reject_unnegotiated_outbound_path(&request, capabilities).is_ok());
6270        assert_eq!(control_request_deadline(&request), Duration::from_secs(40));
6271
6272        let page = NodeRequest::PageNativeSessions {
6273            route,
6274            window: hatchery_core::NativeSessionCatalogWindow::Older,
6275            catalog_revision: 7,
6276            recent_cutoff_unix_ms: 8,
6277            after_selection_id: Some("hist_selection_1".to_owned()),
6278            limit: 8,
6279        };
6280        capabilities.native_session_catalog_paging = false;
6281        assert!(reject_unnegotiated_outbound_path(&page, capabilities).is_err());
6282        capabilities.native_session_catalog_paging = true;
6283        assert!(reject_unnegotiated_outbound_path(&page, capabilities).is_ok());
6284        assert_eq!(control_request_deadline(&page), Duration::from_secs(40));
6285    }
6286
6287    #[tokio::test]
6288    async fn control_owner_rejects_reply_route_or_incarnation_mismatch() {
6289        let (commands_tx, commands_rx) = mpsc::channel(1);
6290        let (events_tx, _events_rx) = mpsc::channel(1);
6291        let (writer_tx, mut writer_rx) = mpsc::channel(1);
6292        let (incoming_tx, incoming_rx) = mpsc::channel(1);
6293        let (topology_tx, _topology_rx) =
6294            watch::channel(Arc::new(C2Topology { nodes: Vec::new() }));
6295        let owner = tokio::spawn(control_owner(
6296            commands_rx,
6297            events_tx,
6298            topology_tx,
6299            writer_tx,
6300            incoming_rx,
6301            all_path_capabilities(),
6302        ));
6303        let (reply_tx, reply_rx) = oneshot::channel();
6304        commands_tx
6305            .send(ControlCommand {
6306                route: route(),
6307                request: NodeRequest::Snapshot,
6308                reply: reply_tx,
6309                correlation_id: None,
6310            })
6311            .await
6312            .unwrap();
6313        let C2ClientFrame::Request(request) = writer_rx.recv().await.unwrap() else {
6314            panic!("owner did not emit a request");
6315        };
6316        incoming_tx
6317            .send(OwnerInput::Frame(C2ServerFrame::Reply(
6318                hatchery_core::C2ReplyEnvelope {
6319                    request_id: request.request_id,
6320                    result: Ok(RoutedNodeResponse {
6321                        node_id: request.request.route.node_id,
6322                        incarnation_id: NodeIncarnationId::from_bytes([8; 16]),
6323                        response: Ok(C2NodeResponse::Accepted),
6324                    }),
6325                    correlation_id: None,
6326                },
6327            )))
6328            .await
6329            .unwrap();
6330        assert!(matches!(
6331            reply_rx.await.unwrap(),
6332            Err(C2ControlError::Protocol(ref message))
6333                if message.contains("route or node incarnation")
6334        ));
6335        timeout(Duration::from_secs(1), owner).await.unwrap().unwrap();
6336    }
6337}
6338
6339#[cfg(all(test, unix))]
6340mod unix_endpoint_tests {
6341    use super::*;
6342
6343    #[test]
6344    fn unix_endpoint_remains_valid() {
6345        assert!(validate_endpoint("/tmp/gate4agent-c2.sock").is_ok());
6346    }
6347
6348    #[test]
6349    fn raw_public_tcp_endpoint_is_still_rejected_by_the_unix_validator() {
6350        for endpoint in ["203.0.113.8:51820", "10.88.1.2:27441", "tcp://10.88.1.2:27441"] {
6351            assert!(
6352                matches!(validate_endpoint(endpoint), Err(C2ControlError::InvalidEndpoint)),
6353                "{endpoint} must stay InvalidEndpoint",
6354            );
6355        }
6356    }
6357}