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