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