1pub use gate4agent_node_protocol::{
9 AdapterContractRevision, AdapterFamily, AdapterId, AgentId, ArchitectureId, CapabilityId,
10 ClientCompatibilityOffer, HostDescriptor, HostDirectoryEntry, HostDirectoryListing,
11 ManagedWorktreeCleanupFailure, ManagedWorktreeGitScope, ManagedWorktreeLeaseId,
12 ManagedWorktreeLeaseSnapshot,
13 ManagedWorktreeLeaseState, ManagedWorktreeRetention, ManagedWorktreeSpawnReceipt,
14 ManagedWorktreeSpawnRequest, ManagedWorktreeSpawnRequestV2, ManagedWorktreeProfileSummary,
15 WorktreeProfileInventory,
16 WorktreeServiceMode,
17 provider_id_is_legacy, NodeCursor, NodeEvent, NodeFailure, NodeId, NodeIncarnationId, NodeRequest,
18 NodeResponse, OpaqueHostPath, OperatingSystemId, PathEncoding, PathSemantics, PathStyle,
19 ProviderAdapterContractSupport, ProviderContractRevision, ProviderContractSupport,
20 ProviderRuntimeContractId, ProviderRuntimeMode, ProviderRuntimeStatus,
21 ProviderRuntimeStatuses, ProviderRuntimeVersion,
22 LaunchInventory, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
23 ResolvedSpawnReceipt, SpawnProfileSummary,
24 ContextPackBytesRead, ContextPackLineageReceipt, ResolvedContextPackReceipt, SpawnContextDigest,
25 ResolvedSpawnSpec, SpawnBundleDigest, SpawnBundleId, SpawnBundleRevision,
26 SpawnContextId, SpawnDeadlineMs, SpawnEnvironmentProfileId,
27 SpawnEnvironmentProfileRevision, SpawnNetworkAllowlistId, SpawnBrowserProfileId,
28 SpawnFieldProvenance, SpawnIdempotencyKey, SpawnOverride,
29 SpawnOverrides, SpawnProfileDefaults, SpawnProfileId, SpawnProfileRevision, SpawnPrompt,
30 SpawnPromptMetadata, SpawnRequiredCapabilities, SpawnResolutionProvenance, SpawnSpec,
31 SpawnTarget,
32 WorktreeProfileId, WorktreeProfileRevision,
33 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
34 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
35 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
36 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
37 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
38 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
39 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
40 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
41 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
42 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
43 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
44 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
45 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
46 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
47 NODE_PROVIDER_ID_OPEN_CAPABILITY, NODE_SESSION_TASK_CORRELATION_CAPABILITY,
48 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
49 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
50 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
51 NODE_ACP_CONTROL_CAPABILITY,
52 NODE_WORKTREE_SELECTION_CAPABILITY,
53 GitDiff, GitHistoryPage, RepositoryPath, WorkspaceFileContent, WorkspaceFileRead,
54 HistoryCandidateSummary, NativeSessionCatalogEntry, NativeSessionCatalogPage,
55 NativeSessionCatalogRoute, NativeSessionCatalogScope, NativeSessionCatalogSummary,
56 NativeSessionCatalogWindow, NativeSessionExternalGroup, NativeSessionExternalGroupKind,
57 NativeSessionPreview, NativeSessionSelection, SessionRecordPreview,
58 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
59 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, SessionAgentProgress,
60 SessionTaskBindingV1, SessionTaskTargetV1, TaskId,
61 DeliveryBlobChunkHexV1, DeliveryBlobDigestV1, DeliveryBlobReceiptV1,
62 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryManifestDigestV2,
63 DeliveryStageId,
64 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
65 HarnessMcpActivationDigest, HarnessMcpCallId, HarnessMcpContentTypeV1,
66 HarnessMcpOpaquePayloadV1, HarnessMcpRejectReasonV1,
67 HarnessMcpReplyChunkHexV1, HarnessMcpReservationId,
68 ResolvedHarnessMcpProxyReceiptV1,
69 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
70 ProtocolNegotiationError, ProtocolRange,
71};
72pub use gate4agent_build_stamp::BUILD_STAMP;
73mod control_detail;
74pub use control_detail::control_telemetry_detail;
75
76mod multiplex;
77pub use multiplex::*;
78pub mod inner;
79#[cfg(feature = "nested-crypto")]
80pub mod nested;
81pub mod buffer;
82pub mod journal;
83
84#[cfg(feature = "nested-crypto")]
86pub mod crypto {
87 pub use crate::nested::*;
88}
89#[cfg(feature = "nested-crypto")]
90pub use nested::{PublicKeyBytes, RestreamerKeys};
91pub use buffer::{BufferLimits, BufferedEvent, EnqueueOutcome, ReplayBuffer, StreamKey};
92pub use journal::{
93 recover_gap, FileJournal, JournalEntry, JournalError, LocalFileJournal, MemoryJournal,
94 NodeJournal,
95};
96use gate4agent_node_protocol::{
97 AgentStreamChunkV1, ManagedSessionRecord, ManagedSessionState, SessionHistorySummaryV1,
98 NodeSnapshot, SessionAddress, SessionMode, SessionRecordId, WorkspaceId,
99};
100use gate4agent_types::{
101 AgentInstanceId, OperationId, PreparedInputKind, ProviderActivity, PtyScreenState,
102 SessionGeneration, SessionStatus, TerminalFrame, TerminalSize, TransportKind,
103};
104use serde::de::{SeqAccess, Visitor};
105use serde::{Deserialize, Deserializer, Serialize};
106use std::collections::BTreeMap;
107use std::fmt;
108
109pub const C2_API_VERSION: u16 = 2;
110pub const DEFAULT_C2_API_LISTEN: &str = "127.0.0.1:18320";
111pub const C2_COMPATIBILITY_METADATA_CAPABILITY: &str = "compatibility.metadata";
112pub const C2_OPAQUE_UNIX_PATH_CAPABILITY: &str =
113 gate4agent_node_protocol::NODE_OPAQUE_UNIX_PATH_CAPABILITY;
114pub const C2_REPOSITORY_PATH_CAPABILITY: &str =
115 gate4agent_node_protocol::NODE_REPOSITORY_PATH_CAPABILITY;
116pub const C2_WORKSPACE_FILE_READ_CAPABILITY: &str =
117 gate4agent_node_protocol::NODE_WORKSPACE_FILE_READ_CAPABILITY;
118pub const C2_WORKSPACE_FILE_WRITE_CAPABILITY: &str =
119 gate4agent_node_protocol::NODE_WORKSPACE_FILE_WRITE_CAPABILITY;
120pub const C2_WORKSPACE_ENTRY_CREATE_CAPABILITY: &str =
121 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY;
122pub const C2_GIT_READ_CAPABILITY: &str = gate4agent_node_protocol::NODE_GIT_READ_CAPABILITY;
123pub const C2_PROVIDER_CONTRACT_MANIFEST_CAPABILITY: &str =
124 gate4agent_node_protocol::NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY;
125pub const C2_PROVIDER_RUNTIME_STATUS_CAPABILITY: &str =
126 gate4agent_node_protocol::NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY;
127pub const C2_PROVIDER_ID_OPEN_CAPABILITY: &str = NODE_PROVIDER_ID_OPEN_CAPABILITY;
128pub const C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY: &str =
129 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY;
130pub const C2_SPAWN_PROFILE_REVISION_CAPABILITY: &str =
131 NODE_SPAWN_PROFILE_REVISION_CAPABILITY;
132pub const C2_TERMINAL_FRAME_EVENTS_CAPABILITY: &str = NODE_TERMINAL_FRAME_EVENTS_CAPABILITY;
133pub const C2_AGENT_STREAM_EVENTS_CAPABILITY: &str = NODE_AGENT_STREAM_EVENTS_CAPABILITY;
138pub const C2_ACP_CONTROL_CAPABILITY: &str = NODE_ACP_CONTROL_CAPABILITY;
143pub const C2_WORKTREE_SELECTION_CAPABILITY: &str = NODE_WORKTREE_SELECTION_CAPABILITY;
144pub const C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY: &str =
145 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY;
146pub const C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY: &str =
147 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY;
148pub const C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY: &str =
149 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY;
150pub const C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY: &str =
151 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY;
152pub const C2_HISTORY_CONTEXT_PACK_CAPABILITY: &str = NODE_HISTORY_CONTEXT_PACK_CAPABILITY;
153pub const C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY: &str =
154 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY;
155pub const C2_NATIVE_SESSION_CATALOG_CAPABILITY: &str = NODE_NATIVE_SESSION_CATALOG_CAPABILITY;
156pub const C2_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY: &str =
157 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY;
158pub const C2_NATIVE_SESSION_PREVIEW_CAPABILITY: &str = NODE_NATIVE_SESSION_PREVIEW_CAPABILITY;
159pub const C2_NATIVE_SESSION_INDEX_CAPABILITY: &str = NODE_NATIVE_SESSION_INDEX_CAPABILITY;
160pub const C2_HOST_DIRECTORY_BROWSE_CAPABILITY: &str = CAPABILITY_HOST_DIRECTORY_BROWSE_V1;
161pub const C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY: &str =
162 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY;
163pub const C2_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY: &str =
164 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY;
165pub const C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY: &str =
166 gate4agent_node_protocol::NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY;
167pub const C2_SESSION_TASK_CORRELATION_CAPABILITY: &str =
168 NODE_SESSION_TASK_CORRELATION_CAPABILITY;
169pub const C2_CONTROL_DETAIL_CAPABILITY: &str = "control-detail-v1";
175pub const C2_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY: &str =
176 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY;
177pub const C2_HARNESS_MCP_READ_PROXY_CAPABILITY: &str =
178 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY;
179pub const C2_AUTH_NONCE_BYTES: usize = 32;
180pub const C2_AUTH_PROOF_BYTES: usize = 32;
181pub const MAX_C2_AUTH_COMPATIBILITY_CAPABILITIES: usize = 64;
182pub const MAX_C2_BOUND_AUTH_TRANSCRIPT_BYTES: usize = 16 * 1024;
183pub const MAX_C2_CLIENT_FRAME_BYTES: usize = 256 * 1024;
184pub const MAX_C2_SERVER_FRAME_BYTES: usize = 8 * 1024 * 1024;
185pub const MAX_C2_AUTH_FRAME_BYTES: usize = 8 * 1024;
186pub const MAX_C2_HELLO_FRAME_BYTES: usize = MAX_C2_SERVER_FRAME_BYTES;
187pub const MAX_C2_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
188pub const MAX_C2_NODES: usize = 64;
189pub const MAX_C2_ENDPOINT_BYTES: usize = 1024;
190pub const MAX_C2_WORKSPACES_PER_NODE: usize = 32;
191pub const MAX_C2_SESSIONS_PER_NODE: usize = 128;
192pub const MAX_C2_MANAGED_SESSIONS_PER_NODE: usize = 128;
193pub const MAX_C2_MANAGED_WORKTREES_PER_NODE: usize =
194 gate4agent_node_protocol::MAX_MANAGED_WORKTREE_LEASES;
195pub const MAX_C2_GAPS_PER_NODE: usize = 64;
196pub const MAX_C2_ROOT_BYTES: usize = 1024;
197pub const MAX_C2_SESSION_DISPLAY_NAME_BYTES: usize =
198 gate4agent_node_protocol::MAX_SESSION_DISPLAY_NAME_BYTES;
199
200#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
201#[serde(transparent)]
202pub struct C2RequestId(pub u64);
203
204#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
205pub struct NodeRoute {
206 pub node_id: NodeId,
207 pub expected_incarnation_id: NodeIncarnationId,
208}
209
210#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
211pub struct RoutedNodeRequest {
212 pub route: NodeRoute,
213 pub request: NodeRequest,
214}
215
216#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
217pub struct RoutedNodeResponse {
218 pub node_id: NodeId,
219 pub incarnation_id: NodeIncarnationId,
220 pub response: Result<C2NodeResponse, C2NodeFailure>,
221}
222
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224pub struct RoutedNodeEvent {
225 pub node_id: NodeId,
226 pub cursor: NodeCursor,
227 pub event: C2NodeEvent,
228}
229
230#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
231pub struct C2NodeFailure {
232 pub code: gate4agent_node_protocol::NodeFailureCode,
233 pub message: String,
234}
235
236impl From<&NodeFailure> for C2NodeFailure {
237 fn from(failure: &NodeFailure) -> Self {
238 use gate4agent_node_protocol::NodeFailureCode;
239 let message = match failure.code {
240 NodeFailureCode::InvalidRequest => "invalid request",
241 NodeFailureCode::UnsupportedCapability => "required capability unavailable",
242 NodeFailureCode::SpawnProfileRevisionMismatch => {
243 "spawn profile revision mismatch"
244 }
245 NodeFailureCode::HarnessMcpUnavailable => "harness MCP proxy unavailable",
246 NodeFailureCode::ReservationNotFound => "harness MCP reservation unavailable",
247 NodeFailureCode::ReservationConflict => "harness MCP reservation conflict",
248 NodeFailureCode::ReservationExpired => "harness MCP reservation expired",
249 NodeFailureCode::BindingMismatch => "harness MCP binding mismatch",
250 NodeFailureCode::NotActivated => "harness MCP reservation not activated",
251 NodeFailureCode::CallNotFound => "harness MCP call unavailable",
252 NodeFailureCode::ChunkOutOfOrder => "harness MCP reply chunk out of order",
253 NodeFailureCode::ResponseTooLarge => "harness MCP response too large",
254 NodeFailureCode::DeliveryManifestInvalid => "delivery manifest invalid",
255 NodeFailureCode::UnknownDeliveryStage => "delivery stage unavailable",
256 NodeFailureCode::DeliveryStageConflict => "delivery stage conflict",
257 NodeFailureCode::DeliveryBlobUnexpected => "delivery blob unexpected",
258 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery chunk out of order",
259 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery blob digest mismatch",
260 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery bundle digest mismatch",
261 NodeFailureCode::DeliveryStageIncomplete => "delivery stage incomplete",
262 NodeFailureCode::DeliveryStageStorageFailed => "delivery stage storage failed",
263 NodeFailureCode::Unauthorized => "authentication rejected",
264 NodeFailureCode::ObserverReadOnly => "operator access required",
265 NodeFailureCode::ControllerBusy => "controller busy",
266 NodeFailureCode::ControllerRequired => "controller required",
267 NodeFailureCode::UnknownWorkspace => "workspace unavailable",
268 NodeFailureCode::HostDirectoryInvalid => "host directory invalid",
269 NodeFailureCode::HostDirectoryReadFailed => "host directory read failed",
270 NodeFailureCode::HostDirectoryReadTimedOut => "host directory read timed out",
271 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
272 "standalone workspace recovery required"
273 }
274 NodeFailureCode::InvalidRepositoryPath => "repository path invalid",
275 NodeFailureCode::RepositoryFileNotFound => "repository file unavailable",
276 NodeFailureCode::RepositoryFileNotRegular => "repository path is not a regular file",
277 NodeFailureCode::RepositoryPathUnsafe => "repository path is unsafe",
278 NodeFailureCode::RepositoryFileReadTimedOut => "repository file read timed out",
279 NodeFailureCode::RepositoryFileReadFailed => "repository file read failed",
280 NodeFailureCode::RepositoryFileWriteTimedOut => "repository file write timed out",
281 NodeFailureCode::RepositoryFileWriteFailed => "repository file write failed",
282 NodeFailureCode::RepositoryFileRevisionConflict => "repository file changed since it was opened",
283 NodeFailureCode::RepositoryEntryAlreadyExists => "repository entry already exists",
284 NodeFailureCode::RepositoryParentNotFound => "repository parent directory unavailable",
285 NodeFailureCode::RepositoryParentNotDirectory => "repository parent path is not a directory",
286 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository entry creation timed out",
287 NodeFailureCode::RepositoryEntryCreateFailed => "repository entry creation failed",
288 NodeFailureCode::GitReadTimedOut => "git read timed out",
289 NodeFailureCode::GitReadFailed => "git read failed",
290 NodeFailureCode::InvalidWorkspaceRoot => "workspace root invalid",
291 NodeFailureCode::DuplicateWorkspaceId => "workspace ID already registered",
292 NodeFailureCode::DuplicateWorkspaceRoot => "workspace root already registered",
293 NodeFailureCode::WorkspaceBusy => "workspace busy",
294 NodeFailureCode::LastWorkspace => "last workspace protected",
295 NodeFailureCode::NotGitRepository => "workspace is not a git repository",
296 NodeFailureCode::WorktreeConflict => "worktree conflict",
297 NodeFailureCode::WorktreeProtected => "worktree protected",
298 NodeFailureCode::WorktreeDirty => "worktree dirty",
299 NodeFailureCode::WorktreeLocked => "worktree locked",
300 NodeFailureCode::UnknownManagedWorktreeLease => "managed worktree unavailable",
301 NodeFailureCode::ManagedWorktreeBusy => "managed worktree busy",
302 NodeFailureCode::ManagedWorktreeOwnershipConflict => {
303 "managed worktree ownership conflict"
304 }
305 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
306 "managed worktree profile revision mismatch"
307 }
308 NodeFailureCode::ManagedWorktreeRecoveryRequired => {
309 "managed worktree recovery required"
310 }
311 NodeFailureCode::SpawnTargetMismatch => "spawn target mismatch",
312 NodeFailureCode::UnknownSpawnProfile => "spawn profile unavailable",
313 NodeFailureCode::UnknownBundle => "session bundle unavailable",
314 NodeFailureCode::UnknownEnvironmentProfile => {
315 "environment profile unavailable"
316 }
317 NodeFailureCode::UnknownNetworkAllowlist => {
318 "network allowlist unavailable"
319 }
320 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
321 "network allowlist mapping unsupported for this provider"
322 }
323 NodeFailureCode::BrowserStationProbeUnavailable => {
324 "dig2browser station probe unavailable on this platform"
325 }
326 NodeFailureCode::BrowserStationUnreachable => {
327 "dig2browser station unreachable"
328 }
329 NodeFailureCode::BrowserStationProfileBusy => {
330 "dig2browser station profile busy"
331 }
332 NodeFailureCode::BundleBindingMismatch => {
333 "session bundle binding mismatch"
334 }
335 NodeFailureCode::EnvironmentProfileBindingMismatch => {
336 "environment profile binding mismatch"
337 }
338 NodeFailureCode::BundleMaterializationFailed => {
339 "session bundle materialization failed"
340 }
341 NodeFailureCode::SpawnIdempotencyConflict => "spawn idempotency conflict",
342 NodeFailureCode::SpawnIdempotencyCapacity => "spawn idempotency capacity exhausted",
343 NodeFailureCode::SpawnDeadlineExceeded => "spawn deadline exceeded",
344 NodeFailureCode::UnsupportedSpawnCapability => "spawn capability unavailable",
345 NodeFailureCode::UnsupportedTransport => "provider does not support the requested transport",
346 NodeFailureCode::TurnInFlight => "session already has a turn in flight",
347 NodeFailureCode::UnknownSession => "session unavailable",
348 NodeFailureCode::UnknownSessionRecord => "managed session unavailable",
349 NodeFailureCode::SessionRecordNotResumable => "managed session cannot resume",
350 NodeFailureCode::SessionRecordBusy => "managed session busy",
351 NodeFailureCode::SessionRecordConflict => "managed session conflict",
352 NodeFailureCode::SessionWorkspaceMismatch => "session workspace mismatch",
353 NodeFailureCode::WorkspaceRegistrationRequired => {
354 "workspace registration required"
355 }
356 NodeFailureCode::StaleNativeSessionCatalog => "native session catalog is stale",
357 NodeFailureCode::UnknownContextPack => "context pack unavailable",
358 NodeFailureCode::ContextPackBusy => "context pack busy",
359 NodeFailureCode::ContextPackMaterializationFailed => {
360 "context pack materialization failed"
361 }
362 NodeFailureCode::StaleGeneration => "stale session generation",
363 NodeFailureCode::BackendBusy => "node backend busy",
364 NodeFailureCode::BackendDisconnected => "node backend disconnected",
365 NodeFailureCode::BackendOperationFailed => "node backend operation failed",
366 NodeFailureCode::ShuttingDown => "node shutting down",
367 };
368 Self { code: failure.code, message: message.to_owned() }
369 }
370}
371
372impl C2NodeFailure {
373 pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
374 use gate4agent_node_protocol::NodeFailureCode;
375 matches!(self.code,
376 NodeFailureCode::HarnessMcpUnavailable
377 | NodeFailureCode::ReservationNotFound
378 | NodeFailureCode::ReservationConflict
379 | NodeFailureCode::ReservationExpired
380 | NodeFailureCode::BindingMismatch
381 | NodeFailureCode::NotActivated
382 | NodeFailureCode::CallNotFound
383 | NodeFailureCode::ChunkOutOfOrder
384 | NodeFailureCode::ResponseTooLarge)
385 }
386
387 pub fn requires_host_directory_browse_capability(&self) -> bool {
388 use gate4agent_node_protocol::NodeFailureCode;
389 matches!(
390 self.code,
391 NodeFailureCode::HostDirectoryInvalid
392 | NodeFailureCode::HostDirectoryReadFailed
393 | NodeFailureCode::HostDirectoryReadTimedOut
394 )
395 }
396
397 pub fn requires_history_context_pack_capability(&self) -> bool {
398 use gate4agent_node_protocol::NodeFailureCode;
399 matches!(
400 self.code,
401 NodeFailureCode::UnknownContextPack
402 | NodeFailureCode::ContextPackBusy
403 | NodeFailureCode::ContextPackMaterializationFailed
404 )
405 }
406
407 pub fn requires_native_session_catalog_paging_capability(&self) -> bool {
408 self.code == gate4agent_node_protocol::NodeFailureCode::StaleNativeSessionCatalog
409 }
410}
411
412#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
413pub struct C2ManagedSessionRecord {
414 pub record_id: SessionRecordId,
415 pub display_name: String,
416 pub provider: AgentId,
417 pub mode: SessionMode,
418 pub state: ManagedSessionState,
419 pub workspace_id: WorkspaceId,
420 pub active_session: Option<SessionAddress>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub bundle: Option<ResolvedBundleReceipt>,
425 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub context_id: Option<SpawnContextId>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub context: Option<ResolvedContextPackReceipt>,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub exported_context: Option<ResolvedContextPackReceipt>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub task_binding: Option<SessionTaskBindingV1>,
433 pub provider_identity_present: bool,
434 pub created_at_unix_ms: u64,
435 pub updated_at_unix_ms: u64,
436}
437
438impl C2ManagedSessionRecord {
439 pub fn context_binding_is_valid(&self) -> bool {
440 match (self.context_id.as_ref(), self.context.as_ref()) {
441 (None, None) => true,
442 (Some(context_id), Some(context)) => {
443 &context.id == context_id && context.is_valid()
444 }
445 (None, Some(_)) | (Some(_), None) => false,
446 }
447 }
448
449 pub fn requires_history_context_pack_capability(&self) -> bool {
450 self.context_id.is_some() || self.context.is_some()
451 }
452
453 pub fn exported_context_is_valid(&self) -> bool {
454 self.exported_context.as_ref().map_or(true, |pack| {
455 pack.is_valid() && pack.lineage.source_provider == self.provider
456 })
457 }
458
459 pub fn requires_session_task_correlation_capability(&self) -> bool {
460 self.task_binding.is_some()
461 }
462
463 pub fn task_binding_is_valid(&self) -> bool {
464 self.task_binding.as_ref().map_or(true, |binding| {
465 binding.revision > 0
466 && binding.changed_at_unix_ms >= self.created_at_unix_ms
467 && binding.changed_at_unix_ms <= self.updated_at_unix_ms
468 })
469 }
470}
471
472impl<'de> Deserialize<'de> for C2ManagedSessionRecord {
473 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
474 where
475 D: Deserializer<'de>,
476 {
477 #[derive(Deserialize)]
478 struct WireRecord {
479 record_id: SessionRecordId,
480 display_name: String,
481 provider: AgentId,
482 mode: SessionMode,
483 state: ManagedSessionState,
484 workspace_id: WorkspaceId,
485 active_session: Option<SessionAddress>,
486 #[serde(default)]
487 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
488 #[serde(default)]
489 bundle: Option<ResolvedBundleReceipt>,
490 #[serde(default)]
491 context_id: Option<SpawnContextId>,
492 #[serde(default)]
493 context: Option<ResolvedContextPackReceipt>,
494 #[serde(default)]
495 exported_context: Option<ResolvedContextPackReceipt>,
496 #[serde(default)]
497 task_binding: Option<SessionTaskBindingV1>,
498 provider_identity_present: bool,
499 created_at_unix_ms: u64,
500 updated_at_unix_ms: u64,
501 }
502
503 let wire = WireRecord::deserialize(deserializer)?;
504 let record = Self {
505 record_id: wire.record_id,
506 display_name: wire.display_name,
507 provider: wire.provider,
508 mode: wire.mode,
509 state: wire.state,
510 workspace_id: wire.workspace_id,
511 active_session: wire.active_session,
512 environment_profile: wire.environment_profile,
513 bundle: wire.bundle,
514 context_id: wire.context_id,
515 context: wire.context,
516 exported_context: wire.exported_context,
517 task_binding: wire.task_binding,
518 provider_identity_present: wire.provider_identity_present,
519 created_at_unix_ms: wire.created_at_unix_ms,
520 updated_at_unix_ms: wire.updated_at_unix_ms,
521 };
522 if !record.context_binding_is_valid() {
523 return Err(serde::de::Error::custom(
524 "C2 managed session context id and materialization receipt are not correlated",
525 ));
526 }
527 if !record.exported_context_is_valid() {
528 return Err(serde::de::Error::custom(
529 "C2 managed session exported context receipt is invalid or from a different provider",
530 ));
531 }
532 if !record.task_binding_is_valid() {
533 return Err(serde::de::Error::custom(
534 "C2 managed session task binding revision or timestamp is invalid",
535 ));
536 }
537 Ok(record)
538 }
539}
540
541impl From<&ManagedSessionRecord> for C2ManagedSessionRecord {
542 fn from(record: &ManagedSessionRecord) -> Self {
543 Self {
544 record_id: record.record_id.clone(),
545 display_name: record.display_name.clone(),
546 provider: record.provider.clone(),
547 mode: record.mode,
548 state: record.state,
549 workspace_id: record.workspace_id.clone(),
550 active_session: record.active_session.clone(),
551 environment_profile: record.environment_profile.clone(),
552 bundle: record.bundle.clone(),
553 context_id: record.context_id.clone(),
554 context: record.context.clone(),
555 exported_context: record.exported_context.clone(),
556 task_binding: record.task_binding.clone().filter(|binding| {
557 binding.revision > 0
558 && binding.changed_at_unix_ms >= record.created_at_unix_ms
559 && binding.changed_at_unix_ms <= record.updated_at_unix_ms
560 }),
561 provider_identity_present: record.provider_session.is_some(),
562 created_at_unix_ms: record.created_at_unix_ms,
563 updated_at_unix_ms: record.updated_at_unix_ms,
564 }
565 }
566
567}
568
569#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
570#[serde(tag = "kind", rename_all = "kebab-case")]
571pub enum C2SessionStatus {
572 Registered,
573 Starting,
574 Running,
575 Stopping,
576 Exited { exit_code: Option<i32> },
577 Failed,
578}
579
580impl From<&SessionStatus> for C2SessionStatus {
581 fn from(status: &SessionStatus) -> Self {
582 match status {
583 SessionStatus::Registered => Self::Registered,
584 SessionStatus::Starting => Self::Starting,
585 SessionStatus::Running => Self::Running,
586 SessionStatus::Stopping => Self::Stopping,
587 SessionStatus::Exited { exit_code } => Self::Exited { exit_code: *exit_code },
588 SessionStatus::Failed { .. } => Self::Failed,
589 }
590 }
591}
592
593#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
594pub struct C2SessionSnapshot {
595 pub instance_id: AgentInstanceId,
596 pub agent_id: AgentId,
597 pub transport: TransportKind,
598 pub generation: SessionGeneration,
599 pub status: C2SessionStatus,
600 pub pending_operation: Option<OperationId>,
601 pub pending_input: Option<PreparedInputKind>,
602 pub process_id: Option<u32>,
603 pub terminal_size: Option<TerminalSize>,
604 pub terminal_frame: Option<TerminalFrame>,
605 pub provider_activity: ProviderActivity,
606 pub provider_interaction_pending: bool,
607 pub provider_identity_present: bool,
608 #[serde(default)]
616 pub screen_state: PtyScreenState,
617}
618
619impl From<&gate4agent_types::SessionSnapshot> for C2SessionSnapshot {
620 fn from(session: &gate4agent_types::SessionSnapshot) -> Self {
621 Self {
622 instance_id: session.instance_id,
623 agent_id: session.agent_id.clone(),
624 transport: session.transport,
625 generation: session.generation,
626 status: C2SessionStatus::from(&session.status),
627 pending_operation: session.pending_operation,
628 pending_input: session.pending_input,
629 process_id: session.process_id,
630 terminal_size: session.terminal_size,
631 terminal_frame: session.terminal_frame.clone(),
632 provider_activity: session.provider.activity,
633 provider_interaction_pending: !session.provider.interactions.is_empty(),
634 provider_identity_present: session.provider.session.is_some(),
635 screen_state: session.screen_state.clone(),
636 }
637 }
638}
639
640#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
641pub struct C2WorkspaceSnapshot {
642 pub workspace_id: WorkspaceId,
643 pub canonical_root: OpaqueHostPath,
644 pub sessions: Vec<C2SessionSnapshot>,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub worktree_service_mode: Option<WorktreeServiceMode>,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub managed_worktree_profiles: Option<WorktreeProfileInventory>,
649}
650
651impl From<&gate4agent_node_protocol::WorkspaceSnapshot> for C2WorkspaceSnapshot {
652 fn from(workspace: &gate4agent_node_protocol::WorkspaceSnapshot) -> Self {
653 Self {
654 workspace_id: workspace.workspace_id.clone(),
655 canonical_root: workspace.canonical_root.clone(),
656 sessions: workspace.sessions.iter().map(C2SessionSnapshot::from).collect(),
657 worktree_service_mode: workspace.worktree_service_mode,
658 managed_worktree_profiles: workspace.managed_worktree_profiles.clone(),
659 }
660 }
661}
662
663#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
664pub struct C2NodeSnapshot {
665 pub node_id: NodeId,
666 pub enabled_providers: Vec<AgentId>,
667 #[serde(default, skip_serializing_if = "ProviderRuntimeStatuses::is_empty")]
668 pub provider_runtime_statuses: ProviderRuntimeStatuses,
669 pub workspaces: Vec<C2WorkspaceSnapshot>,
670 pub session_records: Vec<C2ManagedSessionRecord>,
671 #[serde(
672 default,
673 skip_serializing_if = "Vec::is_empty",
674 deserialize_with = "deserialize_c2_agent_progress_entries"
675 )]
676 pub agent_progress: Vec<SessionAgentProgress>,
677 #[serde(
678 default,
679 skip_serializing_if = "Vec::is_empty",
680 deserialize_with = "deserialize_c2_managed_worktree_leases"
681 )]
682 pub managed_worktrees: Vec<ManagedWorktreeLeaseSnapshot>,
683 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub launch_inventory: Option<LaunchInventory>,
685}
686
687impl C2NodeSnapshot {
688 pub fn requires_child_environment_profile_capability(&self) -> bool {
689 self.session_records
690 .iter()
691 .any(|record| record.environment_profile.is_some())
692 || self.launch_inventory.as_ref().is_some_and(|inventory| {
693 inventory.spawn_profiles.as_ref().is_some_and(|profiles| {
694 profiles
695 .iter()
696 .any(|profile| profile.environment_profile.is_some())
697 })
698 })
699 }
700
701 pub fn requires_session_bundle_materialization_capability(&self) -> bool {
702 self.session_records
703 .iter()
704 .any(|record| record.bundle.is_some())
705 }
706
707 pub fn requires_history_context_pack_capability(&self) -> bool {
708 self.session_records
709 .iter()
710 .any(C2ManagedSessionRecord::requires_history_context_pack_capability)
711 }
712
713 pub fn requires_session_task_correlation_capability(&self) -> bool {
714 self.session_records
715 .iter()
716 .any(C2ManagedSessionRecord::requires_session_task_correlation_capability)
717 }
718}
719
720fn deserialize_c2_managed_worktree_leases<'de, D>(
721 deserializer: D,
722) -> Result<Vec<ManagedWorktreeLeaseSnapshot>, D::Error>
723where
724 D: Deserializer<'de>,
725{
726 struct ManagedWorktreesVisitor;
727
728 impl<'de> Visitor<'de> for ManagedWorktreesVisitor {
729 type Value = Vec<ManagedWorktreeLeaseSnapshot>;
730
731 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
732 write!(
733 formatter,
734 "at most {MAX_C2_MANAGED_WORKTREES_PER_NODE} managed worktrees",
735 )
736 }
737
738 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
739 where
740 A: SeqAccess<'de>,
741 {
742 let mut leases = Vec::with_capacity(
743 sequence
744 .size_hint()
745 .unwrap_or(0)
746 .min(MAX_C2_MANAGED_WORKTREES_PER_NODE),
747 );
748 while let Some(lease) = sequence.next_element::<ManagedWorktreeLeaseSnapshot>()? {
749 if leases.len() == MAX_C2_MANAGED_WORKTREES_PER_NODE {
750 return Err(serde::de::Error::invalid_length(leases.len() + 1, &self));
751 }
752 if leases.iter().any(|existing: &ManagedWorktreeLeaseSnapshot| {
753 existing.lease_id == lease.lease_id
754 || existing.workspace_id == lease.workspace_id
755 }) {
756 return Err(serde::de::Error::custom(
757 "C2 managed worktree snapshot contains duplicate identity",
758 ));
759 }
760 leases.push(lease);
761 }
762 Ok(leases)
763 }
764 }
765
766 deserializer.deserialize_seq(ManagedWorktreesVisitor)
767}
768
769fn deserialize_c2_agent_progress_entries<'de, D>(
770 deserializer: D,
771) -> Result<Vec<SessionAgentProgress>, D::Error>
772where
773 D: Deserializer<'de>,
774{
775 struct AgentProgressEntriesVisitor;
776
777 impl<'de> Visitor<'de> for AgentProgressEntriesVisitor {
778 type Value = Vec<SessionAgentProgress>;
779
780 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
781 write!(
782 formatter,
783 "at most {} bounded agent progress entries",
784 gate4agent_node_protocol::MAX_AGENT_PROGRESS_ENTRIES,
785 )
786 }
787
788 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
789 where
790 A: SeqAccess<'de>,
791 {
792 let mut entries = Vec::with_capacity(
793 sequence
794 .size_hint()
795 .unwrap_or(0)
796 .min(gate4agent_node_protocol::MAX_AGENT_PROGRESS_ENTRIES),
797 );
798 while let Some(entry) = sequence.next_element::<SessionAgentProgress>()? {
799 if entries.len() == gate4agent_node_protocol::MAX_AGENT_PROGRESS_ENTRIES {
800 return Err(serde::de::Error::invalid_length(entries.len() + 1, &self));
801 }
802 if entries.iter().any(|existing: &SessionAgentProgress| {
803 existing.address == entry.address
804 }) {
805 continue;
806 }
807 entries.push(entry);
808 }
809 Ok(entries)
810 }
811 }
812
813 deserializer.deserialize_seq(AgentProgressEntriesVisitor)
814}
815
816impl From<&NodeSnapshot> for C2NodeSnapshot {
817 fn from(snapshot: &NodeSnapshot) -> Self {
818 Self {
819 node_id: snapshot.node_id.clone(),
820 enabled_providers: snapshot.enabled_providers.clone(),
821 provider_runtime_statuses: snapshot.provider_runtime_statuses.clone(),
822 workspaces: snapshot.workspaces.iter().map(C2WorkspaceSnapshot::from).collect(),
823 session_records: snapshot.session_records.iter()
824 .map(C2ManagedSessionRecord::from)
825 .collect(),
826 agent_progress: snapshot.agent_progress.clone(),
827 managed_worktrees: snapshot.managed_worktrees.clone(),
828 launch_inventory: snapshot.launch_inventory.clone(),
829 }
830 }
831}
832
833#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
834pub struct C2NodeEventEnvelope {
835 pub sequence: u64,
836 pub event: C2NodeEvent,
837}
838
839impl From<&gate4agent_node_protocol::NodeEventEnvelope> for C2NodeEventEnvelope {
840 fn from(envelope: &gate4agent_node_protocol::NodeEventEnvelope) -> Self {
841 Self { sequence: envelope.sequence, event: C2NodeEvent::from(&envelope.event) }
842 }
843}
844
845impl C2NodeEventEnvelope {
846 pub fn from_node_envelope_with_control_detail(
850 envelope: &gate4agent_node_protocol::NodeEventEnvelope,
851 ) -> Self {
852 Self {
853 sequence: envelope.sequence,
854 event: C2NodeEvent::from_node_event_with_control_detail(&envelope.event),
855 }
856 }
857}
858
859#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
860pub struct C2ControlEvent {
861 pub sequence: u64,
862 pub command_id: Option<gate4agent_types::CommandId>,
863 pub instance_id: AgentInstanceId,
864 pub generation: SessionGeneration,
865 pub event: C2ControlEventKind,
866 #[serde(default, skip_serializing_if = "Option::is_none")]
871 pub detail: Option<gate4agent_types::ControlEvent>,
872}
873
874#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
875#[serde(rename_all = "kebab-case")]
876pub enum C2ProviderEventKind {
877 SessionStarted,
878 SessionIdentityObserved,
879 TurnStarted,
880 WorkingObserved,
881 Text,
882 Thinking,
883 ToolStarted,
884 ToolCompleted,
885 TurnCompleted,
886 ContextWindowUsage,
887 TurnInterrupted,
888 SessionEnded,
889 Error,
890 Ready,
891 InteractionRequested,
892 InteractionResolved,
893 SubagentStarted,
894 SubagentStopped,
895 RateLimited,
896 HostRequestObserved,
897 UnrecognizedNotification,
898}
899
900impl From<&gate4agent_types::ProviderEvent> for C2ProviderEventKind {
901 fn from(event: &gate4agent_types::ProviderEvent) -> Self {
902 use gate4agent_types::ProviderEvent;
903 match event {
904 ProviderEvent::SessionStarted { .. } => Self::SessionStarted,
905 ProviderEvent::SessionIdentityObserved { .. } => Self::SessionIdentityObserved,
906 ProviderEvent::TurnStarted { .. } => Self::TurnStarted,
907 ProviderEvent::WorkingObserved => Self::WorkingObserved,
908 ProviderEvent::Text { .. } => Self::Text,
909 ProviderEvent::Thinking { .. } => Self::Thinking,
910 ProviderEvent::ToolStarted { .. } => Self::ToolStarted,
911 ProviderEvent::ToolCompleted { .. } => Self::ToolCompleted,
912 ProviderEvent::TurnCompleted { .. } => Self::TurnCompleted,
913 ProviderEvent::ContextWindowUsage { .. } => Self::ContextWindowUsage,
914 ProviderEvent::TurnInterrupted => Self::TurnInterrupted,
915 ProviderEvent::SessionEnded { .. } => Self::SessionEnded,
916 ProviderEvent::Error { .. } => Self::Error,
917 ProviderEvent::Ready => Self::Ready,
918 ProviderEvent::InteractionRequested { .. } => Self::InteractionRequested,
919 ProviderEvent::InteractionResolved { .. } => Self::InteractionResolved,
920 ProviderEvent::SubagentStarted { .. } => Self::SubagentStarted,
921 ProviderEvent::SubagentStopped { .. } => Self::SubagentStopped,
922 ProviderEvent::RateLimited { .. } => Self::RateLimited,
923 ProviderEvent::HostRequestObserved { .. } => Self::HostRequestObserved,
924 ProviderEvent::UnrecognizedNotification { .. } => Self::UnrecognizedNotification,
925 ProviderEvent::UserMessage { .. }
935 | ProviderEvent::Plan { .. }
936 | ProviderEvent::AvailableCommandsUpdated { .. }
937 | ProviderEvent::ModeChanged { .. }
938 | ProviderEvent::SessionInfoUpdated { .. }
939 | ProviderEvent::UsageUpdated { .. }
940 | ProviderEvent::ConfigOptionsUpdated { .. } => Self::UnrecognizedNotification,
941 }
942 }
943}
944
945#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
946#[serde(tag = "kind", rename_all = "kebab-case")]
947pub enum C2ControlEventKind {
948 CommandRejected,
949 Registered,
950 StartRequested,
951 Running,
952 StopRequested,
953 InputRequested,
954 InputCompleted,
955 InputFailed,
956 ResizeRequested,
957 Resized,
958 ResizeFailed,
959 ForegroundRefreshRequested,
960 ForegroundObserved,
961 ForegroundFailed,
962 CapabilityProbeRequested,
963 CapabilitiesProbed,
964 CapabilityProbeFailed,
965 HistoryRequested,
966 HistoryDiscovered,
967 HistoryLoaded,
968 HistoryFailed,
969 ResumeRequested,
970 ResumeAuthorized,
971 Resumed,
972 ResumeDenied,
973 ResumeFailed,
974 TerminalStale,
975 ProviderEvent { event: C2ProviderEventKind },
976 ProviderGap,
977 InteractionRequested,
978 InteractionResolutionRequested,
979 InteractionResolutionFailed,
980 InteractionResolved,
981 SessionModeSetRequested,
986 SessionModeSet,
987 SessionModeSetFailed,
988 SessionConfigOptionSetRequested,
989 SessionConfigOptionSet,
990 SessionConfigOptionSetFailed,
991 SessionModelSetRequested,
992 SessionModelSet,
993 SessionModelSetFailed,
994 Exited { exit_code: Option<i32>, forced: bool },
995 Failed,
996 Removed,
997 ObservationIgnored,
998}
999
1000impl From<&gate4agent_types::ControlEvent> for C2ControlEvent {
1001 fn from(event: &gate4agent_types::ControlEvent) -> Self {
1002 use gate4agent_types::ControlEventKind;
1003 let projected = match &event.event {
1004 ControlEventKind::CommandRejected { .. } => C2ControlEventKind::CommandRejected,
1005 ControlEventKind::Registered => C2ControlEventKind::Registered,
1006 ControlEventKind::StartRequested { .. } => C2ControlEventKind::StartRequested,
1007 ControlEventKind::Running { .. } => C2ControlEventKind::Running,
1008 ControlEventKind::StopRequested { .. } => C2ControlEventKind::StopRequested,
1009 ControlEventKind::InputRequested { .. } => C2ControlEventKind::InputRequested,
1010 ControlEventKind::InputCompleted { .. } => C2ControlEventKind::InputCompleted,
1011 ControlEventKind::InputFailed { .. } => C2ControlEventKind::InputFailed,
1012 ControlEventKind::ResizeRequested { .. } => C2ControlEventKind::ResizeRequested,
1013 ControlEventKind::Resized { .. } => C2ControlEventKind::Resized,
1014 ControlEventKind::ResizeFailed { .. } => C2ControlEventKind::ResizeFailed,
1015 ControlEventKind::ForegroundRefreshRequested { .. } => C2ControlEventKind::ForegroundRefreshRequested,
1016 ControlEventKind::ForegroundObserved { .. } => C2ControlEventKind::ForegroundObserved,
1017 ControlEventKind::ForegroundFailed { .. } => C2ControlEventKind::ForegroundFailed,
1018 ControlEventKind::CapabilityProbeRequested { .. } => C2ControlEventKind::CapabilityProbeRequested,
1019 ControlEventKind::CapabilitiesProbed { .. } => C2ControlEventKind::CapabilitiesProbed,
1020 ControlEventKind::CapabilityProbeFailed { .. } => C2ControlEventKind::CapabilityProbeFailed,
1021 ControlEventKind::HistoryRequested { .. } => C2ControlEventKind::HistoryRequested,
1022 ControlEventKind::HistoryDiscovered { .. } => C2ControlEventKind::HistoryDiscovered,
1023 ControlEventKind::HistoryLoaded { .. } => C2ControlEventKind::HistoryLoaded,
1024 ControlEventKind::HistoryFailed { .. } => C2ControlEventKind::HistoryFailed,
1025 ControlEventKind::ResumeRequested { .. } => C2ControlEventKind::ResumeRequested,
1026 ControlEventKind::ResumeAuthorized { .. } => C2ControlEventKind::ResumeAuthorized,
1027 ControlEventKind::Resumed { .. } => C2ControlEventKind::Resumed,
1028 ControlEventKind::ResumeDenied { .. } => C2ControlEventKind::ResumeDenied,
1029 ControlEventKind::ResumeFailed { .. } => C2ControlEventKind::ResumeFailed,
1030 ControlEventKind::TerminalStale { .. } => C2ControlEventKind::TerminalStale,
1031 ControlEventKind::ProviderEvent { event, .. } => C2ControlEventKind::ProviderEvent {
1032 event: C2ProviderEventKind::from(event),
1033 },
1034 ControlEventKind::ProviderGap { .. } => C2ControlEventKind::ProviderGap,
1035 ControlEventKind::InteractionRequested { .. } => C2ControlEventKind::InteractionRequested,
1036 ControlEventKind::InteractionResolutionRequested { .. } => C2ControlEventKind::InteractionResolutionRequested,
1037 ControlEventKind::InteractionResolutionFailed { .. } => C2ControlEventKind::InteractionResolutionFailed,
1038 ControlEventKind::InteractionResolved { .. } => C2ControlEventKind::InteractionResolved,
1039 ControlEventKind::SessionModeSetRequested { .. } => C2ControlEventKind::SessionModeSetRequested,
1040 ControlEventKind::SessionModeSet { .. } => C2ControlEventKind::SessionModeSet,
1041 ControlEventKind::SessionModeSetFailed { .. } => C2ControlEventKind::SessionModeSetFailed,
1042 ControlEventKind::SessionConfigOptionSetRequested { .. } => C2ControlEventKind::SessionConfigOptionSetRequested,
1043 ControlEventKind::SessionConfigOptionSet { .. } => C2ControlEventKind::SessionConfigOptionSet,
1044 ControlEventKind::SessionConfigOptionSetFailed { .. } => C2ControlEventKind::SessionConfigOptionSetFailed,
1045 ControlEventKind::SessionModelSetRequested { .. } => C2ControlEventKind::SessionModelSetRequested,
1046 ControlEventKind::SessionModelSet { .. } => C2ControlEventKind::SessionModelSet,
1047 ControlEventKind::SessionModelSetFailed { .. } => C2ControlEventKind::SessionModelSetFailed,
1048 ControlEventKind::Exited { exit_code, forced } => C2ControlEventKind::Exited {
1049 exit_code: *exit_code,
1050 forced: *forced,
1051 },
1052 ControlEventKind::Failed { .. } => C2ControlEventKind::Failed,
1053 ControlEventKind::Removed => C2ControlEventKind::Removed,
1054 ControlEventKind::ObservationIgnored { .. } => C2ControlEventKind::ObservationIgnored,
1055 };
1056 Self {
1057 sequence: event.sequence,
1058 command_id: event.command_id,
1059 instance_id: event.instance_id,
1060 generation: event.generation,
1061 event: projected,
1062 detail: None,
1063 }
1064 }
1065}
1066
1067#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1068#[serde(tag = "kind", rename_all = "kebab-case")]
1069pub enum C2NodeEvent {
1070 HarnessMcpReadCall {
1071 reservation_id: HarnessMcpReservationId,
1072 activation_digest: HarnessMcpActivationDigest,
1073 record_id: SessionRecordId,
1074 session: SessionAddress,
1075 call_id: HarnessMcpCallId,
1076 request: HarnessMcpOpaquePayloadV1,
1077 deadline_unix_ms: u64,
1078 },
1079 Control {
1080 address: SessionAddress,
1081 event: C2ControlEvent,
1082 },
1083 SessionRecordHistorySummarized {
1084 record_id: SessionRecordId,
1085 summary: SessionHistorySummaryV1,
1086 },
1087 TerminalFrame {
1088 address: SessionAddress,
1089 frame: TerminalFrame,
1090 },
1091 AgentStream {
1092 address: SessionAddress,
1093 chunk: AgentStreamChunkV1,
1094 },
1095 ControllerChanged {
1096 controller: Option<gate4agent_node_protocol::ControllerState>,
1097 },
1098 WorkspaceAdded {
1099 workspace: C2WorkspaceSnapshot,
1100 },
1101 WorkspaceRemoved { workspace_id: WorkspaceId },
1102 SessionRecordUpserted { record: C2ManagedSessionRecord },
1103 SessionRecordRemoved { record_id: SessionRecordId },
1104 ManagedWorktreeUpserted { lease: ManagedWorktreeLeaseSnapshot },
1105 ManagedWorktreeRemoved { lease_id: ManagedWorktreeLeaseId },
1106 ResyncRequired { oldest_available_sequence: u64 },
1107}
1108
1109impl From<&NodeEvent> for C2NodeEvent {
1110 fn from(event: &NodeEvent) -> Self {
1111 match event {
1112 NodeEvent::HarnessMcpReadCall {
1113 reservation_id,
1114 activation_digest,
1115 record_id,
1116 session,
1117 call_id,
1118 request,
1119 deadline_unix_ms,
1120 } => Self::HarnessMcpReadCall {
1121 reservation_id: reservation_id.clone(),
1122 activation_digest: activation_digest.clone(),
1123 record_id: record_id.clone(),
1124 session: session.clone(),
1125 call_id: call_id.clone(),
1126 request: request.clone(),
1127 deadline_unix_ms: *deadline_unix_ms,
1128 },
1129 NodeEvent::Control { address, event } => Self::Control {
1130 address: address.clone(),
1131 event: C2ControlEvent::from(event),
1132 },
1133 NodeEvent::SessionRecordHistorySummarized { record_id, summary } => {
1134 Self::SessionRecordHistorySummarized {
1135 record_id: record_id.clone(),
1136 summary: *summary,
1137 }
1138 }
1139 NodeEvent::TerminalFrame { address, frame } => Self::TerminalFrame {
1140 address: address.clone(),
1141 frame: frame.clone(),
1142 },
1143 NodeEvent::AgentStream { address, chunk } => Self::AgentStream {
1144 address: address.clone(),
1145 chunk: chunk.clone(),
1146 },
1147 NodeEvent::ControllerChanged { controller } => Self::ControllerChanged {
1148 controller: controller.clone(),
1149 },
1150 NodeEvent::WorkspaceAdded { workspace } => Self::WorkspaceAdded {
1151 workspace: C2WorkspaceSnapshot::from(workspace),
1152 },
1153 NodeEvent::WorkspaceRemoved { workspace_id } => Self::WorkspaceRemoved {
1154 workspace_id: workspace_id.clone(),
1155 },
1156 NodeEvent::SessionRecordUpserted { record } => Self::SessionRecordUpserted {
1157 record: C2ManagedSessionRecord::from(record),
1158 },
1159 NodeEvent::SessionRecordRemoved { record_id } => Self::SessionRecordRemoved {
1160 record_id: record_id.clone(),
1161 },
1162 NodeEvent::ManagedWorktreeUpserted { lease } => Self::ManagedWorktreeUpserted {
1163 lease: lease.clone(),
1164 },
1165 NodeEvent::ManagedWorktreeRemoved { lease_id } => Self::ManagedWorktreeRemoved {
1166 lease_id: lease_id.clone(),
1167 },
1168 NodeEvent::ResyncRequired { oldest_available_sequence } => Self::ResyncRequired {
1169 oldest_available_sequence: *oldest_available_sequence,
1170 },
1171 }
1172 }
1173}
1174
1175impl C2NodeEvent {
1176 pub fn from_node_event_with_control_detail(event: &NodeEvent) -> Self {
1179 match event {
1180 NodeEvent::Control { address, event } => Self::Control {
1181 address: address.clone(),
1182 event: C2ControlEvent {
1183 detail: control_telemetry_detail(event),
1184 ..C2ControlEvent::from(event)
1185 },
1186 },
1187 other => Self::from(other),
1188 }
1189 }
1190
1191 pub fn requires_control_detail_capability(&self) -> bool {
1192 matches!(self, Self::Control { event, .. } if event.detail.is_some())
1193 }
1194
1195 pub fn strip_control_detail(&mut self) {
1197 if let Self::Control { event, .. } = self {
1198 event.detail = None;
1199 }
1200 }
1201
1202 pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
1203 matches!(self, Self::HarnessMcpReadCall { .. })
1204 }
1205
1206 pub fn harness_mcp_contract_is_valid_at(&self, now_unix_ms: u64) -> bool {
1207 match self {
1208 Self::HarnessMcpReadCall {
1209 reservation_id,
1210 activation_digest,
1211 record_id,
1212 session,
1213 call_id,
1214 request,
1215 deadline_unix_ms,
1216 } => NodeEvent::HarnessMcpReadCall {
1217 reservation_id: reservation_id.clone(),
1218 activation_digest: activation_digest.clone(),
1219 record_id: record_id.clone(),
1220 session: session.clone(),
1221 call_id: call_id.clone(),
1222 request: request.clone(),
1223 deadline_unix_ms: *deadline_unix_ms,
1224 }.harness_mcp_contract_is_valid_at(now_unix_ms),
1225 _ => true,
1226 }
1227 }
1228
1229 pub fn requires_child_environment_profile_capability(&self) -> bool {
1230 matches!(self, Self::SessionRecordUpserted { record }
1231 if record.environment_profile.is_some())
1232 }
1233
1234 pub fn requires_session_bundle_materialization_capability(&self) -> bool {
1235 matches!(self, Self::SessionRecordUpserted { record }
1236 if record.bundle.is_some())
1237 }
1238
1239 pub fn requires_history_context_pack_capability(&self) -> bool {
1240 matches!(self, Self::SessionRecordUpserted { record }
1241 if record.requires_history_context_pack_capability())
1242 }
1243
1244 pub fn requires_session_task_correlation_capability(&self) -> bool {
1245 matches!(self, Self::SessionRecordUpserted { record }
1246 if record.requires_session_task_correlation_capability())
1247 }
1248}
1249
1250#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1251pub struct C2GitWorktreeSnapshot {
1252 pub path: OpaqueHostPath,
1253 pub head: String,
1254 pub branch: Option<String>,
1255 pub is_bare: bool,
1256 pub is_main: bool,
1257 pub locked: bool,
1258 pub prunable: bool,
1259 pub workspace_id: Option<WorkspaceId>,
1260}
1261
1262impl From<&gate4agent_node_protocol::GitWorktreeSnapshot> for C2GitWorktreeSnapshot {
1263 fn from(worktree: &gate4agent_node_protocol::GitWorktreeSnapshot) -> Self {
1264 Self {
1265 path: worktree.path.clone(),
1266 head: worktree.head.clone(),
1267 branch: worktree.branch.clone(),
1268 is_bare: worktree.is_bare,
1269 is_main: worktree.is_main,
1270 locked: worktree.locked,
1271 prunable: worktree.prunable,
1272 workspace_id: worktree.workspace_id.clone(),
1273 }
1274 }
1275}
1276
1277#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1278pub struct C2GitSnapshot {
1279 pub is_repository: bool,
1280 pub branch: Option<String>,
1281 pub status: Vec<gate4agent_node_protocol::GitStatusEntry>,
1282 pub recent_commits: Vec<gate4agent_node_protocol::GitCommitSummary>,
1283 pub worktrees: Vec<C2GitWorktreeSnapshot>,
1284 #[serde(default, skip_serializing_if = "Option::is_none")]
1285 pub managed_worktree: Option<ManagedWorktreeGitScope>,
1286 pub truncated: bool,
1287 pub diagnostic_present: bool,
1288}
1289
1290impl From<&gate4agent_node_protocol::GitSnapshot> for C2GitSnapshot {
1291 fn from(git: &gate4agent_node_protocol::GitSnapshot) -> Self {
1292 Self {
1293 is_repository: git.is_repository,
1294 branch: git.branch.clone(),
1295 status: git.status.clone(),
1296 recent_commits: git.recent_commits.clone(),
1297 worktrees: git.worktrees.iter().map(C2GitWorktreeSnapshot::from).collect(),
1298 managed_worktree: git.managed_worktree.clone(),
1299 truncated: git.truncated,
1300 diagnostic_present: git.diagnostic.is_some(),
1301 }
1302 }
1303}
1304
1305impl C2GitSnapshot {
1306 pub fn managed_worktree_is_valid_for(&self, workspace_id: &WorkspaceId) -> bool {
1307 self.managed_worktree.as_ref().map_or(true, |scope| {
1308 self.is_repository
1309 && self.branch.as_deref() == Some(scope.branch.as_str())
1310 && &scope.source_workspace_id != workspace_id
1311 && (u32::from(scope.active_session_count)
1312 + u32::from(scope.managed_record_count))
1313 > 0
1314 })
1315 }
1316}
1317
1318#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1319pub struct C2WorkspaceInspection {
1320 pub workspace_id: WorkspaceId,
1321 pub entries: Vec<gate4agent_node_protocol::WorkspaceEntry>,
1322 pub tree_truncated: bool,
1323 pub git: C2GitSnapshot,
1324 #[serde(default, skip_serializing_if = "Option::is_none")]
1329 pub truncation: Option<gate4agent_node_protocol::WorkspaceInspectionTruncationV1>,
1330}
1331
1332impl<'de> Deserialize<'de> for C2WorkspaceInspection {
1333 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1334 where
1335 D: serde::Deserializer<'de>,
1336 {
1337 #[derive(Deserialize)]
1338 struct WireInspection {
1339 workspace_id: WorkspaceId,
1340 entries: Vec<gate4agent_node_protocol::WorkspaceEntry>,
1341 tree_truncated: bool,
1342 git: C2GitSnapshot,
1343 #[serde(default)]
1344 truncation: Option<gate4agent_node_protocol::WorkspaceInspectionTruncationV1>,
1345 }
1346
1347 let wire = WireInspection::deserialize(deserializer)?;
1348 if !wire.git.managed_worktree_is_valid_for(&wire.workspace_id) {
1349 return Err(serde::de::Error::custom(
1350 "managed worktree git scope is inconsistent with workspace inspection",
1351 ));
1352 }
1353 Ok(Self {
1354 workspace_id: wire.workspace_id,
1355 entries: wire.entries,
1356 tree_truncated: wire.tree_truncated,
1357 git: wire.git,
1358 truncation: wire.truncation,
1359 })
1360 }
1361}
1362
1363impl From<&gate4agent_node_protocol::WorkspaceInspection> for C2WorkspaceInspection {
1364 fn from(inspection: &gate4agent_node_protocol::WorkspaceInspection) -> Self {
1365 let mut projected = Self {
1366 workspace_id: inspection.workspace_id.clone(),
1367 entries: inspection.entries.clone(),
1368 tree_truncated: inspection.tree_truncated,
1369 git: C2GitSnapshot::from(&inspection.git),
1370 truncation: inspection.truncation,
1371 };
1372 if !projected
1373 .git
1374 .managed_worktree_is_valid_for(&projected.workspace_id)
1375 {
1376 projected.git.managed_worktree = None;
1377 }
1378 projected
1379 }
1380}
1381
1382#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1383#[serde(tag = "kind", rename_all = "kebab-case")]
1384pub enum C2NodeResponse {
1385 Snapshot {
1386 event_sequence: u64,
1387 controller: Option<gate4agent_node_protocol::ControllerState>,
1388 snapshot: C2NodeSnapshot,
1389 },
1390 Resync {
1391 event_sequence: u64,
1392 oldest_available_sequence: u64,
1393 snapshot: C2NodeSnapshot,
1394 events: Vec<C2NodeEventEnvelope>,
1395 },
1396 Armed {
1397 reservation_id: HarnessMcpReservationId,
1398 activation_digest: HarnessMcpActivationDigest,
1399 expires_at_unix_ms: u64,
1400 },
1401 Spawned {
1402 reservation_id: HarnessMcpReservationId,
1403 activation_digest: HarnessMcpActivationDigest,
1404 receipt: ResolvedSpawnReceipt,
1405 },
1406 Activated {
1407 reservation_id: HarnessMcpReservationId,
1408 activation_digest: HarnessMcpActivationDigest,
1409 record_id: SessionRecordId,
1410 session: SessionAddress,
1411 },
1412 Aborted {
1413 reservation_id: HarnessMcpReservationId,
1414 activation_digest: HarnessMcpActivationDigest,
1415 },
1416 ReplyChunkAccepted {
1417 reservation_id: HarnessMcpReservationId,
1418 activation_digest: HarnessMcpActivationDigest,
1419 record_id: SessionRecordId,
1420 session: SessionAddress,
1421 call_id: HarnessMcpCallId,
1422 next_offset: u32,
1423 completed: bool,
1424 },
1425 CallRejected {
1426 reservation_id: HarnessMcpReservationId,
1427 activation_digest: HarnessMcpActivationDigest,
1428 record_id: SessionRecordId,
1429 session: SessionAddress,
1430 call_id: HarnessMcpCallId,
1431 },
1432 DeliveryStageBegun {
1433 stage_id: DeliveryStageId,
1434 manifest_digest: DeliveryManifestDigestV2,
1435 missing_blobs: Vec<DeliveryBlobDigestV1>,
1436 },
1437 DeliveryBlobChunkAccepted {
1438 stage_id: DeliveryStageId,
1439 blob_digest: DeliveryBlobDigestV1,
1440 next_offset: u64,
1441 },
1442 DeliveryCommitted {
1443 receipt: DeliveryCommitReceiptV1,
1444 },
1445 DeliveryStageAborted {
1446 stage_id: DeliveryStageId,
1447 },
1448 WorkspaceInspected {
1449 inspection: C2WorkspaceInspection,
1450 },
1451 HostDirectoriesBrowsed {
1452 listing: HostDirectoryListing,
1453 },
1454 WorkspaceFileRead {
1455 file: WorkspaceFileRead,
1456 },
1457 WorkspaceFileWritten {
1458 file: WorkspaceFileRead,
1459 },
1460 WorkspaceFileCreated {
1461 file: WorkspaceFileRead,
1462 },
1463 WorkspaceDirectoryCreated {
1464 workspace_id: WorkspaceId,
1465 entry: gate4agent_node_protocol::WorkspaceEntry,
1466 },
1467 GitHistoryRead {
1468 workspace_id: WorkspaceId,
1469 page: GitHistoryPage,
1470 },
1471 GitDiffRead {
1472 workspace_id: WorkspaceId,
1473 diff: GitDiff,
1474 },
1475 Controller {
1476 controller: Option<gate4agent_node_protocol::ControllerState>,
1477 },
1478 SpawnAccepted { session: SessionAddress },
1479 SpawnSpecAccepted { receipt: ResolvedSpawnReceipt },
1480 ManagedWorktreeSpawnAccepted { receipt: ManagedWorktreeSpawnReceipt },
1481 ManagedWorktreeCleanup { lease: ManagedWorktreeLeaseSnapshot },
1482 SessionRecordUpdated { record: C2ManagedSessionRecord },
1483 ProviderSessionIndexed { record: C2ManagedSessionRecord },
1484 NativeSessionIndexed {
1485 selection: NativeSessionSelection,
1486 record: C2ManagedSessionRecord,
1487 },
1488 SessionRecordResumed {
1489 record: C2ManagedSessionRecord,
1490 session: SessionAddress,
1491 },
1492 SessionRecordForgotten { record_id: SessionRecordId },
1493 NativeSessionsCataloged {
1494 route: NativeSessionCatalogRoute,
1495 #[serde(deserialize_with = "deserialize_c2_native_session_catalog_entries")]
1496 entries: Vec<NativeSessionCatalogEntry>,
1497 #[serde(
1498 default,
1499 skip_serializing_if = "Option::is_none",
1500 deserialize_with = "deserialize_optional_c2_native_session_catalog_summary"
1501 )]
1502 summary: Option<NativeSessionCatalogSummary>,
1503 },
1504 NativeSessionsPaged {
1505 route: NativeSessionCatalogRoute,
1506 #[serde(deserialize_with = "deserialize_c2_native_session_catalog_page")]
1507 page: NativeSessionCatalogPage,
1508 },
1509 NativeSessionPreviewed {
1510 selection: NativeSessionSelection,
1511 #[serde(deserialize_with = "deserialize_c2_native_session_preview")]
1512 preview: NativeSessionPreview,
1513 },
1514 SessionRecordPreviewed {
1515 record_id: SessionRecordId,
1516 #[serde(deserialize_with = "deserialize_c2_session_record_preview")]
1517 preview: SessionRecordPreview,
1518 },
1519 HistoryDiscovered {
1520 session: SessionAddress,
1521 #[serde(deserialize_with = "deserialize_c2_history_candidates")]
1522 candidates: Vec<HistoryCandidateSummary>,
1523 },
1524 HistoryLoaded {
1525 session: SessionAddress,
1526 #[serde(deserialize_with = "deserialize_c2_history_session_id")]
1527 session_id: String,
1528 message_count: u64,
1529 #[serde(default, skip_serializing_if = "Option::is_none")]
1530 completed_turn_count: Option<u64>,
1531 },
1532 ContextPackExported { context: ResolvedContextPackReceipt },
1533 ContextPackForSessionRecordExported {
1534 record_id: SessionRecordId,
1535 session: SessionAddress,
1536 context: ResolvedContextPackReceipt,
1537 },
1538 ContextPackForgotten { context_id: SpawnContextId },
1539 DurableContextPackResolved { context: ResolvedContextPackReceipt },
1540 ContextPackBytesRead { pack: ContextPackBytesRead },
1541 WorkspaceRegistered {
1542 workspace: C2WorkspaceSnapshot,
1543 },
1544 StandaloneWorkspaceCreated {
1545 workspace: C2WorkspaceSnapshot,
1546 },
1547 WorkspaceUnregistered { workspace_id: WorkspaceId },
1548 WorktreeCreated {
1549 worktree: C2GitWorktreeSnapshot,
1550 workspace: C2WorkspaceSnapshot,
1551 },
1552 WorktreeRemoved {
1553 target_root: OpaqueHostPath,
1554 workspace_id: Option<WorkspaceId>,
1555 },
1556 Accepted,
1557 ShuttingDown,
1558}
1559
1560impl From<&NodeResponse> for C2NodeResponse {
1561 fn from(response: &NodeResponse) -> Self {
1562 match response {
1563 NodeResponse::Snapshot { event_sequence, controller, snapshot } => Self::Snapshot {
1564 event_sequence: *event_sequence,
1565 controller: controller.clone(),
1566 snapshot: C2NodeSnapshot::from(snapshot),
1567 },
1568 NodeResponse::Resync {
1569 event_sequence,
1570 oldest_available_sequence,
1571 snapshot,
1572 events,
1573 } => Self::Resync {
1574 event_sequence: *event_sequence,
1575 oldest_available_sequence: *oldest_available_sequence,
1576 snapshot: C2NodeSnapshot::from(snapshot),
1577 events: events.iter().map(C2NodeEventEnvelope::from).collect(),
1578 },
1579 NodeResponse::Armed {
1580 reservation_id,
1581 activation_digest,
1582 expires_at_unix_ms,
1583 } => Self::Armed {
1584 reservation_id: reservation_id.clone(),
1585 activation_digest: activation_digest.clone(),
1586 expires_at_unix_ms: *expires_at_unix_ms,
1587 },
1588 NodeResponse::Spawned { reservation_id, activation_digest, receipt } => {
1589 Self::Spawned {
1590 reservation_id: reservation_id.clone(),
1591 activation_digest: activation_digest.clone(),
1592 receipt: receipt.clone(),
1593 }
1594 }
1595 NodeResponse::Activated {
1596 reservation_id,
1597 activation_digest,
1598 record_id,
1599 session,
1600 } => Self::Activated {
1601 reservation_id: reservation_id.clone(),
1602 activation_digest: activation_digest.clone(),
1603 record_id: record_id.clone(),
1604 session: session.clone(),
1605 },
1606 NodeResponse::Aborted { reservation_id, activation_digest } => Self::Aborted {
1607 reservation_id: reservation_id.clone(),
1608 activation_digest: activation_digest.clone(),
1609 },
1610 NodeResponse::ReplyChunkAccepted {
1611 reservation_id,
1612 activation_digest,
1613 record_id,
1614 session,
1615 call_id,
1616 next_offset,
1617 completed,
1618 } => Self::ReplyChunkAccepted {
1619 reservation_id: reservation_id.clone(),
1620 activation_digest: activation_digest.clone(),
1621 record_id: record_id.clone(),
1622 session: session.clone(),
1623 call_id: call_id.clone(),
1624 next_offset: *next_offset,
1625 completed: *completed,
1626 },
1627 NodeResponse::CallRejected {
1628 reservation_id,
1629 activation_digest,
1630 record_id,
1631 session,
1632 call_id,
1633 } => Self::CallRejected {
1634 reservation_id: reservation_id.clone(),
1635 activation_digest: activation_digest.clone(),
1636 record_id: record_id.clone(),
1637 session: session.clone(),
1638 call_id: call_id.clone(),
1639 },
1640 NodeResponse::DeliveryStageBegun {
1641 stage_id,
1642 manifest_digest,
1643 missing_blobs,
1644 } => Self::DeliveryStageBegun {
1645 stage_id: stage_id.clone(),
1646 manifest_digest: manifest_digest.clone(),
1647 missing_blobs: missing_blobs.clone(),
1648 },
1649 NodeResponse::DeliveryBlobChunkAccepted {
1650 stage_id,
1651 blob_digest,
1652 next_offset,
1653 } => Self::DeliveryBlobChunkAccepted {
1654 stage_id: stage_id.clone(),
1655 blob_digest: blob_digest.clone(),
1656 next_offset: *next_offset,
1657 },
1658 NodeResponse::DeliveryCommitted { receipt } => Self::DeliveryCommitted {
1659 receipt: receipt.clone(),
1660 },
1661 NodeResponse::DeliveryStageAborted { stage_id } => Self::DeliveryStageAborted {
1662 stage_id: stage_id.clone(),
1663 },
1664 NodeResponse::WorkspaceInspected { inspection } => Self::WorkspaceInspected {
1665 inspection: C2WorkspaceInspection::from(inspection),
1666 },
1667 NodeResponse::HostDirectoriesBrowsed { listing } => Self::HostDirectoriesBrowsed {
1668 listing: listing.clone(),
1669 },
1670 NodeResponse::WorkspaceFileRead { file } => Self::WorkspaceFileRead {
1671 file: file.clone(),
1672 },
1673 NodeResponse::WorkspaceFileWritten { file } => Self::WorkspaceFileWritten {
1674 file: file.clone(),
1675 },
1676 NodeResponse::WorkspaceFileCreated { file } => Self::WorkspaceFileCreated {
1677 file: file.clone(),
1678 },
1679 NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry } => {
1680 Self::WorkspaceDirectoryCreated {
1681 workspace_id: workspace_id.clone(),
1682 entry: entry.clone(),
1683 }
1684 }
1685 NodeResponse::GitHistoryRead { workspace_id, page } => Self::GitHistoryRead {
1686 workspace_id: workspace_id.clone(),
1687 page: page.clone(),
1688 },
1689 NodeResponse::GitDiffRead { workspace_id, diff } => Self::GitDiffRead {
1690 workspace_id: workspace_id.clone(),
1691 diff: diff.clone(),
1692 },
1693 NodeResponse::Controller { controller } => Self::Controller {
1694 controller: controller.clone(),
1695 },
1696 NodeResponse::SpawnAccepted { session } => Self::SpawnAccepted { session: session.clone() },
1697 NodeResponse::SpawnSpecAccepted { receipt } => Self::SpawnSpecAccepted {
1698 receipt: receipt.clone(),
1699 },
1700 NodeResponse::ManagedWorktreeSpawnAccepted { receipt } => {
1701 Self::ManagedWorktreeSpawnAccepted {
1702 receipt: receipt.clone(),
1703 }
1704 }
1705 NodeResponse::ManagedWorktreeCleanup { lease } => Self::ManagedWorktreeCleanup {
1706 lease: lease.clone(),
1707 },
1708 NodeResponse::SessionRecordUpdated { record } => Self::SessionRecordUpdated {
1709 record: C2ManagedSessionRecord::from(record),
1710 },
1711 NodeResponse::ProviderSessionIndexed { record } => Self::ProviderSessionIndexed {
1712 record: C2ManagedSessionRecord::from(record),
1713 },
1714 NodeResponse::NativeSessionIndexed { selection, record } => {
1715 Self::NativeSessionIndexed {
1716 selection: selection.clone(),
1717 record: C2ManagedSessionRecord::from(record),
1718 }
1719 }
1720 NodeResponse::SessionRecordResumed { record, session } => Self::SessionRecordResumed {
1721 record: C2ManagedSessionRecord::from(record),
1722 session: session.clone(),
1723 },
1724 NodeResponse::SessionRecordForgotten { record_id } => Self::SessionRecordForgotten {
1725 record_id: record_id.clone(),
1726 },
1727 NodeResponse::NativeSessionsCataloged {
1728 route,
1729 entries,
1730 summary,
1731 } => Self::NativeSessionsCataloged {
1732 route: route.clone(),
1733 entries: entries.clone(),
1734 summary: summary.clone(),
1735 },
1736 NodeResponse::NativeSessionsPaged {
1737 route,
1738 page,
1739 } => Self::NativeSessionsPaged {
1740 route: route.clone(),
1741 page: page.clone(),
1742 },
1743 NodeResponse::NativeSessionPreviewed {
1744 selection,
1745 preview,
1746 } => Self::NativeSessionPreviewed {
1747 selection: selection.clone(),
1748 preview: preview.clone(),
1749 },
1750 NodeResponse::SessionRecordPreviewed { record_id, preview } => {
1751 Self::SessionRecordPreviewed {
1752 record_id: record_id.clone(),
1753 preview: preview.clone(),
1754 }
1755 }
1756 NodeResponse::HistoryDiscovered { session, candidates } => Self::HistoryDiscovered {
1757 session: session.clone(),
1758 candidates: candidates.clone(),
1759 },
1760 NodeResponse::HistoryLoaded {
1761 session,
1762 session_id,
1763 message_count,
1764 completed_turn_count,
1765 } => Self::HistoryLoaded {
1766 session: session.clone(),
1767 session_id: session_id.clone(),
1768 message_count: *message_count,
1769 completed_turn_count: *completed_turn_count,
1770 },
1771 NodeResponse::ContextPackExported { context } => Self::ContextPackExported {
1772 context: context.clone(),
1773 },
1774 NodeResponse::ContextPackForSessionRecordExported {
1775 record_id,
1776 session,
1777 context,
1778 } => Self::ContextPackForSessionRecordExported {
1779 record_id: record_id.clone(),
1780 session: session.clone(),
1781 context: context.clone(),
1782 },
1783 NodeResponse::ContextPackForgotten { context_id } => Self::ContextPackForgotten {
1784 context_id: context_id.clone(),
1785 },
1786 NodeResponse::DurableContextPackResolved { context } => {
1787 Self::DurableContextPackResolved {
1788 context: context.clone(),
1789 }
1790 }
1791 NodeResponse::ContextPackBytesRead { pack } => Self::ContextPackBytesRead { pack: pack.clone() },
1792 NodeResponse::WorkspaceRegistered { workspace } => Self::WorkspaceRegistered {
1793 workspace: C2WorkspaceSnapshot::from(workspace),
1794 },
1795 NodeResponse::StandaloneWorkspaceCreated { workspace } => {
1796 Self::StandaloneWorkspaceCreated {
1797 workspace: C2WorkspaceSnapshot::from(workspace),
1798 }
1799 }
1800 NodeResponse::WorkspaceUnregistered { workspace_id } => Self::WorkspaceUnregistered {
1801 workspace_id: workspace_id.clone(),
1802 },
1803 NodeResponse::WorktreeCreated { worktree, workspace } => Self::WorktreeCreated {
1804 worktree: C2GitWorktreeSnapshot::from(worktree),
1805 workspace: C2WorkspaceSnapshot::from(workspace),
1806 },
1807 NodeResponse::WorktreeRemoved { target_root, workspace_id } => Self::WorktreeRemoved {
1808 target_root: target_root.clone(),
1809 workspace_id: workspace_id.clone(),
1810 },
1811 NodeResponse::Accepted => Self::Accepted,
1812 NodeResponse::ShuttingDown => Self::ShuttingDown,
1813 }
1814 }
1815}
1816
1817impl C2NodeResponse {
1818 pub fn from_node_response_with_control_detail(response: &NodeResponse) -> Self {
1821 let mut projected = Self::from(response);
1822 if let (
1823 Self::Resync { events: projected_events, .. },
1824 NodeResponse::Resync { events, .. },
1825 ) = (&mut projected, response)
1826 {
1827 *projected_events = events
1828 .iter()
1829 .map(C2NodeEventEnvelope::from_node_envelope_with_control_detail)
1830 .collect();
1831 }
1832 projected
1833 }
1834
1835 pub fn requires_control_detail_capability(&self) -> bool {
1836 matches!(self, Self::Resync { events, .. }
1837 if events.iter().any(|event| event.event.requires_control_detail_capability()))
1838 }
1839
1840 pub fn strip_control_detail(&mut self) {
1841 if let Self::Resync { events, .. } = self {
1842 for event in events {
1843 event.event.strip_control_detail();
1844 }
1845 }
1846 }
1847
1848 pub fn requires_session_record_context_export_capability(&self) -> bool {
1849 matches!(self, Self::ContextPackForSessionRecordExported { .. })
1850 }
1851
1852 pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
1853 matches!(self,
1854 Self::Armed { .. }
1855 | Self::Spawned { .. }
1856 | Self::Activated { .. }
1857 | Self::Aborted { .. }
1858 | Self::ReplyChunkAccepted { .. }
1859 | Self::CallRejected { .. })
1860 || matches!(self, Self::SpawnSpecAccepted { receipt }
1861 if receipt.harness_mcp_proxy.is_some())
1862 || matches!(self, Self::ManagedWorktreeSpawnAccepted { receipt }
1863 if receipt.spawn.harness_mcp_proxy.is_some())
1864 }
1865}
1866
1867impl C2NodeResponse {
1868 pub fn native_session_catalog_contract_is_valid(&self) -> bool {
1869 match self {
1870 Self::NativeSessionsCataloged { route, entries, summary } => {
1871 route.validate().is_ok()
1872 && entries.len()
1873 <= usize::from(gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX)
1874 && entries.iter().enumerate().all(|(index, entry)| {
1875 entry.validate_for_route(route).is_ok()
1876 && !entries[..index].iter().any(|existing| {
1877 existing.selection_id == entry.selection_id
1878 || entry.record_id.as_ref().is_some_and(|record_id| {
1879 existing.record_id.as_ref() == Some(record_id)
1880 })
1881 })
1882 })
1883 && summary.as_ref().map_or(true, |summary| {
1884 summary.validate_initial_entries(entries.len()).is_ok()
1885 })
1886 }
1887 Self::NativeSessionsPaged { route, page } => {
1888 page.validate_for_route(route).is_ok()
1889 }
1890 _ => true,
1891 }
1892 }
1893
1894 pub fn requires_native_session_catalog_capability(&self) -> bool {
1895 matches!(self, Self::NativeSessionsCataloged { .. })
1896 }
1897
1898 pub fn requires_native_session_catalog_paging_capability(&self) -> bool {
1899 matches!(self, Self::NativeSessionsPaged { .. })
1900 }
1901
1902 pub fn requires_native_session_preview_capability(&self) -> bool {
1903 matches!(
1904 self,
1905 Self::NativeSessionPreviewed { .. } | Self::SessionRecordPreviewed { .. }
1906 )
1907 }
1908
1909 pub fn requires_native_session_index_capability(&self) -> bool {
1910 matches!(self, Self::NativeSessionIndexed { .. })
1911 }
1912
1913 pub fn native_session_index_contract_is_valid(&self) -> bool {
1914 match self {
1915 Self::NativeSessionIndexed { selection, record } => {
1916 selection.validate().is_ok()
1917 && selection.route.scope == NativeSessionCatalogScope::Workspace
1918 && selection.route.workspace_id.as_ref() == Some(&record.workspace_id)
1919 && selection.route.provider == record.provider
1920 }
1921 _ => false,
1922 }
1923 }
1924
1925 pub fn native_session_preview_contract_is_valid(&self) -> bool {
1926 match self {
1927 Self::NativeSessionPreviewed { selection, preview } => {
1928 selection.validate().is_ok() && preview.validate().is_ok()
1929 }
1930 Self::SessionRecordPreviewed { preview, .. } => preview.validate().is_ok(),
1931 _ => true,
1932 }
1933 }
1934
1935 pub fn requires_host_directory_browse_capability(&self) -> bool {
1936 matches!(self, Self::HostDirectoriesBrowsed { .. })
1937 }
1938
1939 pub fn requires_workspace_entry_create_capability(&self) -> bool {
1940 matches!(
1941 self,
1942 Self::WorkspaceFileCreated { .. } | Self::WorkspaceDirectoryCreated { .. }
1943 )
1944 }
1945
1946 pub fn requires_child_environment_profile_capability(&self) -> bool {
1947 match self {
1948 Self::Snapshot { snapshot, .. } => {
1949 snapshot.requires_child_environment_profile_capability()
1950 }
1951 Self::Resync {
1952 snapshot, events, ..
1953 } => {
1954 snapshot.requires_child_environment_profile_capability()
1955 || events.iter().any(|event| {
1956 event.event.requires_child_environment_profile_capability()
1957 })
1958 }
1959 Self::SpawnSpecAccepted { receipt }
1960 | Self::Spawned { receipt, .. } => receipt.environment_profile.is_some(),
1961 Self::ManagedWorktreeSpawnAccepted { receipt } => {
1962 receipt.spawn.environment_profile.is_some()
1963 }
1964 Self::SessionRecordUpdated { record }
1965 | Self::ProviderSessionIndexed { record }
1966 | Self::NativeSessionIndexed { record, .. }
1967 | Self::SessionRecordResumed { record, .. } => {
1968 record.environment_profile.is_some()
1969 }
1970 Self::DurableContextPackResolved { .. } | Self::ContextPackBytesRead { .. } => false,
1971 Self::Armed { .. }
1972 | Self::Activated { .. }
1973 | Self::Aborted { .. }
1974 | Self::ReplyChunkAccepted { .. }
1975 | Self::CallRejected { .. }
1976 | Self::WorkspaceInspected { .. }
1977 | Self::DeliveryStageBegun { .. }
1978 | Self::DeliveryBlobChunkAccepted { .. }
1979 | Self::DeliveryCommitted { .. }
1980 | Self::DeliveryStageAborted { .. }
1981 | Self::HostDirectoriesBrowsed { .. }
1982 | Self::WorkspaceFileRead { .. }
1983 | Self::WorkspaceFileWritten { .. }
1984 | Self::WorkspaceFileCreated { .. }
1985 | Self::WorkspaceDirectoryCreated { .. }
1986 | Self::GitHistoryRead { .. }
1987 | Self::GitDiffRead { .. }
1988 | Self::Controller { .. }
1989 | Self::SpawnAccepted { .. }
1990 | Self::ManagedWorktreeCleanup { .. }
1991 | Self::SessionRecordForgotten { .. }
1992 | Self::NativeSessionsCataloged { .. }
1993 | Self::NativeSessionsPaged { .. }
1994 | Self::NativeSessionPreviewed { .. }
1995 | Self::SessionRecordPreviewed { .. }
1996 | Self::HistoryDiscovered { .. }
1997 | Self::HistoryLoaded { .. }
1998 | Self::ContextPackForSessionRecordExported { .. }
1999 | Self::ContextPackExported { .. }
2000 | Self::ContextPackForgotten { .. }
2001 | Self::WorkspaceRegistered { .. }
2002 | Self::StandaloneWorkspaceCreated { .. }
2003 | Self::WorkspaceUnregistered { .. }
2004 | Self::WorktreeCreated { .. }
2005 | Self::WorktreeRemoved { .. }
2006 | Self::Accepted
2007 | Self::ShuttingDown => false,
2008 }
2009 }
2010
2011 pub fn requires_session_bundle_materialization_capability(&self) -> bool {
2012 match self {
2013 Self::Snapshot { snapshot, .. } => {
2014 snapshot.requires_session_bundle_materialization_capability()
2015 }
2016 Self::Resync {
2017 snapshot, events, ..
2018 } => {
2019 snapshot.requires_session_bundle_materialization_capability()
2020 || events.iter().any(|event| {
2021 event.event.requires_session_bundle_materialization_capability()
2022 })
2023 }
2024 Self::SpawnSpecAccepted { receipt }
2025 | Self::Spawned { receipt, .. } => receipt.bundle.is_some(),
2026 Self::ManagedWorktreeSpawnAccepted { receipt } => receipt.spawn.bundle.is_some(),
2027 Self::SessionRecordUpdated { record }
2028 | Self::ProviderSessionIndexed { record }
2029 | Self::NativeSessionIndexed { record, .. }
2030 | Self::SessionRecordResumed { record, .. } => record.bundle.is_some(),
2031 Self::DurableContextPackResolved { .. } | Self::ContextPackBytesRead { .. } => false,
2032 Self::Armed { .. }
2033 | Self::Activated { .. }
2034 | Self::Aborted { .. }
2035 | Self::ReplyChunkAccepted { .. }
2036 | Self::CallRejected { .. }
2037 | Self::WorkspaceInspected { .. }
2038 | Self::DeliveryStageBegun { .. }
2039 | Self::DeliveryBlobChunkAccepted { .. }
2040 | Self::DeliveryCommitted { .. }
2041 | Self::DeliveryStageAborted { .. }
2042 | Self::HostDirectoriesBrowsed { .. }
2043 | Self::WorkspaceFileRead { .. }
2044 | Self::WorkspaceFileWritten { .. }
2045 | Self::WorkspaceFileCreated { .. }
2046 | Self::WorkspaceDirectoryCreated { .. }
2047 | Self::GitHistoryRead { .. }
2048 | Self::GitDiffRead { .. }
2049 | Self::Controller { .. }
2050 | Self::SpawnAccepted { .. }
2051 | Self::ManagedWorktreeCleanup { .. }
2052 | Self::SessionRecordForgotten { .. }
2053 | Self::NativeSessionsCataloged { .. }
2054 | Self::NativeSessionsPaged { .. }
2055 | Self::NativeSessionPreviewed { .. }
2056 | Self::SessionRecordPreviewed { .. }
2057 | Self::HistoryDiscovered { .. }
2058 | Self::HistoryLoaded { .. }
2059 | Self::ContextPackForSessionRecordExported { .. }
2060 | Self::ContextPackExported { .. }
2061 | Self::ContextPackForgotten { .. }
2062 | Self::WorkspaceRegistered { .. }
2063 | Self::StandaloneWorkspaceCreated { .. }
2064 | Self::WorkspaceUnregistered { .. }
2065 | Self::WorktreeCreated { .. }
2066 | Self::WorktreeRemoved { .. }
2067 | Self::Accepted
2068 | Self::ShuttingDown => false,
2069 }
2070 }
2071
2072 pub fn requires_history_context_pack_capability(&self) -> bool {
2073 match self {
2074 Self::Snapshot { snapshot, .. } => {
2075 snapshot.requires_history_context_pack_capability()
2076 }
2077 Self::Resync {
2078 snapshot, events, ..
2079 } => {
2080 snapshot.requires_history_context_pack_capability()
2081 || events.iter().any(|event| {
2082 event.event.requires_history_context_pack_capability()
2083 })
2084 }
2085 Self::SpawnSpecAccepted { receipt }
2086 | Self::Spawned { receipt, .. } => {
2087 receipt.context_id.is_some() || receipt.context.is_some()
2088 }
2089 Self::ManagedWorktreeSpawnAccepted { receipt } => {
2090 receipt.spawn.context_id.is_some() || receipt.spawn.context.is_some()
2091 }
2092 Self::SessionRecordUpdated { record }
2093 | Self::ProviderSessionIndexed { record }
2094 | Self::NativeSessionIndexed { record, .. }
2095 | Self::SessionRecordResumed { record, .. } => {
2096 record.requires_history_context_pack_capability()
2097 }
2098 Self::HistoryDiscovered { .. }
2099 | Self::HistoryLoaded { .. }
2100 | Self::ContextPackForSessionRecordExported { .. }
2101 | Self::ContextPackExported { .. }
2102 | Self::ContextPackForgotten { .. }
2103 | Self::DurableContextPackResolved { .. }
2104 | Self::ContextPackBytesRead { .. } => true,
2105 Self::Armed { .. }
2106 | Self::Activated { .. }
2107 | Self::Aborted { .. }
2108 | Self::ReplyChunkAccepted { .. }
2109 | Self::CallRejected { .. }
2110 | Self::WorkspaceInspected { .. }
2111 | Self::DeliveryStageBegun { .. }
2112 | Self::DeliveryBlobChunkAccepted { .. }
2113 | Self::DeliveryCommitted { .. }
2114 | Self::DeliveryStageAborted { .. }
2115 | Self::HostDirectoriesBrowsed { .. }
2116 | Self::WorkspaceFileRead { .. }
2117 | Self::WorkspaceFileWritten { .. }
2118 | Self::WorkspaceFileCreated { .. }
2119 | Self::WorkspaceDirectoryCreated { .. }
2120 | Self::GitHistoryRead { .. }
2121 | Self::GitDiffRead { .. }
2122 | Self::Controller { .. }
2123 | Self::SpawnAccepted { .. }
2124 | Self::ManagedWorktreeCleanup { .. }
2125 | Self::SessionRecordForgotten { .. }
2126 | Self::NativeSessionsCataloged { .. }
2127 | Self::NativeSessionsPaged { .. }
2128 | Self::NativeSessionPreviewed { .. }
2129 | Self::SessionRecordPreviewed { .. }
2130 | Self::WorkspaceRegistered { .. }
2131 | Self::StandaloneWorkspaceCreated { .. }
2132 | Self::WorkspaceUnregistered { .. }
2133 | Self::WorktreeCreated { .. }
2134 | Self::WorktreeRemoved { .. }
2135 | Self::Accepted
2136 | Self::ShuttingDown => false,
2137 }
2138 }
2139
2140 pub fn requires_session_task_correlation_capability(&self) -> bool {
2141 match self {
2142 Self::Snapshot { snapshot, .. } => {
2143 snapshot.requires_session_task_correlation_capability()
2144 }
2145 Self::Resync { snapshot, events, .. } => {
2146 snapshot.requires_session_task_correlation_capability()
2147 || events.iter().any(|event| {
2148 event.event.requires_session_task_correlation_capability()
2149 })
2150 }
2151 Self::SessionRecordUpdated { record }
2152 | Self::ProviderSessionIndexed { record }
2153 | Self::NativeSessionIndexed { record, .. }
2154 | Self::SessionRecordResumed { record, .. } => {
2155 record.requires_session_task_correlation_capability()
2156 }
2157 _ => false,
2158 }
2159 }
2160}
2161
2162fn deserialize_c2_native_session_catalog_entries<'de, D>(
2163 deserializer: D,
2164) -> Result<Vec<NativeSessionCatalogEntry>, D::Error>
2165where
2166 D: Deserializer<'de>,
2167{
2168 let entries = Vec::<NativeSessionCatalogEntry>::deserialize(deserializer)?;
2169 if entries.len() > usize::from(gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX) {
2170 return Err(serde::de::Error::custom(
2171 "C2 native session catalog exceeds the supported bounded range",
2172 ));
2173 }
2174 for (index, entry) in entries.iter().enumerate() {
2175 entry.validate().map_err(serde::de::Error::custom)?;
2176 if entries[..index]
2177 .iter()
2178 .any(|existing| {
2179 existing.selection_id == entry.selection_id
2180 || entry.record_id.as_ref().is_some_and(|record_id| {
2181 existing.record_id.as_ref() == Some(record_id)
2182 })
2183 })
2184 {
2185 return Err(serde::de::Error::custom(
2186 "C2 native session catalog contains a duplicate selection or managed record",
2187 ));
2188 }
2189 }
2190 Ok(entries)
2191}
2192
2193fn deserialize_optional_c2_native_session_catalog_summary<'de, D>(
2194 deserializer: D,
2195) -> Result<Option<NativeSessionCatalogSummary>, D::Error>
2196where
2197 D: Deserializer<'de>,
2198{
2199 let summary = Option::<NativeSessionCatalogSummary>::deserialize(deserializer)?;
2200 if let Some(summary) = summary.as_ref() {
2201 summary.validate().map_err(serde::de::Error::custom)?;
2202 }
2203 Ok(summary)
2204}
2205
2206fn deserialize_c2_native_session_catalog_page<'de, D>(
2207 deserializer: D,
2208) -> Result<NativeSessionCatalogPage, D::Error>
2209where
2210 D: Deserializer<'de>,
2211{
2212 let page = NativeSessionCatalogPage::deserialize(deserializer)?;
2213 page.validate().map_err(serde::de::Error::custom)?;
2214 Ok(page)
2215}
2216
2217fn deserialize_c2_native_session_preview<'de, D>(
2218 deserializer: D,
2219) -> Result<NativeSessionPreview, D::Error>
2220where
2221 D: Deserializer<'de>,
2222{
2223 let preview = NativeSessionPreview::deserialize(deserializer)?;
2224 preview.validate().map_err(serde::de::Error::custom)?;
2225 Ok(preview)
2226}
2227
2228fn deserialize_c2_session_record_preview<'de, D>(
2229 deserializer: D,
2230) -> Result<SessionRecordPreview, D::Error>
2231where
2232 D: Deserializer<'de>,
2233{
2234 let preview = SessionRecordPreview::deserialize(deserializer)?;
2235 preview.validate().map_err(serde::de::Error::custom)?;
2236 Ok(preview)
2237}
2238
2239fn deserialize_c2_history_candidates<'de, D>(
2240 deserializer: D,
2241) -> Result<Vec<HistoryCandidateSummary>, D::Error>
2242where
2243 D: Deserializer<'de>,
2244{
2245 let candidates = Vec::<HistoryCandidateSummary>::deserialize(deserializer)?;
2246 if candidates.len() > usize::from(gate4agent_types::HISTORY_DISCOVERY_LIMIT_MAX) {
2247 return Err(serde::de::Error::custom(
2248 "C2 history candidate count exceeds the discovery limit",
2249 ));
2250 }
2251 for (index, candidate) in candidates.iter().enumerate() {
2252 candidate.validate().map_err(serde::de::Error::custom)?;
2253 if candidates[..index]
2254 .iter()
2255 .any(|existing| existing.id == candidate.id)
2256 {
2257 return Err(serde::de::Error::custom(
2258 "C2 history candidates contain a duplicate candidate ID",
2259 ));
2260 }
2261 }
2262 Ok(candidates)
2263}
2264
2265fn deserialize_c2_history_session_id<'de, D>(deserializer: D) -> Result<String, D::Error>
2266where
2267 D: Deserializer<'de>,
2268{
2269 let session_id = String::deserialize(deserializer)?;
2270 let validation = gate4agent_types::HistorySessionRecord {
2271 session_id: session_id.clone(),
2272 title: None,
2273 cwd: None,
2274 model: None,
2275 message_count: 0,
2276 completed_turn_count: None,
2277 total_tokens: 0,
2278 messages: Vec::new(),
2279 };
2280 validation.validate().map_err(serde::de::Error::custom)?;
2281 Ok(session_id)
2282}
2283
2284#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2285#[serde(rename_all = "kebab-case")]
2286pub enum C2RelayFailureCode {
2287 UnknownNode,
2288 NodeOffline,
2289 StaleNodeIncarnation,
2290 RelayBusy,
2291 OperatorAlreadyConnected,
2292 RequestIdReused,
2293 RequestForbidden,
2294 ClientLagged,
2295 ShuttingDown,
2296 BuildStampMismatch,
2304}
2305
2306#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2307pub struct C2RelayFailure {
2308 pub code: C2RelayFailureCode,
2309 pub message: String,
2310 pub current_incarnation_id: Option<NodeIncarnationId>,
2311}
2312
2313#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2314pub struct C2ClientHello {
2315 pub build_stamp: String,
2316 pub client_nonce: [u8; C2_AUTH_NONCE_BYTES],
2317 #[serde(default, skip_serializing_if = "Option::is_none")]
2318 pub compatibility: Option<ClientCompatibilityOffer>,
2319}
2320
2321impl C2ClientHello {
2322 pub fn new(client_nonce: [u8; C2_AUTH_NONCE_BYTES]) -> Self {
2323 Self {
2324 build_stamp: BUILD_STAMP.to_owned(),
2325 client_nonce,
2326 compatibility: None,
2327 }
2328 }
2329
2330 pub fn negotiating(
2331 client_nonce: [u8; C2_AUTH_NONCE_BYTES],
2332 compatibility: ClientCompatibilityOffer,
2333 ) -> Self {
2334 Self {
2335 build_stamp: BUILD_STAMP.to_owned(),
2336 client_nonce,
2337 compatibility: Some(compatibility),
2338 }
2339 }
2340}
2341
2342#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2343pub struct C2ControlCompatibilitySupport {
2344 pub build_stamp: String,
2345 #[serde(default)]
2346 pub capabilities: Vec<CapabilityId>,
2347 pub host: HostDescriptor,
2348 pub path_semantics: PathSemantics,
2349}
2350
2351#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2352pub struct NegotiatedC2ControlCompatibility {
2353 pub build_stamp: String,
2354 #[serde(default)]
2355 pub capabilities: Vec<CapabilityId>,
2356 pub host: HostDescriptor,
2357 pub path_semantics: PathSemantics,
2358}
2359
2360impl C2ControlCompatibilitySupport {
2361 pub fn negotiate(
2362 &self,
2363 hello: &C2ClientHello,
2364 ) -> Result<NegotiatedC2ControlCompatibility, ProtocolNegotiationError> {
2365 if self.build_stamp != hello.build_stamp {
2366 return Err(ProtocolNegotiationError::BuildStampMismatch {
2367 local: self.build_stamp.clone(),
2368 remote: hello.build_stamp.clone(),
2369 });
2370 }
2371 let legacy;
2372 let offer = match hello.compatibility.as_ref() {
2373 Some(offer) => offer,
2374 None => {
2375 legacy = ClientCompatibilityOffer::local();
2376 &legacy
2377 }
2378 };
2379 let capabilities = self
2380 .capabilities
2381 .iter()
2382 .filter(|capability| offer.capabilities.contains(capability))
2383 .cloned()
2384 .collect();
2385 Ok(NegotiatedC2ControlCompatibility {
2386 build_stamp: self.build_stamp.clone(),
2387 capabilities,
2388 host: self.host.clone(),
2389 path_semantics: self.path_semantics.clone(),
2390 })
2391 }
2392}
2393
2394#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2395pub struct C2ServerChallenge {
2396 pub build_stamp: String,
2397 pub server_nonce: [u8; C2_AUTH_NONCE_BYTES],
2398 pub server_proof: [u8; C2_AUTH_PROOF_BYTES],
2399 #[serde(default, skip_serializing_if = "Option::is_none")]
2400 pub compatibility: Option<NegotiatedC2ControlCompatibility>,
2401}
2402
2403#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2404pub struct C2ClientAuthentication {
2405 pub client_proof: [u8; C2_AUTH_PROOF_BYTES],
2406}
2407
2408#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2409pub struct C2Hello {
2410 pub build_stamp: String,
2411 pub connection_id: u64,
2412 pub status: StatusResponse,
2413 #[serde(default, skip_serializing_if = "Option::is_none")]
2414 pub compatibility: Option<NegotiatedC2ControlCompatibility>,
2415}
2416
2417#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
2418#[serde(rename_all = "kebab-case")]
2419pub enum C2RelayRoute {
2420 #[default]
2421 Unknown,
2422 LocalIpc,
2423 SshForwardedLoopback,
2424}
2425
2426impl C2RelayRoute {
2427 pub const fn is_unknown(&self) -> bool {
2428 matches!(self, Self::Unknown)
2429 }
2430
2431 fn from_transport_label(transport_label: &str) -> Self {
2432 match transport_label {
2433 "windows-named-pipe" | "unix-domain-socket" => Self::LocalIpc,
2434 "ssh-forwarded-loopback" => Self::SshForwardedLoopback,
2435 _ => Self::Unknown,
2436 }
2437 }
2438}
2439
2440#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2441pub struct C2TopologyNode {
2442 pub node_id: NodeId,
2443 pub endpoint: String,
2444 #[serde(default, skip_serializing_if = "C2RelayRoute::is_unknown")]
2445 pub relay_route: C2RelayRoute,
2446 pub transport: NodeTransportState,
2447 pub current_incarnation_id: Option<NodeIncarnationId>,
2448 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2449 pub provider_contracts: Vec<ProviderContractSupport>,
2450 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2451 pub provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
2452 #[serde(default, skip_serializing_if = "ProviderRuntimeStatuses::is_empty")]
2453 pub provider_runtime_statuses: ProviderRuntimeStatuses,
2454}
2455
2456#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2457pub struct C2Topology {
2458 pub nodes: Vec<C2TopologyNode>,
2459}
2460
2461impl C2Topology {
2462 pub fn from_status(status: &StatusResponse) -> Self {
2463 Self::from_status_with_capabilities(status, true, true)
2464 }
2465
2466 pub fn from_status_with_provider_contracts(
2467 status: &StatusResponse,
2468 include_provider_contracts: bool,
2469 ) -> Self {
2470 Self::from_status_with_capabilities(
2471 status,
2472 include_provider_contracts,
2473 include_provider_contracts,
2474 )
2475 }
2476
2477 pub fn from_status_with_capabilities(
2478 status: &StatusResponse,
2479 include_provider_contracts: bool,
2480 include_provider_runtime_status: bool,
2481 ) -> Self {
2482 let nodes = status.nodes.iter().take(MAX_C2_NODES).map(|(node_id, observed)| {
2483 let provider_contract_inventory = include_provider_contracts
2484 .then_some(observed.inventory.as_ref())
2485 .flatten();
2486 let provider_runtime_statuses = if include_provider_runtime_status {
2487 observed
2488 .inventory
2489 .as_ref()
2490 .map(|inventory| inventory.provider_runtime_statuses.clone())
2491 .unwrap_or_default()
2492 } else {
2493 ProviderRuntimeStatuses::default()
2494 };
2495 C2TopologyNode {
2496 node_id: node_id.clone(),
2497 endpoint: observed.endpoint.clone(),
2498 relay_route: C2RelayRoute::from_transport_label(&observed.transport_label),
2499 transport: observed.transport,
2500 current_incarnation_id: observed.cursor.map(|cursor| cursor.incarnation_id),
2501 provider_contracts: provider_contract_inventory
2502 .map(|inventory| inventory.provider_contracts.clone())
2503 .unwrap_or_default(),
2504 provider_adapter_contracts: provider_contract_inventory
2505 .map(|inventory| inventory.provider_adapter_contracts.clone())
2506 .unwrap_or_default(),
2507 provider_runtime_statuses,
2508 }
2509 }).collect();
2510 Self { nodes }
2511 }
2512}
2513
2514#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2515pub struct C2RequestEnvelope {
2516 pub request_id: C2RequestId,
2517 pub request: RoutedNodeRequest,
2518}
2519
2520#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2521pub struct C2ReplyEnvelope {
2522 pub request_id: C2RequestId,
2523 pub result: Result<RoutedNodeResponse, C2RelayFailure>,
2524}
2525
2526#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2527#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
2528pub enum C2ClientFrame {
2529 Hello(C2ClientHello),
2530 Authenticate(C2ClientAuthentication),
2531 Request(C2RequestEnvelope),
2532}
2533
2534#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2535#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
2536pub enum C2ServerFrame {
2537 Challenge(C2ServerChallenge),
2538 Hello(C2Hello),
2539 Reply(C2ReplyEnvelope),
2540 Event(RoutedNodeEvent),
2541 Topology(C2Topology),
2542 Rejected(C2RelayFailure),
2543}
2544
2545#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2546pub enum C2AuthDirection {
2547 Server,
2548 Client,
2549}
2550
2551pub fn c2_auth_transcript(
2552 direction: C2AuthDirection,
2553 client_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2554 server_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2555) -> Vec<u8> {
2556 let mut message = Vec::with_capacity(32 + (C2_AUTH_NONCE_BYTES * 2));
2557 message.extend_from_slice(b"gate4agent-c2-control-auth-v2\0");
2558 encode_bounded_str(&mut message, BUILD_STAMP);
2559 message.push(match direction { C2AuthDirection::Server => 1, C2AuthDirection::Client => 2 });
2560 message.extend_from_slice(client_nonce);
2561 message.extend_from_slice(server_nonce);
2562 message
2563}
2564
2565#[derive(Clone, Debug, Eq, PartialEq)]
2566pub enum C2AuthTranscriptError {
2567 TooManyCapabilities {
2568 section: &'static str,
2569 count: usize,
2570 max: usize,
2571 },
2572 TooLong {
2573 len: usize,
2574 max: usize,
2575 },
2576}
2577
2578impl fmt::Display for C2AuthTranscriptError {
2579 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2580 match self {
2581 Self::TooManyCapabilities { section, count, max } => write!(
2582 formatter,
2583 "{section} contains {count} capabilities, exceeding the {max}-entry authentication limit",
2584 ),
2585 Self::TooLong { len, max } => write!(
2586 formatter,
2587 "C2 compatibility authentication transcript is {len} bytes, exceeding the {max}-byte limit",
2588 ),
2589 }
2590 }
2591}
2592
2593impl std::error::Error for C2AuthTranscriptError {}
2594
2595pub fn c2_bound_auth_transcript(
2596 direction: C2AuthDirection,
2597 client_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2598 server_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2599 offer: &ClientCompatibilityOffer,
2600 selected: &NegotiatedC2ControlCompatibility,
2601) -> Result<Vec<u8>, C2AuthTranscriptError> {
2602 validate_auth_capabilities("offer", &offer.capabilities)?;
2603 validate_auth_capabilities("selection", &selected.capabilities)?;
2604
2605 let mut message = Vec::with_capacity(512);
2606 message.extend_from_slice(b"gate4agent-c2-control-auth-v2-compatibility\0");
2607 encode_bounded_str(&mut message, BUILD_STAMP);
2608 message.push(match direction { C2AuthDirection::Server => 1, C2AuthDirection::Client => 2 });
2609 message.extend_from_slice(client_nonce);
2610 message.extend_from_slice(server_nonce);
2611
2612 message.extend_from_slice(b"offer\0");
2613 encode_bounded_str(&mut message, &offer.build_stamp);
2614 encode_capabilities(&mut message, &offer.capabilities);
2615 match offer.state_schema {
2616 Some(state_schema) => {
2617 message.push(1);
2618 encode_protocol_range(&mut message, state_schema.versions);
2619 }
2620 None => message.push(0),
2621 }
2622
2623 message.extend_from_slice(b"selected\0");
2624 encode_bounded_str(&mut message, &selected.build_stamp);
2625 encode_capabilities(&mut message, &selected.capabilities);
2626 encode_bounded_str(&mut message, selected.host.operating_system.as_str());
2627 encode_bounded_str(&mut message, selected.host.architecture.as_str());
2628 message.push(match selected.path_semantics.style {
2629 PathStyle::Windows => 1,
2630 PathStyle::Posix => 2,
2631 });
2632 message.push(match selected.path_semantics.encoding {
2633 PathEncoding::Utf8 => 1,
2634 PathEncoding::UnixBytes => 2,
2635 });
2636
2637 if message.len() > MAX_C2_BOUND_AUTH_TRANSCRIPT_BYTES {
2638 return Err(C2AuthTranscriptError::TooLong {
2639 len: message.len(),
2640 max: MAX_C2_BOUND_AUTH_TRANSCRIPT_BYTES,
2641 });
2642 }
2643 Ok(message)
2644}
2645
2646fn validate_auth_capabilities(
2647 section: &'static str,
2648 capabilities: &[CapabilityId],
2649) -> Result<(), C2AuthTranscriptError> {
2650 if capabilities.len() > MAX_C2_AUTH_COMPATIBILITY_CAPABILITIES {
2651 return Err(C2AuthTranscriptError::TooManyCapabilities {
2652 section,
2653 count: capabilities.len(),
2654 max: MAX_C2_AUTH_COMPATIBILITY_CAPABILITIES,
2655 });
2656 }
2657 Ok(())
2658}
2659
2660fn encode_protocol_range(message: &mut Vec<u8>, range: ProtocolRange) {
2661 message.extend_from_slice(&range.minimum().to_le_bytes());
2662 message.extend_from_slice(&range.maximum().to_le_bytes());
2663}
2664
2665fn encode_capabilities(message: &mut Vec<u8>, capabilities: &[CapabilityId]) {
2666 message.extend_from_slice(&(capabilities.len() as u16).to_le_bytes());
2667 for capability in capabilities {
2668 encode_bounded_str(message, capability.as_str());
2669 }
2670}
2671
2672fn encode_bounded_str(message: &mut Vec<u8>, value: &str) {
2673 debug_assert!(value.len() <= u16::MAX as usize);
2674 message.extend_from_slice(&(value.len() as u16).to_le_bytes());
2675 message.extend_from_slice(value.as_bytes());
2676}
2677
2678#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2679#[serde(rename_all = "kebab-case")]
2680pub enum NodeTransportState {
2681 Online,
2682 Offline,
2683 Parked,
2684}
2685
2686#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2687#[serde(rename_all = "kebab-case")]
2688pub enum NodeFreshness {
2689 Fresh,
2690 Stale,
2691 Unavailable,
2692}
2693
2694#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2695#[serde(rename_all = "kebab-case")]
2696pub enum GapKind {
2697 IncarnationChanged,
2698 HistoryEvicted,
2699 NonContiguousEvents,
2700 CursorRegression,
2701}
2702
2703#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2704pub struct NodeGap {
2705 pub kind: GapKind,
2706 pub detected_at_unix_ms: u64,
2707 pub previous: Option<NodeCursor>,
2708 pub observed: NodeCursor,
2709}
2710
2711#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2712#[serde(rename_all = "kebab-case")]
2713pub enum C2ErrorCategory {
2714 Authentication,
2715 Identity,
2716 Protocol,
2717 Transport,
2718 Timeout,
2719 Internal,
2720}
2721
2722#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2723pub struct SanitizedError {
2724 pub category: C2ErrorCategory,
2725 pub message: String,
2726}
2727
2728#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2729pub struct SlimSession {
2730 pub instance_id: AgentInstanceId,
2731 pub generation: SessionGeneration,
2732 pub agent_id: String,
2733 pub transport: TransportKind,
2734 pub status: SlimSessionStatus,
2735 pub process_id: Option<u32>,
2736 pub terminal_size: Option<TerminalSize>,
2737 pub operation_pending: bool,
2738 pub input_pending: bool,
2739 #[serde(default)]
2745 pub screen_state: PtyScreenState,
2746}
2747
2748#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2749#[serde(rename_all = "kebab-case")]
2750pub enum SlimSessionStatus {
2751 Registered,
2752 Starting,
2753 Running,
2754 Stopping,
2755 Exited,
2756 Failed,
2757}
2758
2759impl From<&SessionStatus> for SlimSessionStatus {
2760 fn from(status: &SessionStatus) -> Self {
2761 match status {
2762 SessionStatus::Registered => Self::Registered,
2763 SessionStatus::Starting => Self::Starting,
2764 SessionStatus::Running => Self::Running,
2765 SessionStatus::Stopping => Self::Stopping,
2766 SessionStatus::Exited { .. } => Self::Exited,
2767 SessionStatus::Failed { .. } => Self::Failed,
2768 }
2769 }
2770}
2771
2772impl From<&C2SessionStatus> for SlimSessionStatus {
2773 fn from(status: &C2SessionStatus) -> Self {
2774 match status {
2775 C2SessionStatus::Registered => Self::Registered,
2776 C2SessionStatus::Starting => Self::Starting,
2777 C2SessionStatus::Running => Self::Running,
2778 C2SessionStatus::Stopping => Self::Stopping,
2779 C2SessionStatus::Exited { .. } => Self::Exited,
2780 C2SessionStatus::Failed => Self::Failed,
2781 }
2782 }
2783}
2784
2785#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2786pub struct SlimWorkspace {
2787 pub workspace_id: WorkspaceId,
2788 pub canonical_root: String,
2789 pub canonical_root_truncated: bool,
2790 pub sessions: Vec<SlimSession>,
2791 pub session_count: usize,
2792 pub sessions_truncated: bool,
2793 #[serde(default, skip_serializing_if = "Option::is_none")]
2794 pub worktree_service_mode: Option<WorktreeServiceMode>,
2795 #[serde(default, skip_serializing_if = "Option::is_none")]
2796 pub managed_worktree_profiles: Option<WorktreeProfileInventory>,
2797}
2798
2799#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2800pub struct SlimManagedSessionRecord {
2801 pub record_id: SessionRecordId,
2802 pub display_name: String,
2803 pub display_name_truncated: bool,
2804 pub provider: AgentId,
2805 pub mode: SessionMode,
2806 pub state: ManagedSessionState,
2807 pub workspace_id: WorkspaceId,
2808 pub active_session: Option<SessionAddress>,
2809 #[serde(default, skip_serializing_if = "Option::is_none")]
2810 pub environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
2811 #[serde(default, skip_serializing_if = "Option::is_none")]
2812 pub bundle: Option<ResolvedBundleReceipt>,
2813 pub provider_identity_present: bool,
2814 pub updated_at_unix_ms: u64,
2815}
2816
2817#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2818pub struct SlimNodeInventory {
2819 pub node_id: NodeId,
2820 pub enabled_providers: Vec<AgentId>,
2821 #[serde(default, skip_serializing_if = "ProviderRuntimeStatuses::is_empty")]
2822 pub provider_runtime_statuses: ProviderRuntimeStatuses,
2823 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2824 pub provider_contracts: Vec<ProviderContractSupport>,
2825 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2826 pub provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
2827 pub workspaces: BTreeMap<WorkspaceId, SlimWorkspace>,
2828 pub workspace_count: usize,
2829 pub workspaces_truncated: bool,
2830 pub session_count: usize,
2831 pub sessions_truncated: bool,
2832 #[serde(default)]
2833 pub managed_sessions: Vec<SlimManagedSessionRecord>,
2834 #[serde(default)]
2835 pub managed_session_count: usize,
2836 #[serde(default)]
2837 pub managed_sessions_truncated: bool,
2838 #[serde(default, skip_serializing_if = "usize_is_zero")]
2846 pub retired_count: usize,
2847 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2848 pub managed_worktrees: Vec<ManagedWorktreeLeaseSnapshot>,
2849 #[serde(default, skip_serializing_if = "usize_is_zero")]
2850 pub managed_worktree_count: usize,
2851 #[serde(default, skip_serializing_if = "bool_is_false")]
2852 pub managed_worktrees_truncated: bool,
2853 #[serde(default, skip_serializing_if = "Option::is_none")]
2854 pub launch_inventory: Option<LaunchInventory>,
2855}
2856
2857fn usize_is_zero(value: &usize) -> bool { *value == 0 }
2858
2859fn bool_is_false(value: &bool) -> bool { !*value }
2860
2861fn managed_session_liveness_rank(state: &ManagedSessionState) -> u8 {
2873 match state {
2874 ManagedSessionState::Live => 0,
2875 ManagedSessionState::IdentityPending => 1,
2876 ManagedSessionState::Dormant => 2,
2877 ManagedSessionState::Unavailable => 3,
2878 }
2879}
2880
2881fn session_liveness_rank(status: &SessionStatus) -> u8 {
2888 match status {
2889 SessionStatus::Running => 0,
2890 SessionStatus::Starting | SessionStatus::Stopping => 1,
2891 SessionStatus::Registered => 2,
2892 SessionStatus::Exited { .. } | SessionStatus::Failed { .. } => 3,
2893 }
2894}
2895
2896fn c2_session_liveness_rank(status: &C2SessionStatus) -> u8 {
2898 match status {
2899 C2SessionStatus::Running => 0,
2900 C2SessionStatus::Starting | C2SessionStatus::Stopping => 1,
2901 C2SessionStatus::Registered => 2,
2902 C2SessionStatus::Exited { .. } | C2SessionStatus::Failed => 3,
2903 }
2904}
2905
2906impl SlimNodeInventory {
2907 pub fn from_snapshot(snapshot: &NodeSnapshot) -> Self {
2908 let mut providers = snapshot.enabled_providers.clone();
2909 providers.sort();
2910 providers.dedup();
2911 let workspace_count = snapshot.workspaces.len();
2912 let session_count = snapshot.workspaces.iter().map(|workspace| workspace.sessions.len()).sum();
2913 let mut remaining_sessions = MAX_C2_SESSIONS_PER_NODE;
2914 let mut workspaces = BTreeMap::new();
2915 let mut ordered = snapshot.workspaces.iter().collect::<Vec<_>>();
2916 ordered.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id));
2917 for workspace in ordered.into_iter().take(MAX_C2_WORKSPACES_PER_NODE) {
2918 let mut sessions = workspace.sessions.iter().collect::<Vec<_>>();
2919 sessions.sort_by(|left, right| {
2920 session_liveness_rank(&left.status)
2921 .cmp(&session_liveness_rank(&right.status))
2922 .then_with(|| left.instance_id.cmp(&right.instance_id))
2923 .then_with(|| left.generation.cmp(&right.generation))
2924 });
2925 let take = remaining_sessions.min(sessions.len());
2926 let slim_sessions = sessions.into_iter().take(take).map(|session| SlimSession {
2927 instance_id: session.instance_id,
2928 generation: session.generation,
2929 agent_id: session.agent_id.as_str().to_owned(),
2930 transport: session.transport,
2931 status: SlimSessionStatus::from(&session.status),
2932 process_id: session.process_id,
2933 terminal_size: session.terminal_size,
2934 operation_pending: session.pending_operation.is_some(),
2935 input_pending: session.pending_input.is_some(),
2936 screen_state: session.screen_state.clone(),
2937 }).collect();
2938 remaining_sessions -= take;
2939 let display_root = sanitize_host_path_display(&workspace.canonical_root);
2940 let (canonical_root, canonical_root_truncated) =
2941 truncate_utf8(&display_root, MAX_C2_ROOT_BYTES);
2942 workspaces.insert(workspace.workspace_id.clone(), SlimWorkspace {
2943 workspace_id: workspace.workspace_id.clone(),
2944 canonical_root,
2945 canonical_root_truncated,
2946 sessions: slim_sessions,
2947 session_count: workspace.sessions.len(),
2948 sessions_truncated: workspace.sessions.len() > take,
2949 worktree_service_mode: workspace.worktree_service_mode,
2950 managed_worktree_profiles: workspace.managed_worktree_profiles.clone(),
2951 });
2952 }
2953 let included_session_count = workspaces
2954 .values()
2955 .map(|workspace| workspace.sessions.len())
2956 .sum::<usize>();
2957 let managed_session_count = snapshot.session_records.len();
2958 let mut ordered_records = snapshot.session_records.iter().collect::<Vec<_>>();
2959 ordered_records.sort_by(|left, right| {
2960 managed_session_liveness_rank(&left.state)
2961 .cmp(&managed_session_liveness_rank(&right.state))
2962 .then_with(|| left.record_id.cmp(&right.record_id))
2963 });
2964 let managed_sessions = ordered_records
2965 .into_iter()
2966 .take(MAX_C2_MANAGED_SESSIONS_PER_NODE)
2967 .map(SlimManagedSessionRecord::from)
2968 .collect::<Vec<_>>();
2969 let managed_worktree_count = snapshot.managed_worktrees.len();
2970 let mut managed_worktrees = snapshot.managed_worktrees.clone();
2971 managed_worktrees.sort_by(|left, right| left.lease_id.cmp(&right.lease_id));
2972 managed_worktrees.truncate(MAX_C2_MANAGED_WORKTREES_PER_NODE);
2973 Self {
2974 node_id: snapshot.node_id.clone(),
2975 enabled_providers: providers,
2976 provider_runtime_statuses: snapshot.provider_runtime_statuses.clone(),
2977 provider_contracts: Vec::new(),
2978 provider_adapter_contracts: Vec::new(),
2979 workspaces,
2980 workspace_count,
2981 workspaces_truncated: workspace_count > MAX_C2_WORKSPACES_PER_NODE,
2982 session_count,
2983 sessions_truncated: included_session_count < session_count,
2984 managed_sessions_truncated: managed_sessions.len() < managed_session_count,
2985 managed_sessions,
2986 managed_session_count,
2987 retired_count: 0,
2993 managed_worktrees_truncated:
2994 managed_worktrees.len() < managed_worktree_count,
2995 managed_worktrees,
2996 managed_worktree_count,
2997 launch_inventory: snapshot.launch_inventory.clone(),
2998 }
2999 }
3000
3001 pub fn from_c2_snapshot(snapshot: &C2NodeSnapshot) -> Self {
3002 let mut providers = snapshot.enabled_providers.clone();
3003 providers.sort();
3004 providers.dedup();
3005 let workspace_count = snapshot.workspaces.len();
3006 let session_count = snapshot.workspaces.iter().map(|workspace| workspace.sessions.len()).sum();
3007 let mut remaining_sessions = MAX_C2_SESSIONS_PER_NODE;
3008 let mut workspaces = BTreeMap::new();
3009 let mut ordered = snapshot.workspaces.iter().collect::<Vec<_>>();
3010 ordered.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id));
3011 for workspace in ordered.into_iter().take(MAX_C2_WORKSPACES_PER_NODE) {
3012 let mut sessions = workspace.sessions.iter().collect::<Vec<_>>();
3013 sessions.sort_by(|left, right| {
3014 c2_session_liveness_rank(&left.status)
3015 .cmp(&c2_session_liveness_rank(&right.status))
3016 .then_with(|| left.instance_id.cmp(&right.instance_id))
3017 .then_with(|| left.generation.cmp(&right.generation))
3018 });
3019 let take = remaining_sessions.min(sessions.len());
3020 let slim_sessions = sessions.into_iter().take(take).map(|session| SlimSession {
3021 instance_id: session.instance_id,
3022 generation: session.generation,
3023 agent_id: session.agent_id.as_str().to_owned(),
3024 transport: session.transport,
3025 status: SlimSessionStatus::from(&session.status),
3026 process_id: session.process_id,
3027 terminal_size: session.terminal_size,
3028 operation_pending: session.pending_operation.is_some(),
3029 input_pending: session.pending_input.is_some(),
3030 screen_state: session.screen_state.clone(),
3031 }).collect();
3032 remaining_sessions -= take;
3033 let display_root = sanitize_host_path_display(&workspace.canonical_root);
3034 let (canonical_root, canonical_root_truncated) =
3035 truncate_utf8(&display_root, MAX_C2_ROOT_BYTES);
3036 workspaces.insert(workspace.workspace_id.clone(), SlimWorkspace {
3037 workspace_id: workspace.workspace_id.clone(),
3038 canonical_root,
3039 canonical_root_truncated,
3040 sessions: slim_sessions,
3041 session_count: workspace.sessions.len(),
3042 sessions_truncated: workspace.sessions.len() > take,
3043 worktree_service_mode: workspace.worktree_service_mode,
3044 managed_worktree_profiles: workspace.managed_worktree_profiles.clone(),
3045 });
3046 }
3047 let included_session_count = workspaces.values()
3048 .map(|workspace| workspace.sessions.len())
3049 .sum::<usize>();
3050 let managed_session_count = snapshot.session_records.len();
3051 let mut ordered_records = snapshot.session_records.iter().collect::<Vec<_>>();
3052 ordered_records.sort_by(|left, right| {
3053 managed_session_liveness_rank(&left.state)
3054 .cmp(&managed_session_liveness_rank(&right.state))
3055 .then_with(|| left.record_id.cmp(&right.record_id))
3056 });
3057 let managed_sessions = ordered_records.into_iter()
3058 .take(MAX_C2_MANAGED_SESSIONS_PER_NODE)
3059 .map(SlimManagedSessionRecord::from)
3060 .collect::<Vec<_>>();
3061 let managed_worktree_count = snapshot.managed_worktrees.len();
3062 let mut managed_worktrees = snapshot.managed_worktrees.clone();
3063 managed_worktrees.sort_by(|left, right| left.lease_id.cmp(&right.lease_id));
3064 managed_worktrees.truncate(MAX_C2_MANAGED_WORKTREES_PER_NODE);
3065 Self {
3066 node_id: snapshot.node_id.clone(),
3067 enabled_providers: providers,
3068 provider_runtime_statuses: snapshot.provider_runtime_statuses.clone(),
3069 provider_contracts: Vec::new(),
3070 provider_adapter_contracts: Vec::new(),
3071 workspaces,
3072 workspace_count,
3073 workspaces_truncated: workspace_count > MAX_C2_WORKSPACES_PER_NODE,
3074 session_count,
3075 sessions_truncated: included_session_count < session_count,
3076 managed_sessions_truncated: managed_sessions.len() < managed_session_count,
3077 managed_sessions,
3078 managed_session_count,
3079 retired_count: 0,
3085 managed_worktrees_truncated:
3086 managed_worktrees.len() < managed_worktree_count,
3087 managed_worktrees,
3088 managed_worktree_count,
3089 launch_inventory: snapshot.launch_inventory.clone(),
3090 }
3091 }
3092}
3093
3094impl From<&ManagedSessionRecord> for SlimManagedSessionRecord {
3095 fn from(record: &ManagedSessionRecord) -> Self {
3096 let (display_name, display_name_truncated) =
3097 truncate_utf8(&record.display_name, MAX_C2_SESSION_DISPLAY_NAME_BYTES);
3098 Self {
3099 record_id: record.record_id.clone(),
3101 display_name,
3102 display_name_truncated,
3103 provider: record.provider.clone(),
3104 mode: record.mode,
3105 state: record.state,
3106 workspace_id: record.workspace_id.clone(),
3107 active_session: record.active_session.clone(),
3108 environment_profile: record.environment_profile.clone(),
3109 bundle: record.bundle.clone(),
3110 provider_identity_present: record.provider_session.is_some(),
3111 updated_at_unix_ms: record.updated_at_unix_ms,
3112 }
3113 }
3114}
3115
3116impl From<&C2ManagedSessionRecord> for SlimManagedSessionRecord {
3117 fn from(record: &C2ManagedSessionRecord) -> Self {
3118 let (display_name, display_name_truncated) =
3119 truncate_utf8(&record.display_name, MAX_C2_SESSION_DISPLAY_NAME_BYTES);
3120 Self {
3121 record_id: record.record_id.clone(),
3123 display_name,
3124 display_name_truncated,
3125 provider: record.provider.clone(),
3126 mode: record.mode,
3127 state: record.state,
3128 workspace_id: record.workspace_id.clone(),
3129 active_session: record.active_session.clone(),
3130 environment_profile: record.environment_profile.clone(),
3131 bundle: record.bundle.clone(),
3132 provider_identity_present: record.provider_identity_present,
3133 updated_at_unix_ms: record.updated_at_unix_ms,
3134 }
3135 }
3136}
3137
3138fn truncate_utf8(value: &str, max_bytes: usize) -> (String, bool) {
3139 if value.len() <= max_bytes {
3140 return (value.to_owned(), false);
3141 }
3142 let mut end = max_bytes;
3143 while !value.is_char_boundary(end) {
3144 end -= 1;
3145 }
3146 (value[..end].to_owned(), true)
3147}
3148
3149fn sanitize_host_path_display(path: &OpaqueHostPath) -> String {
3150 let mut sanitized = String::new();
3151 for ch in path.display_text().chars() {
3152 if ch.is_control() {
3153 sanitized.extend(ch.escape_default());
3154 } else {
3155 sanitized.push(ch);
3156 }
3157 }
3158 sanitized
3159}
3160
3161#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3162pub struct ObservedNode {
3163 pub endpoint: String,
3164 pub transport_label: String,
3165 pub transport: NodeTransportState,
3166 pub freshness: NodeFreshness,
3167 pub cursor: Option<NodeCursor>,
3168 pub inventory: Option<SlimNodeInventory>,
3169 pub last_attempt_unix_ms: Option<u64>,
3170 pub last_success_unix_ms: Option<u64>,
3171 pub consecutive_failures: u32,
3172 pub last_error: Option<SanitizedError>,
3173 pub gaps: Vec<NodeGap>,
3174 pub gaps_truncated: u64,
3175}
3176
3177#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3178pub struct HealthResponse {
3179 pub ok: bool,
3180 pub service: String,
3181 pub api_version: u16,
3182 pub pid: u32,
3183 pub version: String,
3184}
3185
3186#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3187pub struct ReadyResponse {
3188 pub ready: bool,
3189 pub api_version: u16,
3190 pub configured_nodes: usize,
3191 pub attempted_nodes: usize,
3192 pub online_nodes: usize,
3193 pub offline_nodes: usize,
3194 pub parked_nodes: usize,
3195}
3196
3197#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3198pub struct StatusResponse {
3199 pub api_version: u16,
3200 pub ready: bool,
3201 pub observed_at_unix_ms: u64,
3202 pub nodes: BTreeMap<NodeId, ObservedNode>,
3203}
3204
3205#[cfg(test)]
3206mod tests {
3207 use super::*;
3208 use gate4agent_node_protocol::{
3209 GitSnapshot, GitStatusEntry, GitWorktreeSnapshot, NodeFailureCode, NodeSnapshot,
3210 WorkspaceEntry, WorkspaceEntryKind, WorkspaceInspection, WorkspaceSnapshot,
3211 };
3212 use gate4agent_types::{
3213 AdapterBinding, AdapterFamily, AdapterId, AdapterVerification, AgentId, AgentInstanceId,
3214 CapabilitySnapshot, ControlEvent, ControlEventKind, ForegroundSnapshot, HistorySnapshot,
3215 OperationId, PreparedInputKind, ProviderEvent, ProviderInteractionOutcome,
3216 ProviderSessionIdentity, ProviderSessionKey, ProviderSnapshot, ProviderSource,
3217 PtyScreenState, ResumeSessionSummary, ResumeSnapshot, SessionGeneration, SessionSnapshot,
3218 SessionStatus, TerminalSize, TransportKind,
3219 };
3220
3221 #[test]
3222 fn c2_harness_mcp_projection_is_value_exact_and_sanitized() {
3223 let reservation_id = HarnessMcpReservationId::new(format!(
3224 "hmcpres_{}", "a".repeat(24),
3225 )).unwrap();
3226 let activation_digest = HarnessMcpActivationDigest::new(format!(
3227 "sha256:{}", "b".repeat(64),
3228 )).unwrap();
3229 let call_id = HarnessMcpCallId::new(format!("hmcpcall_{}", "c".repeat(24))).unwrap();
3230 let session = SessionAddress {
3231 workspace_id: WorkspaceId::new("primary").unwrap(),
3232 session: gate4agent_node_protocol::SessionKey {
3233 instance_id: AgentInstanceId(7),
3234 generation: SessionGeneration(1),
3235 },
3236 };
3237 let source = NodeEvent::HarnessMcpReadCall {
3238 reservation_id: reservation_id.clone(),
3239 activation_digest: activation_digest.clone(),
3240 record_id: SessionRecordId::new("record-a").unwrap(),
3241 session: session.clone(),
3242 call_id: call_id.clone(),
3243 request: HarnessMcpOpaquePayloadV1 {
3244 content_type: HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
3245 body: br#"{"kind":"context-get"}"#.to_vec(),
3246 },
3247 deadline_unix_ms: 4_000,
3248 };
3249 let projected = C2NodeEvent::from(&source);
3250 assert!(projected.requires_harness_mcp_proxy_capability());
3251 assert!(projected.harness_mcp_contract_is_valid_at(1_000));
3252 let json = serde_json::to_string(&projected).unwrap();
3253 assert!(!json.contains("g4ah3_"));
3254 assert!(!json.contains("endpoint"));
3255 assert!(!json.contains("path"));
3256 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), projected);
3257
3258 let response = NodeResponse::ReplyChunkAccepted {
3259 reservation_id,
3260 activation_digest,
3261 record_id: SessionRecordId::new("record-a").unwrap(),
3262 session,
3263 call_id,
3264 next_offset: 2,
3265 completed: true,
3266 };
3267 let projected_response = C2NodeResponse::from(&response);
3268 assert!(projected_response.requires_harness_mcp_proxy_capability());
3269 assert_eq!(
3270 serde_json::from_value::<C2NodeResponse>(
3271 serde_json::to_value(&projected_response).unwrap(),
3272 ).unwrap(),
3273 projected_response,
3274 );
3275
3276 let failure = C2NodeFailure::from(&NodeFailure {
3277 code: NodeFailureCode::ChunkOutOfOrder,
3278 message: "secret raw detail".to_owned(),
3279 });
3280 assert_eq!(failure.message, "harness MCP reply chunk out of order");
3281 assert!(!failure.message.contains("secret"));
3282 assert!(failure.requires_harness_mcp_proxy_capability());
3283 assert_eq!(C2_HARNESS_MCP_READ_PROXY_CAPABILITY, "harness-mcp-read-proxy-v1");
3284 }
3285
3286 fn host_path(value: impl Into<String>) -> OpaqueHostPath {
3287 OpaqueHostPath::utf8(value.into()).unwrap()
3288 }
3289
3290 fn repository_path(value: impl Into<String>) -> RepositoryPath {
3291 RepositoryPath::utf8(value.into()).unwrap()
3292 }
3293
3294 fn provider(value: &str) -> AgentId {
3295 AgentId::new(value).unwrap()
3296 }
3297
3298 fn fixture_session() -> SessionSnapshot {
3299 SessionSnapshot {
3300 instance_id: AgentInstanceId(7),
3301 agent_id: AgentId::new("codex").unwrap(),
3302 transport: TransportKind::Pty,
3303 generation: SessionGeneration(2),
3304 status: SessionStatus::Running,
3305 pending_operation: Some(OperationId(9)),
3306 pending_input: Some(PreparedInputKind::TerminalText),
3307 process_id: Some(1234),
3308 terminal_size: Some(TerminalSize { rows: 40, columns: 120 }),
3309 terminal_frame: None,
3310 terminal_stale: None,
3311 session_options: None,
3312 capabilities: CapabilitySnapshot::default(),
3313 history: HistorySnapshot::default(),
3314 resume: ResumeSnapshot::default(),
3315 foreground: ForegroundSnapshot::default(),
3316 provider: ProviderSnapshot::default(),
3317 screen_state: PtyScreenState::default(),
3318 }
3319 }
3320
3321 fn fixture_agent_progress() -> SessionAgentProgress {
3322 SessionAgentProgress {
3323 address: SessionAddress {
3324 workspace_id: WorkspaceId::new("primary").unwrap(),
3325 session: gate4agent_node_protocol::SessionKey {
3326 instance_id: AgentInstanceId(7),
3327 generation: SessionGeneration(2),
3328 },
3329 },
3330 progress: AgentProgressV1 {
3331 provider_sequence: 19,
3332 activity: ProviderActivity::Working,
3333 completed_turns: 3,
3334 usage: Some(AgentProgressUsageV1 {
3335 input_tokens: 101,
3336 output_tokens: 37,
3337 cache_read_tokens: 11,
3338 cache_write_tokens: 5,
3339 reasoning_tokens: 13,
3340 }),
3341 current: AgentProgressCurrentV1::Working,
3342 active_tool_labels: vec!["Shell".to_owned()],
3347 active_tool_count: 1,
3348 attention: None,
3349 subagent_count: 2,
3350 last_event_kind: Some(AgentProgressEventKindV1::ToolStarted),
3351 gap_count: 0,
3352 stale: false,
3353 truncated: false,
3354 },
3355 }
3356 }
3357
3358 #[test]
3359 fn c2_agent_progress_snapshot_roundtrip_is_exact_and_private() {
3360 assert_eq!(
3361 C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3362 "agent-progress-snapshot-v1",
3363 );
3364 assert_eq!(
3365 C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3366 gate4agent_node_protocol::NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3367 );
3368 let progress = fixture_agent_progress();
3369 let snapshot = C2NodeSnapshot {
3370 node_id: NodeId::new("node-a").unwrap(),
3371 enabled_providers: vec![provider("codex")],
3372 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
3373 workspaces: Vec::new(),
3374 session_records: Vec::new(),
3375 agent_progress: vec![progress.clone()],
3376 managed_worktrees: Vec::new(),
3377 launch_inventory: None,
3378 };
3379 let json = serde_json::to_string(&snapshot).unwrap();
3380 let decoded = serde_json::from_str::<C2NodeSnapshot>(&json).unwrap();
3381 assert_eq!(decoded.agent_progress, vec![progress]);
3382 for forbidden in [
3383 "prompt", "arguments", "contents", "transcript", "provider_session",
3384 "canonical_root", "executable",
3385 ] {
3386 assert!(!json.contains(forbidden), "agent progress leaked {forbidden}");
3387 }
3388
3389 let mut value = serde_json::to_value(snapshot).unwrap();
3390 value["agent_progress"][0]["progress"]["prompt"] =
3391 serde_json::json!("private prompt");
3392 assert!(serde_json::from_value::<C2NodeSnapshot>(value).is_err());
3393
3394 let empty = serde_json::json!({
3395 "node_id": "node-a",
3396 "enabled_providers": [],
3397 "workspaces": [],
3398 "session_records": [],
3399 "managed_worktrees": [],
3400 "launch_inventory": null
3401 });
3402 let empty = serde_json::from_value::<C2NodeSnapshot>(empty).unwrap();
3403 assert!(empty.agent_progress.is_empty());
3404 assert!(serde_json::to_value(empty).unwrap().get("agent_progress").is_none());
3405 }
3406
3407 #[test]
3408 fn c2_workspace_event_does_not_synthesize_agent_progress() {
3409 let event = C2NodeEvent::from(&NodeEvent::WorkspaceAdded {
3410 workspace: WorkspaceSnapshot {
3411 workspace_id: WorkspaceId::new("primary").unwrap(),
3412 canonical_root: host_path(r"C:\private\workspace"),
3413 sessions: vec![fixture_session()],
3414 worktree_service_mode: None,
3415 managed_worktree_profiles: None,
3416 },
3417 });
3418 let json = serde_json::to_string(&event).unwrap();
3419 assert!(!json.contains("agent_progress"));
3420 assert!(!json.contains("provider_sequence"));
3421 }
3422
3423 fn private_session_record() -> ManagedSessionRecord {
3424 ManagedSessionRecord {
3425 record_id: SessionRecordId::new("session-private").unwrap(),
3426 display_name: "release shepherd".to_owned(),
3427 provider: provider("codex"),
3428 mode: SessionMode::Pty,
3429 state: ManagedSessionState::Live,
3430 workspace_id: WorkspaceId::new("primary").unwrap(),
3431 canonical_root: host_path(r"C:\private\canonical-root"),
3432 provider_session: Some(ProviderSessionIdentity {
3433 key: ProviderSessionKey::SessionId,
3434 id: "private-provider-session-id".to_owned(),
3435 transcript_path: Some(r"C:\private\transcript-secret.jsonl".to_owned()),
3436 }),
3437 active_session: Some(SessionAddress {
3438 workspace_id: WorkspaceId::new("primary").unwrap(),
3439 session: gate4agent_node_protocol::SessionKey {
3440 instance_id: AgentInstanceId(41),
3441 generation: SessionGeneration(3),
3442 },
3443 }),
3444 environment_profile: Some(ResolvedEnvironmentProfileReceipt {
3445 profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
3446 profile_revision: SpawnEnvironmentProfileRevision::new(
3447 "local-default.2026-08",
3448 )
3449 .unwrap(),
3450 network_allowlist: None,
3451 browser_profile_id: None,
3452 }),
3453 bundle: None,
3454 context_id: None,
3455 context: None,
3456 exported_context: None,
3457 task_binding: None,
3458 created_at_unix_ms: 10,
3459 updated_at_unix_ms: 20,
3460 last_error: Some("private-error-with-secret-token".to_owned()),
3461 }
3462 }
3463
3464 fn context_pack_receipt() -> ResolvedContextPackReceipt {
3465 ResolvedContextPackReceipt {
3466 id: SpawnContextId::new("context-review-7").unwrap(),
3467 digest: SpawnContextDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap(),
3468 lineage: ContextPackLineageReceipt {
3469 source_node_id: NodeId::new("node-source").unwrap(),
3470 source_session: SessionAddress {
3471 workspace_id: WorkspaceId::new("source-workspace").unwrap(),
3472 session: gate4agent_node_protocol::SessionKey {
3473 instance_id: AgentInstanceId(17),
3474 generation: SessionGeneration(4),
3475 },
3476 },
3477 source_provider: provider("codex"),
3478 },
3479 source_message_count: 9,
3480 retained_message_count: 7,
3481 byte_len: 4096,
3482 truncated: true,
3483 }
3484 }
3485
3486 fn assert_private_record_fields_absent(json: &str) {
3487 assert!(!json.contains("canonical_root"));
3488 assert!(!json.contains("provider_session"));
3489 assert!(!json.contains("private-provider-session-id"));
3490 assert!(!json.contains("transcript_path"));
3491 assert!(!json.contains("transcript-secret.jsonl"));
3492 assert!(!json.contains("last_error"));
3493 assert!(!json.contains("private-error-with-secret-token"));
3494 }
3495
3496 #[test]
3497 fn c2_task_binding_projection_is_exact_safe_and_legacy_slim_omits_it() {
3498 let mut source = private_session_record();
3499 source.task_binding = Some(SessionTaskBindingV1 {
3500 revision: 3,
3501 task_id: Some(TaskId::from_nonce([3; 12])),
3502 changed_at_unix_ms: 15,
3503 });
3504 let projected = C2ManagedSessionRecord::from(&source);
3505 assert_eq!(projected.task_binding, source.task_binding);
3506 assert!(projected.task_binding_is_valid());
3507 let slim = serde_json::to_value(SlimManagedSessionRecord::from(&projected)).unwrap();
3508 assert!(slim.get("task_binding").is_none());
3509
3510 source.task_binding.as_mut().unwrap().changed_at_unix_ms = 21;
3511 assert!(C2ManagedSessionRecord::from(&source).task_binding.is_none());
3512
3513 let mut invalid = serde_json::to_value(projected).unwrap();
3514 invalid["task_binding"]["changed_at_unix_ms"] = serde_json::json!(9);
3515 assert!(serde_json::from_value::<C2ManagedSessionRecord>(invalid).is_err());
3516 }
3517
3518 fn routed_response(response: NodeResponse) -> RoutedNodeResponse {
3519 RoutedNodeResponse {
3520 node_id: NodeId::new("node-a").unwrap(),
3521 incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3522 response: Ok(C2NodeResponse::from(&response)),
3523 }
3524 }
3525
3526 fn control_node_event(event: ControlEventKind) -> NodeEvent {
3527 NodeEvent::Control {
3528 address: SessionAddress {
3529 workspace_id: WorkspaceId::new("primary").unwrap(),
3530 session: gate4agent_node_protocol::SessionKey {
3531 instance_id: AgentInstanceId(41),
3532 generation: SessionGeneration(3),
3533 },
3534 },
3535 event: ControlEvent {
3536 sequence: 12,
3537 command_id: None,
3538 instance_id: AgentInstanceId(41),
3539 generation: SessionGeneration(3),
3540 event,
3541 },
3542 }
3543 }
3544
3545 #[test]
3546 fn history_summary_event_projects_exactly_and_carries_counts_only() {
3547 let summary = gate4agent_node_protocol::SessionHistorySummaryV1 {
3548 message_count: 12,
3549 message_count_exact: true,
3550 completed_turn_count: Some(5),
3551 total_tokens: Some(4_000),
3552 modified_at_unix_ms: Some(1_786_000_000_000),
3553 };
3554 let source = NodeEvent::SessionRecordHistorySummarized {
3555 record_id: SessionRecordId::new("record-a").unwrap(),
3556 summary,
3557 };
3558 let projected = C2NodeEvent::from(&source);
3559 assert_eq!(
3560 projected,
3561 C2NodeEvent::SessionRecordHistorySummarized {
3562 record_id: SessionRecordId::new("record-a").unwrap(),
3563 summary,
3564 },
3565 );
3566 assert_eq!(projected, C2NodeEvent::from_node_event_with_control_detail(&source));
3567 let json = serde_json::to_string(&projected).unwrap();
3568 assert_eq!(
3569 json,
3570 r#"{"kind":"session-record-history-summarized","record_id":"record-a","summary":{"message_count":12,"message_count_exact":true,"completed_turn_count":5,"total_tokens":4000,"modified_at_unix_ms":1786000000000}}"#,
3571 );
3572 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), projected);
3573 }
3574
3575 fn tool_started_event() -> ControlEventKind {
3576 ControlEventKind::ProviderEvent {
3577 sequence: 19,
3578 source: provider_source(),
3579 source_sequence: 7,
3580 event: ProviderEvent::ToolStarted {
3581 id: "toolu_1".to_owned(),
3582 name: "Bash".to_owned(),
3583 input_json: r#"{"command":"cat private.txt"}"#.to_owned(),
3584 agent_id: None,
3585 },
3586 }
3587 }
3588
3589 #[test]
3590 fn control_detail_is_absent_by_default_and_sanitized_when_requested() {
3591 let source = control_node_event(tool_started_event());
3592
3593 let default = C2NodeEvent::from(&source);
3594 assert!(!default.requires_control_detail_capability());
3595 assert!(!serde_json::to_string(&default).unwrap().contains("detail"));
3596
3597 let detailed = C2NodeEvent::from_node_event_with_control_detail(&source);
3598 assert!(detailed.requires_control_detail_capability());
3599 let json = serde_json::to_string(&detailed).unwrap();
3600 assert!(json.contains("toolu_1"));
3601 assert!(json.contains("Bash"));
3602 assert!(!json.contains("private.txt"));
3603 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), detailed);
3604
3605 let mut stripped = detailed;
3606 stripped.strip_control_detail();
3607 assert_eq!(stripped, default);
3608 }
3609
3610 #[test]
3611 fn content_only_control_events_carry_their_kind_but_never_their_content() {
3612 let source = control_node_event(ControlEventKind::ProviderEvent {
3613 sequence: 20,
3614 source: provider_source(),
3615 source_sequence: 8,
3616 event: ProviderEvent::Text { text: "private words".to_owned(), is_delta: true },
3617 });
3618 let detailed = C2NodeEvent::from_node_event_with_control_detail(&source);
3619 assert!(detailed.requires_control_detail_capability());
3620 assert!(!serde_json::to_string(&detailed).unwrap().contains("private words"));
3621 }
3622
3623 #[test]
3624 fn control_plane_bookkeeping_has_no_detail_even_when_requested() {
3625 let source = control_node_event(ControlEventKind::Registered);
3626 let detailed = C2NodeEvent::from_node_event_with_control_detail(&source);
3627 assert!(!detailed.requires_control_detail_capability());
3628 assert_eq!(detailed, C2NodeEvent::from(&source));
3629 }
3630
3631 #[test]
3632 fn resync_control_detail_follows_the_same_rule_and_strips_to_the_default() {
3633 let events = vec![
3634 gate4agent_node_protocol::NodeEventEnvelope {
3635 sequence: 3,
3636 event: control_node_event(tool_started_event()),
3637 },
3638 gate4agent_node_protocol::NodeEventEnvelope {
3639 sequence: 4,
3640 event: control_node_event(ControlEventKind::Removed),
3641 },
3642 ];
3643 let snapshot = NodeSnapshot {
3644 node_id: NodeId::new("node-a").unwrap(),
3645 enabled_providers: Vec::new(),
3646 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
3647 workspaces: Vec::new(),
3648 session_records: Vec::new(),
3649 managed_worktrees: Vec::new(),
3650 launch_inventory: None,
3651 agent_progress: Vec::new(),
3652 };
3653 let source = NodeResponse::Resync {
3654 event_sequence: 4,
3655 oldest_available_sequence: 1,
3656 snapshot,
3657 events,
3658 };
3659 let default = C2NodeResponse::from(&source);
3660 assert!(!default.requires_control_detail_capability());
3661 let mut detailed = C2NodeResponse::from_node_response_with_control_detail(&source);
3662 assert!(detailed.requires_control_detail_capability());
3663 detailed.strip_control_detail();
3664 assert_eq!(detailed, default);
3665 }
3666
3667 fn routed_control_event(event: ControlEventKind) -> RoutedNodeEvent {
3668 let address = SessionAddress {
3669 workspace_id: WorkspaceId::new("primary").unwrap(),
3670 session: gate4agent_node_protocol::SessionKey {
3671 instance_id: AgentInstanceId(41),
3672 generation: SessionGeneration(3),
3673 },
3674 };
3675 RoutedNodeEvent {
3676 node_id: NodeId::new("node-a").unwrap(),
3677 cursor: NodeCursor {
3678 incarnation_id: NodeIncarnationId::from_bytes([8; 16]),
3679 sequence: 9,
3680 },
3681 event: C2NodeEvent::from(&NodeEvent::Control {
3682 address,
3683 event: ControlEvent {
3684 sequence: 12,
3685 command_id: None,
3686 instance_id: AgentInstanceId(41),
3687 generation: SessionGeneration(3),
3688 event,
3689 },
3690 }),
3691 }
3692 }
3693
3694 fn provider_source() -> ProviderSource {
3695 ProviderSource {
3696 family: AdapterFamily::PtySemantic,
3697 binding: AdapterBinding::new(
3698 AdapterId::new("codex").unwrap(),
3699 "fixture/v1",
3700 AdapterVerification::SyntheticFixture,
3701 ).unwrap(),
3702 }
3703 }
3704
3705 fn provider_contract_manifest() -> (
3706 Vec<ProviderContractSupport>,
3707 Vec<ProviderAdapterContractSupport>,
3708 ) {
3709 (
3710 vec![ProviderContractSupport {
3711 provider: provider("codex"),
3712 revision: ProviderContractRevision::new("codex.2026-08").unwrap(),
3713 }],
3714 vec![ProviderAdapterContractSupport {
3715 provider: provider("codex"),
3716 family: AdapterFamily::PtySemantic,
3717 adapter_id: AdapterId::new("codex").unwrap(),
3718 revision: AdapterContractRevision::new("pty-semantic.2026-08").unwrap(),
3719 }],
3720 )
3721 }
3722
3723 fn c2_compatibility_support(
3724 capabilities: Vec<CapabilityId>,
3725 ) -> C2ControlCompatibilitySupport {
3726 C2ControlCompatibilitySupport {
3727 build_stamp: BUILD_STAMP.to_owned(),
3728 capabilities,
3729 host: HostDescriptor {
3730 operating_system: OperatingSystemId::new("darwin").unwrap(),
3731 architecture: ArchitectureId::new("aarch64").unwrap(),
3732 },
3733 path_semantics: PathSemantics {
3734 style: PathStyle::Posix,
3735 encoding: PathEncoding::Utf8,
3736 },
3737 }
3738 }
3739
3740 #[test]
3753 fn the_legacy_client_hello_json_carries_exactly_these_keys_in_this_order() {
3754 let hello = C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]);
3755 let json = serde_json::to_string(&hello).unwrap();
3756 let owned = format!(
3757 concat!(
3758 r#"{{"build_stamp":"{}","client_nonce":["#,
3759 "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,",
3760 "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}}"
3761 ),
3762 BUILD_STAMP,
3763 );
3764 let expected = owned.as_str();
3765
3766 assert_eq!(json, expected);
3767 assert!(!json.contains("compatibility"));
3771 assert_eq!(
3772 serde_json::from_str::<C2ClientHello>(expected).unwrap(),
3773 hello,
3774 );
3775 }
3776
3777 fn build_stamp_hex() -> String {
3791 let mut bytes = Vec::with_capacity(2 + BUILD_STAMP.len());
3792 bytes.extend_from_slice(&(BUILD_STAMP.len() as u16).to_le_bytes());
3793 bytes.extend_from_slice(BUILD_STAMP.as_bytes());
3794 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
3795 }
3796
3797 #[test]
3798 fn c2_compatibility_legacy_server_json_is_byte_equivalent() {
3799 #[derive(Serialize)]
3800 struct LegacyChallenge {
3801 build_stamp: String,
3802 server_nonce: [u8; C2_AUTH_NONCE_BYTES],
3803 server_proof: [u8; C2_AUTH_PROOF_BYTES],
3804 }
3805
3806 #[derive(Serialize)]
3807 struct LegacyHello<'a> {
3808 build_stamp: String,
3809 connection_id: u64,
3810 status: &'a StatusResponse,
3811 }
3812
3813 let challenge = C2ServerChallenge {
3814 build_stamp: BUILD_STAMP.to_owned(),
3815 server_nonce: [1; C2_AUTH_NONCE_BYTES],
3816 server_proof: [2; C2_AUTH_PROOF_BYTES],
3817 compatibility: None,
3818 };
3819 let legacy_challenge = LegacyChallenge {
3820 build_stamp: BUILD_STAMP.to_owned(),
3821 server_nonce: [1; C2_AUTH_NONCE_BYTES],
3822 server_proof: [2; C2_AUTH_PROOF_BYTES],
3823 };
3824 assert_eq!(
3825 serde_json::to_vec(&challenge).unwrap(),
3826 serde_json::to_vec(&legacy_challenge).unwrap(),
3827 );
3828
3829 let status = StatusResponse {
3830 api_version: C2_API_VERSION,
3831 ready: true,
3832 observed_at_unix_ms: 7,
3833 nodes: BTreeMap::new(),
3834 };
3835 let hello = C2Hello {
3836 build_stamp: BUILD_STAMP.to_owned(),
3837 connection_id: 11,
3838 status: status.clone(),
3839 compatibility: None,
3840 };
3841 let legacy_hello = LegacyHello {
3842 build_stamp: BUILD_STAMP.to_owned(),
3843 connection_id: 11,
3844 status: &status,
3845 };
3846 assert_eq!(
3847 serde_json::to_vec(&hello).unwrap(),
3848 serde_json::to_vec(&legacy_hello).unwrap(),
3849 );
3850 }
3851
3852 #[test]
3853 fn c2_terminal_frame_event_projection_wire_contract_is_exact() {
3854 let source = NodeEvent::TerminalFrame {
3855 address: SessionAddress {
3856 workspace_id: WorkspaceId::new("primary").unwrap(),
3857 session: gate4agent_node_protocol::SessionKey {
3858 instance_id: AgentInstanceId(7),
3859 generation: SessionGeneration(3),
3860 },
3861 },
3862 frame: TerminalFrame {
3863 sequence: 11,
3864 size: TerminalSize { rows: 24, columns: 80 },
3865 cursor_row: 2,
3866 cursor_column: 4,
3867 contents: "ready".to_owned(),
3868 formatted: b"ready".to_vec(),
3869 scrollback_formatted: vec![b"previous".to_vec()],
3870 alternate_screen: false,
3871 mouse_protocol_enabled: false,
3872 mouse_protocol_encoding:
3873 gate4agent_types::TerminalMouseProtocolEncoding::Default,
3874 produced_at_unix_ms: 0,
3875 screen_state: gate4agent_types::PtyScreenState::default(),
3876 bracketed_paste: None,
3877 },
3878 };
3879 let event = C2NodeEvent::from(&source);
3880 let json = serde_json::to_string(&event).unwrap();
3881 assert_eq!(
3882 json,
3883 r#"{"kind":"terminal-frame","address":{"workspace_id":"primary","session":{"instance_id":7,"generation":3}},"frame":{"sequence":11,"size":{"rows":24,"columns":80},"cursor_row":2,"cursor_column":4,"contents":"ready","formatted":[114,101,97,100,121],"scrollback_formatted":[[112,114,101,118,105,111,117,115]],"alternate_screen":false,"mouse_protocol_enabled":false,"mouse_protocol_encoding":"default","produced_at_unix_ms":0,"screen_state":{"kind":"unknown"}}}"#,
3884 );
3885 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), event);
3886 }
3887
3888 #[test]
3894 fn c2_agent_stream_projection_passes_the_blocked_chunk_through_unchanged() {
3895 let address = SessionAddress {
3896 workspace_id: WorkspaceId::new("primary").unwrap(),
3897 session: gate4agent_node_protocol::SessionKey {
3898 instance_id: AgentInstanceId(7),
3899 generation: SessionGeneration(3),
3900 },
3901 };
3902 let chunk = AgentStreamChunkV1 {
3903 source_sequence: 11,
3904 kind: gate4agent_node_protocol::AgentStreamChunkKindV1::Blocked {
3905 correlation_id: None,
3906 tool_class: "Write".to_owned(),
3907 authority: gate4agent_node_protocol::BlockAuthorityV1::HostGate,
3908 reason_kind: None,
3909 reason: "blocked by dangerous-command gate: rule=filesystem-wipe".to_owned(),
3910 help: None,
3911 },
3912 };
3913 let source = NodeEvent::AgentStream { address: address.clone(), chunk: chunk.clone() };
3914 let projected = C2NodeEvent::from(&source);
3915 assert_eq!(projected, C2NodeEvent::AgentStream { address, chunk: chunk.clone() });
3916 let json = serde_json::to_string(&projected).unwrap();
3917 assert_eq!(
3918 json,
3919 r#"{"kind":"agent-stream","address":{"workspace_id":"primary","session":{"instance_id":7,"generation":3}},"chunk":{"source_sequence":11,"kind":{"kind":"blocked","correlation_id":null,"tool_class":"Write","authority":"host-gate","reason_kind":null,"reason":"blocked by dangerous-command gate: rule=filesystem-wipe","help":null}}}"#,
3920 );
3921 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), projected);
3922 }
3923
3924 #[test]
3925 fn c2_terminal_frame_events_capability_is_optional_and_auth_bound_exactly() {
3926 assert_eq!(
3927 C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
3928 "terminal-frame-events-v1",
3929 );
3930 assert_eq!(
3931 C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
3932 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
3933 );
3934 let capability = CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).unwrap();
3935 let support = c2_compatibility_support(vec![capability.clone()],
3936 );
3937 assert!(support
3938 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
3939 .unwrap()
3940 .capabilities
3941 .is_empty());
3942
3943 let offer = ClientCompatibilityOffer {
3944 build_stamp: BUILD_STAMP.to_owned(),
3945 capabilities: vec![capability.clone()],
3946 state_schema: None,
3947 };
3948 let selected = support
3949 .negotiate(&C2ClientHello::negotiating(
3950 [0; C2_AUTH_NONCE_BYTES],
3951 offer.clone(),
3952 ))
3953 .unwrap();
3954 assert_eq!(selected.capabilities, vec![capability]);
3955 let transcript = c2_bound_auth_transcript(
3956 C2AuthDirection::Server,
3957 &[0x11; C2_AUTH_NONCE_BYTES],
3958 &[0x22; C2_AUTH_NONCE_BYTES],
3959 &offer,
3960 &selected,
3961 )
3962 .unwrap();
3963 let hex = transcript
3964 .iter()
3965 .map(|byte| format!("{byte:02x}"))
3966 .collect::<String>();
3967 let stamp = build_stamp_hex();
3968 assert_eq!(
3969 hex,
3970 format!(
3971 concat!(
3972 "67617465346167656e742d63322d636f6e74726f6c2d617574682d76322d636f6d7061746962696c69747900",
3973 "{stamp}",
3974 "01",
3975 "1111111111111111111111111111111111111111111111111111111111111111",
3976 "2222222222222222222222222222222222222222222222222222222222222222",
3977 "6f6666657200",
3978 "{stamp}",
3979 "0100",
3980 "18007465726d696e616c2d6672616d652d6576656e74732d763100",
3981 "73656c656374656400",
3982 "{stamp}",
3983 "0100",
3984 "18007465726d696e616c2d6672616d652d6576656e74732d7631",
3985 "060064617277696e0700616172636836340201",
3986 ),
3987 stamp = stamp,
3988 ),
3989 );
3990 }
3991
3992 #[test]
3993 fn c2_acp_control_capability_is_optional_and_negotiable() {
3994 assert_eq!(C2_ACP_CONTROL_CAPABILITY, "acp-control-v1");
3995 assert_eq!(C2_ACP_CONTROL_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY);
3996 let capability = CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).unwrap();
3997 let support = c2_compatibility_support(vec![capability.clone()],
3998 );
3999 assert!(support
4000 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4001 .unwrap()
4002 .capabilities
4003 .is_empty());
4004
4005 let offer = ClientCompatibilityOffer {
4006 build_stamp: BUILD_STAMP.to_owned(),
4007 capabilities: vec![capability.clone()],
4008 state_schema: None,
4009 };
4010 let selected = support
4011 .negotiate(&C2ClientHello::negotiating(
4012 [0; C2_AUTH_NONCE_BYTES],
4013 offer,
4014 ))
4015 .unwrap();
4016 assert_eq!(selected.capabilities, vec![capability]);
4017 }
4018
4019 #[test]
4020 fn c2_spawn_spec_defaults_overrides_capability_is_optional_and_auth_bound_exactly() {
4021 assert_eq!(
4022 C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
4023 "spawn-spec.defaults-overrides-v1",
4024 );
4025 assert_eq!(
4026 C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
4027 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
4028 );
4029 let capability =
4030 CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
4031 let support = c2_compatibility_support(vec![capability.clone()],
4032 );
4033 assert!(support
4034 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4035 .unwrap()
4036 .capabilities
4037 .is_empty());
4038
4039 let offer = ClientCompatibilityOffer {
4040 build_stamp: BUILD_STAMP.to_owned(),
4041 capabilities: vec![capability.clone()],
4042 state_schema: None,
4043 };
4044 let selected = support
4045 .negotiate(&C2ClientHello::negotiating(
4046 [0; C2_AUTH_NONCE_BYTES],
4047 offer.clone(),
4048 ))
4049 .unwrap();
4050 assert_eq!(selected.capabilities, vec![capability]);
4051 let bound = c2_bound_auth_transcript(
4052 C2AuthDirection::Server,
4053 &[0x11; C2_AUTH_NONCE_BYTES],
4054 &[0x22; C2_AUTH_NONCE_BYTES],
4055 &offer,
4056 &selected,
4057 )
4058 .unwrap();
4059 let without_spawn_spec = c2_bound_auth_transcript(
4060 C2AuthDirection::Server,
4061 &[0x11; C2_AUTH_NONCE_BYTES],
4062 &[0x22; C2_AUTH_NONCE_BYTES],
4063 &ClientCompatibilityOffer {
4064 build_stamp: offer.build_stamp.clone(),
4065 capabilities: Vec::new(),
4066 state_schema: None,
4067 },
4068 &NegotiatedC2ControlCompatibility {
4069 build_stamp: selected.build_stamp.clone(),
4070 capabilities: Vec::new(),
4071 host: selected.host.clone(),
4072 path_semantics: selected.path_semantics.clone(),
4073 },
4074 )
4075 .unwrap();
4076 assert_ne!(bound, without_spawn_spec);
4077 assert!(bound.windows(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY.len()).any(
4078 |window| window == C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY.as_bytes()
4079 ));
4080 }
4081
4082 #[test]
4083 fn c2_spawn_profile_revision_capability_is_optional_and_auth_bound_exactly() {
4084 assert_eq!(
4085 C2_SPAWN_PROFILE_REVISION_CAPABILITY,
4086 "spawn-spec.profile-revision-v1",
4087 );
4088 assert_eq!(
4089 C2_SPAWN_PROFILE_REVISION_CAPABILITY,
4090 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
4091 );
4092 let capability =
4093 CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
4094 let support = c2_compatibility_support(vec![capability.clone()],
4095 );
4096 assert!(support
4097 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4098 .unwrap()
4099 .capabilities
4100 .is_empty());
4101
4102 let offer = ClientCompatibilityOffer {
4103 build_stamp: BUILD_STAMP.to_owned(),
4104 capabilities: vec![capability.clone()],
4105 state_schema: None,
4106 };
4107 let selected = support
4108 .negotiate(&C2ClientHello::negotiating(
4109 [0; C2_AUTH_NONCE_BYTES],
4110 offer.clone(),
4111 ))
4112 .unwrap();
4113 assert_eq!(selected.capabilities, vec![capability]);
4114 let bound = c2_bound_auth_transcript(
4115 C2AuthDirection::Server,
4116 &[0x11; C2_AUTH_NONCE_BYTES],
4117 &[0x22; C2_AUTH_NONCE_BYTES],
4118 &offer,
4119 &selected,
4120 )
4121 .unwrap();
4122 assert!(bound.windows(C2_SPAWN_PROFILE_REVISION_CAPABILITY.len()).any(
4123 |window| window == C2_SPAWN_PROFILE_REVISION_CAPABILITY.as_bytes()
4124 ));
4125 }
4126
4127 #[test]
4128 fn c2_worktree_selection_capability_is_optional_and_auth_bound_exactly() {
4129 assert_eq!(C2_WORKTREE_SELECTION_CAPABILITY, "worktree-selection-v1");
4130 assert_eq!(
4131 C2_WORKTREE_SELECTION_CAPABILITY,
4132 NODE_WORKTREE_SELECTION_CAPABILITY,
4133 );
4134 let capability = CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).unwrap();
4135 let support = c2_compatibility_support(vec![capability.clone()],
4136 );
4137 assert!(support
4138 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4139 .unwrap()
4140 .capabilities
4141 .is_empty());
4142
4143 let offer = ClientCompatibilityOffer {
4144 build_stamp: BUILD_STAMP.to_owned(),
4145 capabilities: vec![capability.clone()],
4146 state_schema: None,
4147 };
4148 let selected = support
4149 .negotiate(&C2ClientHello::negotiating(
4150 [0; C2_AUTH_NONCE_BYTES],
4151 offer.clone(),
4152 ))
4153 .unwrap();
4154 assert_eq!(selected.capabilities, vec![capability]);
4155 let bound = c2_bound_auth_transcript(
4156 C2AuthDirection::Server,
4157 &[0x11; C2_AUTH_NONCE_BYTES],
4158 &[0x22; C2_AUTH_NONCE_BYTES],
4159 &offer,
4160 &selected,
4161 )
4162 .unwrap();
4163 let without_worktree_selection = c2_bound_auth_transcript(
4164 C2AuthDirection::Server,
4165 &[0x11; C2_AUTH_NONCE_BYTES],
4166 &[0x22; C2_AUTH_NONCE_BYTES],
4167 &ClientCompatibilityOffer {
4168 build_stamp: offer.build_stamp.clone(),
4169 capabilities: Vec::new(),
4170 state_schema: None,
4171 },
4172 &NegotiatedC2ControlCompatibility {
4173 build_stamp: selected.build_stamp.clone(),
4174 capabilities: Vec::new(),
4175 host: selected.host.clone(),
4176 path_semantics: selected.path_semantics.clone(),
4177 },
4178 )
4179 .unwrap();
4180 assert_ne!(bound, without_worktree_selection);
4181 assert!(bound.windows(C2_WORKTREE_SELECTION_CAPABILITY.len()).any(
4182 |window| window == C2_WORKTREE_SELECTION_CAPABILITY.as_bytes()
4183 ));
4184 }
4185
4186 #[test]
4187 fn c2_managed_worktree_capability_is_optional_and_auth_bound_exactly() {
4188 assert_eq!(
4189 C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
4190 "managed-worktree-lifecycle-v1",
4191 );
4192 assert_eq!(
4193 C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
4194 "managed-worktree-spawn-v2",
4195 );
4196 assert_eq!(
4197 C2NodeFailure::from(&NodeFailure {
4198 code: NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
4199 message: "private node detail".to_owned(),
4200 })
4201 .message,
4202 "managed worktree profile revision mismatch",
4203 );
4204 let capability =
4205 CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).unwrap();
4206 let support = c2_compatibility_support(vec![capability.clone()],
4207 );
4208 let offer = ClientCompatibilityOffer {
4209 build_stamp: BUILD_STAMP.to_owned(),
4210 capabilities: vec![capability],
4211 state_schema: None,
4212 };
4213 let selected = support
4214 .negotiate(&C2ClientHello::negotiating(
4215 [0; C2_AUTH_NONCE_BYTES],
4216 offer.clone(),
4217 ))
4218 .unwrap();
4219 let bound = c2_bound_auth_transcript(
4220 C2AuthDirection::Server,
4221 &[0x11; C2_AUTH_NONCE_BYTES],
4222 &[0x22; C2_AUTH_NONCE_BYTES],
4223 &offer,
4224 &selected,
4225 )
4226 .unwrap();
4227 assert!(bound
4228 .windows(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY.len())
4229 .any(|window| {
4230 window == C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY.as_bytes()
4231 }));
4232 assert!(support
4233 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4234 .unwrap()
4235 .capabilities
4236 .is_empty());
4237 }
4238
4239 #[test]
4240 fn c2_child_environment_profile_capability_is_optional_and_auth_bound_exactly() {
4241 assert_eq!(
4242 C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
4243 "child-environment-profile-v1",
4244 );
4245 let capability =
4246 CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).unwrap();
4247 let support = c2_compatibility_support(vec![capability.clone()],
4248 );
4249 let offer = ClientCompatibilityOffer {
4250 build_stamp: BUILD_STAMP.to_owned(),
4251 capabilities: vec![capability],
4252 state_schema: None,
4253 };
4254 let selected = support
4255 .negotiate(&C2ClientHello::negotiating(
4256 [0; C2_AUTH_NONCE_BYTES],
4257 offer.clone(),
4258 ))
4259 .unwrap();
4260 let bound = c2_bound_auth_transcript(
4261 C2AuthDirection::Server,
4262 &[0x11; C2_AUTH_NONCE_BYTES],
4263 &[0x22; C2_AUTH_NONCE_BYTES],
4264 &offer,
4265 &selected,
4266 )
4267 .unwrap();
4268 assert!(bound
4269 .windows(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.len())
4270 .any(|window| {
4271 window == C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.as_bytes()
4272 }));
4273 assert!(support
4274 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4275 .unwrap()
4276 .capabilities
4277 .is_empty());
4278 }
4279
4280 #[test]
4281 fn c2_session_bundle_materialization_is_auth_bound_and_projected_as_opaque_metadata() {
4282 assert_eq!(
4283 C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
4284 "session-bundle-materialization-v1",
4285 );
4286 assert_eq!(
4287 C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
4288 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
4289 );
4290 let capability =
4291 CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
4292 let support = c2_compatibility_support(vec![capability.clone()],
4293 );
4294 let offer = ClientCompatibilityOffer {
4295 build_stamp: BUILD_STAMP.to_owned(),
4296 capabilities: vec![capability.clone()],
4297 state_schema: None,
4298 };
4299 let selected = support
4300 .negotiate(&C2ClientHello::negotiating(
4301 [0; C2_AUTH_NONCE_BYTES],
4302 offer.clone(),
4303 ))
4304 .unwrap();
4305 assert_eq!(selected.capabilities, vec![capability]);
4306 let bound = c2_bound_auth_transcript(
4307 C2AuthDirection::Server,
4308 &[0x11; C2_AUTH_NONCE_BYTES],
4309 &[0x22; C2_AUTH_NONCE_BYTES],
4310 &offer,
4311 &selected,
4312 )
4313 .unwrap();
4314 assert!(bound
4315 .windows(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.len())
4316 .any(|window| {
4317 window == C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.as_bytes()
4318 }));
4319
4320 let mut record = private_session_record();
4321 record.bundle = Some(ResolvedBundleReceipt {
4322 id: SpawnBundleId::new("review-bundle").unwrap(),
4323 revision: SpawnBundleRevision::new("review-bundle.r1").unwrap(),
4324 digest: SpawnBundleDigest::new(format!("sha256:{}", "a".repeat(64)))
4325 .unwrap(),
4326 });
4327 let projected = C2ManagedSessionRecord::from(&record);
4328 let json = serde_json::to_string(&projected).unwrap();
4329 assert!(json.contains(
4330 r#""bundle":{"id":"review-bundle","revision":"review-bundle.r1","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#,
4331 ));
4332 assert_private_record_fields_absent(&json);
4333 assert!(C2NodeEvent::SessionRecordUpserted { record: projected }
4334 .requires_session_bundle_materialization_capability());
4335 }
4336
4337 #[test]
4338 fn c2_history_context_pack_capability_is_optional_and_auth_bound_exactly() {
4339 assert_eq!(C2_HISTORY_CONTEXT_PACK_CAPABILITY, "history-context-pack-v1");
4340 assert_eq!(
4341 C2_HISTORY_CONTEXT_PACK_CAPABILITY,
4342 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
4343 );
4344 let capability = CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
4345 let support = c2_compatibility_support(vec![capability.clone()],
4346 );
4347 assert!(support
4348 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4349 .unwrap()
4350 .capabilities
4351 .is_empty());
4352
4353 let offer = ClientCompatibilityOffer {
4354 build_stamp: BUILD_STAMP.to_owned(),
4355 capabilities: vec![capability.clone()],
4356 state_schema: None,
4357 };
4358 let selected = support
4359 .negotiate(&C2ClientHello::negotiating(
4360 [0; C2_AUTH_NONCE_BYTES],
4361 offer.clone(),
4362 ))
4363 .unwrap();
4364 assert_eq!(selected.capabilities, vec![capability]);
4365 let bound = c2_bound_auth_transcript(
4366 C2AuthDirection::Server,
4367 &[0x11; C2_AUTH_NONCE_BYTES],
4368 &[0x22; C2_AUTH_NONCE_BYTES],
4369 &offer,
4370 &selected,
4371 )
4372 .unwrap();
4373 assert!(bound
4374 .windows(C2_HISTORY_CONTEXT_PACK_CAPABILITY.len())
4375 .any(|window| window == C2_HISTORY_CONTEXT_PACK_CAPABILITY.as_bytes()));
4376
4377 for code in [
4378 NodeFailureCode::UnknownContextPack,
4379 NodeFailureCode::ContextPackBusy,
4380 NodeFailureCode::ContextPackMaterializationFailed,
4381 ] {
4382 let failure = C2NodeFailure::from(&NodeFailure {
4383 code,
4384 message: "private context backend detail".to_owned(),
4385 });
4386 assert!(failure.requires_history_context_pack_capability());
4387 assert!(!failure.message.contains("private"));
4388 }
4389 assert!(!C2NodeFailure::from(&NodeFailure {
4390 code: NodeFailureCode::UnknownSession,
4391 message: "private session backend detail".to_owned(),
4392 })
4393 .requires_history_context_pack_capability());
4394 }
4395
4396 #[test]
4397 fn c2_history_context_pack_requests_are_exact_and_capability_gated() {
4398 let session = SessionAddress {
4399 workspace_id: WorkspaceId::new("primary").unwrap(),
4400 session: gate4agent_node_protocol::SessionKey {
4401 instance_id: AgentInstanceId(41),
4402 generation: SessionGeneration(3),
4403 },
4404 };
4405 let requests = [
4406 (
4407 NodeRequest::DiscoverHistory { session: session.clone(), limit: 4 },
4408 r#"{"kind":"discover-history","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"limit":4}"#,
4409 ),
4410 (
4411 NodeRequest::LoadHistory {
4412 session: session.clone(),
4413 candidate_id: "candidate-7".to_owned(),
4414 },
4415 r#"{"kind":"load-history","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"candidate_id":"candidate-7"}"#,
4416 ),
4417 (
4418 NodeRequest::ExportContextPack { session },
4419 r#"{"kind":"export-context-pack","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}}}"#,
4420 ),
4421 (
4422 NodeRequest::ForgetContextPack {
4423 context_id: SpawnContextId::new("context-review-7").unwrap(),
4424 },
4425 r#"{"kind":"forget-context-pack","context_id":"context-review-7"}"#,
4426 ),
4427 ];
4428
4429 for (request, expected) in requests {
4430 assert_eq!(request.required_capability(), Some(C2_HISTORY_CONTEXT_PACK_CAPABILITY));
4431 assert!(request.requires_history_context_pack_capability());
4432 assert_eq!(serde_json::to_string(&request).unwrap(), expected);
4433 assert_eq!(serde_json::from_str::<NodeRequest>(expected).unwrap(), request);
4434 }
4435 assert!(serde_json::from_str::<NodeRequest>(
4436 r#"{"kind":"export-context-pack","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"path":"private.jsonl"}"#,
4437 )
4438 .is_err());
4439 }
4440
4441 #[test]
4442 fn c2_session_record_context_export_is_exact_correlated_and_private() {
4443 assert_eq!(
4444 C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
4445 "session-record-context-export-v1",
4446 );
4447 let session = SessionAddress {
4448 workspace_id: WorkspaceId::new("primary").unwrap(),
4449 session: gate4agent_node_protocol::SessionKey {
4450 instance_id: AgentInstanceId(41),
4451 generation: SessionGeneration(3),
4452 },
4453 };
4454 let record_id = SessionRecordId::new("record-context-41").unwrap();
4455 let request = NodeRequest::ExportContextPackForSessionRecord {
4456 record_id: record_id.clone(),
4457 session: session.clone(),
4458 };
4459 assert_eq!(
4460 request.required_capability(),
4461 Some(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY),
4462 );
4463 assert!(request.requires_history_context_pack_capability());
4464 let request_json = serde_json::to_string(&request).unwrap();
4465 assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
4466
4467 let response = C2NodeResponse::from(
4468 &NodeResponse::ContextPackForSessionRecordExported {
4469 record_id,
4470 session,
4471 context: context_pack_receipt(),
4472 },
4473 );
4474 assert!(response.requires_history_context_pack_capability());
4475 assert!(response.requires_session_record_context_export_capability());
4476 let response_json = serde_json::to_string(&response).unwrap();
4477 assert_eq!(
4478 serde_json::from_str::<C2NodeResponse>(&response_json).unwrap(),
4479 response,
4480 );
4481 for private in [
4482 "candidate_id",
4483 "session_id_hint",
4484 "provider_session",
4485 "messages",
4486 "model",
4487 "path",
4488 ] {
4489 assert!(!request_json.contains(private), "request leaked {private}");
4490 assert!(!response_json.contains(private), "response leaked {private}");
4491 }
4492 }
4493
4494 #[test]
4495 fn c2_context_metadata_projects_through_records_snapshots_events_and_responses() {
4496 let context = context_pack_receipt();
4497 let mut source = private_session_record();
4498 source.context_id = Some(context.id.clone());
4499 source.context = Some(context.clone());
4500 let projected = C2ManagedSessionRecord::from(&source);
4501 assert!(projected.context_binding_is_valid());
4502 assert!(projected.requires_history_context_pack_capability());
4503 assert_eq!(projected.context_id.as_ref(), Some(&context.id));
4504 assert_eq!(projected.context.as_ref(), Some(&context));
4505
4506 let record_json = serde_json::to_string(&projected).unwrap();
4507 assert_private_record_fields_absent(&record_json);
4508 assert_eq!(
4509 serde_json::from_str::<C2ManagedSessionRecord>(&record_json).unwrap(),
4510 projected,
4511 );
4512 let mut mismatched = serde_json::from_str::<serde_json::Value>(&record_json).unwrap();
4513 mismatched["context_id"] = serde_json::json!("context-other");
4514 assert!(serde_json::from_value::<C2ManagedSessionRecord>(mismatched).is_err());
4515
4516 let snapshot = C2NodeSnapshot::from(&NodeSnapshot {
4517 node_id: NodeId::new("node-a").unwrap(),
4518 enabled_providers: vec![provider("codex")],
4519 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
4520 workspaces: Vec::new(),
4521 session_records: vec![source.clone()],
4522 managed_worktrees: Vec::new(),
4523 launch_inventory: None,
4524 agent_progress: Vec::new(),
4525 });
4526 assert!(snapshot.requires_history_context_pack_capability());
4527 let event = C2NodeEvent::from(&NodeEvent::SessionRecordUpserted {
4528 record: source.clone(),
4529 });
4530 assert!(event.requires_history_context_pack_capability());
4531
4532 let updated = C2NodeResponse::from(&NodeResponse::SessionRecordUpdated {
4533 record: source,
4534 });
4535 assert!(updated.requires_history_context_pack_capability());
4536 assert!(serde_json::to_string(&updated).unwrap().contains("context-review-7"));
4537
4538 let exported = C2NodeResponse::from(&NodeResponse::ContextPackExported {
4539 context: context.clone(),
4540 });
4541 assert!(exported.requires_history_context_pack_capability());
4542 let json = serde_json::to_string(&exported).unwrap();
4543 assert!(json.contains(r#""kind":"context-pack-exported""#));
4544 assert!(json.contains(r#""source_message_count":9"#));
4545 assert!(!json.contains("messages"));
4546 assert!(!json.contains("path"));
4547 assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), exported);
4548 }
4549
4550 #[test]
4551 fn c2_exported_context_projects_through_records_and_is_provider_correlated() {
4552 let exported = context_pack_receipt();
4553 let mut source = private_session_record();
4554 source.exported_context = Some(exported.clone());
4555 let projected = C2ManagedSessionRecord::from(&source);
4556 assert!(projected.exported_context_is_valid());
4557 assert_eq!(projected.exported_context.as_ref(), Some(&exported));
4558
4559 let record_json = serde_json::to_string(&projected).unwrap();
4560 assert_private_record_fields_absent(&record_json);
4561 assert_eq!(
4562 serde_json::from_str::<C2ManagedSessionRecord>(&record_json).unwrap(),
4563 projected,
4564 );
4565
4566 let mut mismatched_provider =
4567 serde_json::from_str::<serde_json::Value>(&record_json).unwrap();
4568 mismatched_provider["exported_context"]["lineage"]["source_provider"] =
4569 serde_json::json!("claude");
4570 assert!(serde_json::from_value::<C2ManagedSessionRecord>(mismatched_provider).is_err());
4571 }
4572
4573 #[test]
4574 fn c2_history_responses_are_exact_bounded_metadata_without_messages_or_paths() {
4575 let session = SessionAddress {
4576 workspace_id: WorkspaceId::new("primary").unwrap(),
4577 session: gate4agent_node_protocol::SessionKey {
4578 instance_id: AgentInstanceId(41),
4579 generation: SessionGeneration(3),
4580 },
4581 };
4582 let discovered = C2NodeResponse::from(&NodeResponse::HistoryDiscovered {
4583 session: session.clone(),
4584 candidates: vec![HistoryCandidateSummary {
4585 id: "candidate-7".to_owned(),
4586 session_id_hint: "session-hint-7".to_owned(),
4587 modified_at_unix_ms: Some(77),
4588 }],
4589 });
4590 let loaded = C2NodeResponse::from(&NodeResponse::HistoryLoaded {
4591 session,
4592 session_id: "session-loaded-7".to_owned(),
4593 message_count: 12,
4594 completed_turn_count: Some(5),
4595 });
4596 let forgotten = C2NodeResponse::from(&NodeResponse::ContextPackForgotten {
4597 context_id: SpawnContextId::new("context-review-7").unwrap(),
4598 });
4599 assert_eq!(
4600 serde_json::to_string(&discovered).unwrap(),
4601 r#"{"kind":"history-discovered","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"candidates":[{"id":"candidate-7","session_id_hint":"session-hint-7","modified_at_unix_ms":77}]}"#,
4602 );
4603 assert_eq!(
4604 serde_json::to_string(&loaded).unwrap(),
4605 r#"{"kind":"history-loaded","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"session_id":"session-loaded-7","message_count":12,"completed_turn_count":5}"#,
4606 );
4607 let legacy_loaded_json = r#"{"kind":"history-loaded","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"session_id":"session-loaded-7","message_count":12}"#;
4608 let legacy_loaded = serde_json::from_str::<C2NodeResponse>(legacy_loaded_json).unwrap();
4609 assert_eq!(serde_json::to_string(&legacy_loaded).unwrap(), legacy_loaded_json);
4610 assert!(matches!(
4611 legacy_loaded,
4612 C2NodeResponse::HistoryLoaded {
4613 completed_turn_count: None,
4614 ..
4615 }
4616 ));
4617 assert_eq!(
4618 serde_json::to_string(&forgotten).unwrap(),
4619 r#"{"kind":"context-pack-forgotten","context_id":"context-review-7"}"#,
4620 );
4621 for response in [discovered, loaded, forgotten] {
4622 assert!(response.requires_history_context_pack_capability());
4623 let json = serde_json::to_string(&response).unwrap();
4624 assert!(!json.contains("messages"));
4625 assert!(!json.contains("path"));
4626 assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), response);
4627 }
4628 assert!(serde_json::from_str::<C2NodeResponse>(
4629 r#"{"kind":"history-discovered","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"candidates":[{"id":"duplicate","session_id_hint":"one","modified_at_unix_ms":null},{"id":"duplicate","session_id_hint":"two","modified_at_unix_ms":null}]}"#,
4630 )
4631 .is_err());
4632 }
4633
4634 #[test]
4635 fn native_session_catalog_projection_is_exact_and_capability_bound() {
4636 assert_eq!(
4637 C2_NATIVE_SESSION_CATALOG_CAPABILITY,
4638 "native-session-catalog-v2"
4639 );
4640 let route = NativeSessionCatalogRoute::workspace(
4641 WorkspaceId::new("primary").unwrap(),
4642 provider("codex"),
4643 );
4644 let projected = C2NodeResponse::from(&NodeResponse::NativeSessionsCataloged {
4645 route: route.clone(),
4646 entries: vec![NativeSessionCatalogEntry {
4647 selection_id: "hist_selection_7".to_owned(),
4648 title: Some("Review".to_owned()),
4649 modified_at_unix_ms: Some(77),
4650 model: Some("model-7".to_owned()),
4651 message_count: 8,
4652 completed_turn_count: Some(4),
4653 external_group: None,
4654 record_id: Some(SessionRecordId::new("record-7").unwrap()),
4655 }],
4656 summary: Some(NativeSessionCatalogSummary {
4657 catalog_revision: 7,
4658 recent_cutoff_unix_ms: 70,
4659 recent_total_count: 1,
4660 older_total_count: 3,
4661 recent_next_after_selection_id: None,
4662 recent_has_more: false,
4663 }),
4664 });
4665 assert!(projected.requires_native_session_catalog_capability());
4666 let json = serde_json::to_string(&projected).unwrap();
4667 for forbidden in [
4668 "session_id",
4669 "cwd",
4670 "candidate",
4671 "path",
4672 "messages",
4673 "tokens",
4674 "documents",
4675 "raw",
4676 ] {
4677 assert!(!json.contains(forbidden));
4678 }
4679 assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), projected);
4680 assert!(serde_json::from_str::<C2NodeResponse>(
4681 r#"{"kind":"native-sessions-cataloged","workspace_id":"primary","provider":"codex","entries":[]}"#,
4682 )
4683 .is_err());
4684
4685 let paged = C2NodeResponse::from(&NodeResponse::NativeSessionsPaged {
4686 route,
4687 page: NativeSessionCatalogPage {
4688 window: NativeSessionCatalogWindow::Older,
4689 revision: 7,
4690 entries: Vec::new(),
4691 next_after_selection_id: None,
4692 remaining_count: 0,
4693 has_more: false,
4694 },
4695 });
4696 assert!(paged.requires_native_session_catalog_paging_capability());
4697 }
4698
4699 #[test]
4700 fn session_record_preview_projection_does_not_expose_native_identity() {
4701 let response = NodeResponse::SessionRecordPreviewed {
4702 record_id: SessionRecordId::new("record-7").unwrap(),
4703 preview: gate4agent_types::SessionRecordPreview {
4704 title: Some("Review".to_owned()),
4705 modified_at_unix_ms: Some(77),
4706 model: Some("model-7".to_owned()),
4707 message_count: 3,
4708 message_count_exact: true,
4709 completed_turn_count: Some(1),
4710 total_tokens: None,
4711 truncated: true,
4712 messages: vec![gate4agent_types::NativeSessionPreviewMessage {
4713 role: gate4agent_types::HistoryMessageRole::User,
4714 text: "visible dialogue".to_owned(),
4715 }],
4716 },
4717 };
4718 let projected = C2NodeResponse::from(&response);
4719 assert!(projected.requires_native_session_preview_capability());
4720 let json = serde_json::to_string(&projected).unwrap();
4721 for forbidden in ["session_id", "provider", "workspace_id", "cwd", "path", "tokens"] {
4722 assert!(!json.contains(forbidden));
4723 }
4724 assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), projected);
4725 }
4726
4727 #[test]
4728 fn legacy_slim_projection_deliberately_strips_context_metadata() {
4729 let context = context_pack_receipt();
4730 let mut source = private_session_record();
4731 source.context_id = Some(context.id.clone());
4732 source.context = Some(context);
4733 let c2 = C2ManagedSessionRecord::from(&source);
4734
4735 for json in [
4736 serde_json::to_value(SlimManagedSessionRecord::from(&source)).unwrap(),
4737 serde_json::to_value(SlimManagedSessionRecord::from(&c2)).unwrap(),
4738 ] {
4739 assert!(json.get("context_id").is_none());
4740 assert!(json.get("context").is_none());
4741 }
4742 }
4743
4744 #[test]
4745 fn c2_legacy_node_event_bytes_remain_exact_after_terminal_frame_addition() {
4746 assert_eq!(
4747 serde_json::to_vec(&C2NodeEvent::ResyncRequired {
4748 oldest_available_sequence: 7,
4749 })
4750 .unwrap(),
4751 br#"{"kind":"resync-required","oldest_available_sequence":7}"#,
4752 );
4753 }
4754
4755 #[test]
4756 fn c2_compatibility_missing_offer_negotiates_local_build_stamp() {
4757 let support = c2_compatibility_support(Vec::new());
4758
4759 let negotiated = support
4760 .negotiate(&C2ClientHello::new([1; C2_AUTH_NONCE_BYTES]))
4761 .unwrap();
4762
4763 assert_eq!(negotiated.build_stamp, BUILD_STAMP);
4764 assert!(negotiated.capabilities.is_empty());
4765 }
4766
4767 #[test]
4768 fn c2_compatibility_selects_local_build_stamp_and_capability_intersection() {
4769 let shared = CapabilityId::new("terminal-stream").unwrap();
4770 let server_only = CapabilityId::new("server-only").unwrap();
4771 let client_only = CapabilityId::new("client-only").unwrap();
4772 let support = c2_compatibility_support(vec![shared.clone(), server_only]);
4773 let hello = C2ClientHello::negotiating(
4774 [2; C2_AUTH_NONCE_BYTES],
4775 ClientCompatibilityOffer {
4776 build_stamp: BUILD_STAMP.to_owned(),
4777 capabilities: vec![client_only, shared.clone()],
4778 state_schema: None,
4779 },
4780 );
4781
4782 let negotiated = support.negotiate(&hello).unwrap();
4783
4784 assert_eq!(negotiated.build_stamp, BUILD_STAMP);
4785 assert_eq!(negotiated.capabilities, vec![shared]);
4786 }
4787
4788 #[test]
4789 fn c2_compatibility_negotiate_rejects_a_foreign_build_stamp_naming_both_values() {
4790 let support = c2_compatibility_support(Vec::new());
4791 let foreign_stamp = "f".repeat(40);
4792 let hello = C2ClientHello {
4793 build_stamp: foreign_stamp.clone(),
4794 client_nonce: [3; C2_AUTH_NONCE_BYTES],
4795 compatibility: None,
4796 };
4797
4798 let error = support.negotiate(&hello).unwrap_err();
4799 assert!(matches!(
4800 &error,
4801 ProtocolNegotiationError::BuildStampMismatch { local, remote }
4802 if local == BUILD_STAMP && remote == &foreign_stamp,
4803 ));
4804 assert_eq!(
4805 error.to_string(),
4806 format!("build stamp mismatch: local={BUILD_STAMP} remote={foreign_stamp}"),
4807 );
4808 }
4809
4810 #[test]
4811 fn c2_compatibility_bound_auth_transcript_is_exact_and_selection_sensitive() {
4812 let capability = CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).unwrap();
4813 let offer = ClientCompatibilityOffer {
4814 build_stamp: BUILD_STAMP.to_owned(),
4815 capabilities: vec![capability.clone()],
4816 state_schema: None,
4817 };
4818 let selected = NegotiatedC2ControlCompatibility {
4819 build_stamp: BUILD_STAMP.to_owned(),
4820 capabilities: vec![capability],
4821 host: HostDescriptor {
4822 operating_system: OperatingSystemId::new("windows").unwrap(),
4823 architecture: ArchitectureId::new("x86_64").unwrap(),
4824 },
4825 path_semantics: PathSemantics {
4826 style: PathStyle::Windows,
4827 encoding: PathEncoding::Utf8,
4828 },
4829 };
4830 let transcript = c2_bound_auth_transcript(
4831 C2AuthDirection::Server,
4832 &[0x11; C2_AUTH_NONCE_BYTES],
4833 &[0x22; C2_AUTH_NONCE_BYTES],
4834 &offer,
4835 &selected,
4836 ).unwrap();
4837 let hex = transcript.iter().map(|byte| format!("{byte:02x}")).collect::<String>();
4838
4839 let stamp = build_stamp_hex();
4840 assert_eq!(
4841 hex,
4842 format!(
4843 concat!(
4844 "67617465346167656e742d63322d636f6e74726f6c2d617574682d76322d636f6d7061746962696c69747900",
4845 "{stamp}",
4846 "01",
4847 "1111111111111111111111111111111111111111111111111111111111111111",
4848 "2222222222222222222222222222222222222222222222222222222222222222",
4849 "6f6666657200",
4850 "{stamp}",
4851 "0100",
4852 "1600636f6d7061746962696c6974792e6d6574616461746100",
4853 "73656c656374656400",
4854 "{stamp}",
4855 "0100",
4856 "1600636f6d7061746962696c6974792e6d65746164617461",
4857 "070077696e646f777306007838365f36340101",
4858 ),
4859 stamp = stamp,
4860 ),
4861 );
4862
4863 let mut tampered = selected;
4864 tampered.path_semantics.style = PathStyle::Posix;
4865 assert_ne!(
4866 transcript,
4867 c2_bound_auth_transcript(
4868 C2AuthDirection::Server,
4869 &[0x11; C2_AUTH_NONCE_BYTES],
4870 &[0x22; C2_AUTH_NONCE_BYTES],
4871 &offer,
4872 &tampered,
4873 ).unwrap(),
4874 );
4875 }
4876
4877 #[test]
4878 fn c2_compatibility_preserves_foreign_host_and_opaque_path() {
4879 let support = c2_compatibility_support(Vec::new(),
4880 );
4881 let negotiated = support
4882 .negotiate(&C2ClientHello::new([4; C2_AUTH_NONCE_BYTES]))
4883 .unwrap();
4884 let projected = C2NodeSnapshot::from(&NodeSnapshot {
4885 node_id: NodeId::new("remote-mac").unwrap(),
4886 enabled_providers: Vec::new(),
4887 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
4888 workspaces: vec![WorkspaceSnapshot {
4889 workspace_id: WorkspaceId::new("repo").unwrap(),
4890 canonical_root: host_path("/srv/CaseSensitive/../literal-root"),
4891 sessions: Vec::new(),
4892 worktree_service_mode: None,
4893 managed_worktree_profiles: None,
4894 }],
4895 session_records: Vec::new(),
4896 managed_worktrees: Vec::new(),
4897 launch_inventory: None,
4898 agent_progress: Vec::new(),
4899 });
4900 let json = serde_json::to_string(&(projected, negotiated)).unwrap();
4901 let (projected, negotiated) = serde_json::from_str::<(
4902 C2NodeSnapshot,
4903 NegotiatedC2ControlCompatibility,
4904 )>(&json).unwrap();
4905
4906 assert_eq!(
4907 projected.workspaces[0].canonical_root.display_text(),
4908 "/srv/CaseSensitive/../literal-root",
4909 );
4910 assert_eq!(negotiated.host.operating_system.as_str(), "darwin");
4911 assert_eq!(negotiated.host.architecture.as_str(), "aarch64");
4912 assert_eq!(negotiated.path_semantics.style, PathStyle::Posix);
4913 }
4914
4915 #[test]
4916 fn c2_workspace_inspection_preserves_legacy_utf8_repository_path_shape() {
4917 let inspection = C2WorkspaceInspection {
4918 workspace_id: WorkspaceId::new("repo").unwrap(),
4919 entries: vec![WorkspaceEntry {
4920 relative_path: repository_path(r"src\literal/main.rs"),
4921 kind: WorkspaceEntryKind::File,
4922 }],
4923 tree_truncated: false,
4924 git: C2GitSnapshot {
4925 is_repository: true,
4926 branch: Some("main".to_owned()),
4927 status: Vec::new(),
4928 recent_commits: Vec::new(),
4929 worktrees: Vec::new(),
4930 managed_worktree: None,
4931 truncated: false,
4932 diagnostic_present: false,
4933 },
4934 truncation: None,
4935 };
4936
4937 let json = serde_json::to_string(&inspection).unwrap();
4938 assert_eq!(
4939 json,
4940 r#"{"workspace_id":"repo","entries":[{"relative_path":"src\\literal/main.rs","kind":"file"}],"tree_truncated":false,"git":{"is_repository":true,"branch":"main","status":[],"recent_commits":[],"worktrees":[],"truncated":false,"diagnostic_present":false}}"#,
4941 );
4942 assert_eq!(serde_json::from_str::<C2WorkspaceInspection>(&json).unwrap(), inspection);
4943 }
4944
4945 #[test]
4946 fn c2_workspace_inspection_roundtrips_all_tagged_repository_path_fields() {
4947 let entry_path = RepositoryPath::unix_bytes(vec![b's', b'r', b'c', b'/', 0xfe]).unwrap();
4948 let status_path = RepositoryPath::unix_bytes(vec![b's', b'r', b'c', b'/', 0xff]).unwrap();
4949 let previous_path = RepositoryPath::unix_bytes(vec![b'o', b'l', b'd', b'/', 0xfd]).unwrap();
4950 let inspection = C2WorkspaceInspection {
4951 workspace_id: WorkspaceId::new("repo").unwrap(),
4952 entries: vec![WorkspaceEntry {
4953 relative_path: entry_path,
4954 kind: WorkspaceEntryKind::File,
4955 }],
4956 tree_truncated: false,
4957 git: C2GitSnapshot {
4958 is_repository: true,
4959 branch: None,
4960 status: vec![GitStatusEntry {
4961 index_status: "R".to_owned(),
4962 worktree_status: " ".to_owned(),
4963 path: status_path,
4964 previous_path: Some(previous_path),
4965 }],
4966 recent_commits: Vec::new(),
4967 worktrees: Vec::new(),
4968 managed_worktree: None,
4969 truncated: false,
4970 diagnostic_present: false,
4971 },
4972 truncation: None,
4973 };
4974
4975 let json = serde_json::to_string(&inspection).unwrap();
4976 assert!(json.contains(r#""relative_path":{"kind":"unix-bytes""#));
4977 assert!(json.contains(r#""path":{"kind":"unix-bytes""#));
4978 assert!(json.contains(r#""previous_path":{"kind":"unix-bytes""#));
4979 assert_eq!(serde_json::from_str::<C2WorkspaceInspection>(&json).unwrap(), inspection);
4980 }
4981
4982 #[test]
4983 fn c2_workspace_inspection_projects_and_validates_managed_git_scope_without_paths() {
4984 let workspace_id = WorkspaceId::new("managed-a").unwrap();
4985 let source_workspace_id = WorkspaceId::new("primary").unwrap();
4986 let inspection = WorkspaceInspection {
4987 workspace_id: workspace_id.clone(),
4988 entries: Vec::new(),
4989 tree_truncated: false,
4990 git: GitSnapshot {
4991 is_repository: true,
4992 branch: Some("gate4agent/a".to_owned()),
4993 status: Vec::new(),
4994 recent_commits: Vec::new(),
4995 worktrees: Vec::new(),
4996 managed_worktree: Some(ManagedWorktreeGitScope {
4997 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
4998 source_workspace_id: source_workspace_id.clone(),
4999 branch: "gate4agent/a".to_owned(),
5000 base_commit: gate4agent_node_protocol::GitObjectId::new(
5001 "0123456789abcdef0123456789abcdef01234567".to_owned(),
5002 )
5003 .unwrap(),
5004 active_session_count: 1,
5005 managed_record_count: 1,
5006 }),
5007 truncated: false,
5008 diagnostic: None,
5009 },
5010 truncation: None,
5011 };
5012 let projected = C2WorkspaceInspection::from(&inspection);
5013 let json = serde_json::to_string(&projected).unwrap();
5014 assert!(json.contains("\"managed_worktree\""));
5015 assert!(json.contains("\"lease_id\":\"mw-a\""));
5016 assert!(!json.contains("target_root"));
5017 assert_eq!(
5018 serde_json::from_str::<C2WorkspaceInspection>(&json).unwrap(),
5019 projected,
5020 );
5021
5022 let invalid = json.replace(
5023 "\"source_workspace_id\":\"primary\"",
5024 "\"source_workspace_id\":\"managed-a\"",
5025 );
5026 assert!(serde_json::from_str::<C2WorkspaceInspection>(&invalid).is_err());
5027
5028 let mut invalid_node = inspection;
5029 invalid_node.git.branch = Some("gate4agent/b".to_owned());
5030 assert!(C2WorkspaceInspection::from(&invalid_node)
5031 .git
5032 .managed_worktree
5033 .is_none());
5034 }
5035
5036 #[test]
5037 fn slim_inventory_is_deterministic_and_excludes_terminal_history() {
5038 let snapshot = NodeSnapshot {
5039 node_id: NodeId::new("node-a").unwrap(),
5040 enabled_providers: vec![provider("codex"), provider("claude"), provider("codex")],
5041 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5042 workspaces: vec![
5043 WorkspaceSnapshot { workspace_id: WorkspaceId::new("z-work").unwrap(), canonical_root: host_path("z"), sessions: Vec::new(), worktree_service_mode: None, managed_worktree_profiles: None },
5044 WorkspaceSnapshot {
5045 workspace_id: WorkspaceId::new("a-work").unwrap(),
5046 canonical_root: host_path("a"),
5047 sessions: vec![fixture_session()],
5048 worktree_service_mode: None,
5049 managed_worktree_profiles: None,
5050 },
5051 ],
5052 session_records: Vec::new(),
5053 managed_worktrees: Vec::new(),
5054 launch_inventory: None,
5055 agent_progress: Vec::new(),
5056 };
5057 let slim = SlimNodeInventory::from_snapshot(&snapshot);
5058 assert_eq!(slim.enabled_providers, vec![provider("claude"), provider("codex")]);
5059 assert_eq!(slim.workspaces.keys().map(WorkspaceId::as_str).collect::<Vec<_>>(), vec!["a-work", "z-work"]);
5060 let session = &slim.workspaces[&WorkspaceId::new("a-work").unwrap()].sessions[0];
5061 assert_eq!(session.transport, TransportKind::Pty);
5062 assert_eq!(session.process_id, Some(1234));
5063 assert_eq!(session.terminal_size, Some(TerminalSize { rows: 40, columns: 120 }));
5064 assert!(session.operation_pending);
5065 assert!(session.input_pending);
5066 let json = serde_json::to_string(&slim).unwrap();
5067 assert!(!json.contains("terminal_frame"));
5068 assert!(!json.contains("history"));
5069 }
5070
5071 #[test]
5075 fn slim_session_json_omitting_screen_state_decodes_to_unknown_not_ready() {
5076 let json = r#"{
5077 "instance_id": 7,
5078 "generation": 2,
5079 "agent_id": "codex",
5080 "transport": "pty",
5081 "status": "running",
5082 "process_id": 1234,
5083 "terminal_size": null,
5084 "operation_pending": true,
5085 "input_pending": false
5086 }"#;
5087 let session = serde_json::from_str::<SlimSession>(json).unwrap();
5088 assert_eq!(session.screen_state, PtyScreenState::Unknown);
5089 assert_ne!(session.screen_state, PtyScreenState::Ready);
5090 }
5091
5092 #[test]
5097 fn slim_inventory_projection_carries_a_non_default_screen_state_from_the_session_snapshot() {
5098 let mut session = fixture_session();
5099 session.screen_state = PtyScreenState::OperatorGate {
5100 gate: gate4agent_types::OperatorGateState::new(gate4agent_types::OperatorGateKind::WorkspaceTrust),
5101 };
5102 let snapshot = NodeSnapshot {
5103 node_id: NodeId::new("node-a").unwrap(),
5104 enabled_providers: vec![provider("codex")],
5105 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5106 workspaces: vec![WorkspaceSnapshot {
5107 workspace_id: WorkspaceId::new("a-work").unwrap(),
5108 canonical_root: host_path("a"),
5109 sessions: vec![session.clone()],
5110 worktree_service_mode: None,
5111 managed_worktree_profiles: None,
5112 }],
5113 session_records: Vec::new(),
5114 managed_worktrees: Vec::new(),
5115 launch_inventory: None,
5116 agent_progress: Vec::new(),
5117 };
5118 let slim = SlimNodeInventory::from_snapshot(&snapshot);
5119 let slim_session = &slim.workspaces[&WorkspaceId::new("a-work").unwrap()].sessions[0];
5120 assert_eq!(slim_session.screen_state, session.screen_state);
5121 }
5122
5123 #[test]
5124 fn c2_full_and_slim_snapshots_preserve_launch_and_worktree_inventory() {
5125 let worktree_profiles = WorktreeProfileInventory {
5126 profiles: vec![ManagedWorktreeProfileSummary {
5127 id: WorktreeProfileId::new("review").unwrap(),
5128 revision: WorktreeProfileRevision::new("v1").unwrap(),
5129 retention: ManagedWorktreeRetention::Retain,
5130 }],
5131 };
5132 let launch_inventory = LaunchInventory {
5133 spawn_profiles: Some(vec![SpawnProfileSummary {
5134 id: SpawnProfileId::new("default").unwrap(),
5135 revision: SpawnProfileRevision::new("v1").unwrap(),
5136 environment_profile: None,
5137 }]),
5138 bundles: Some(vec![ResolvedBundleReceipt {
5139 id: SpawnBundleId::new("review").unwrap(),
5140 revision: SpawnBundleRevision::new("v1").unwrap(),
5141 digest: SpawnBundleDigest::new(format!("sha256:{}", "0".repeat(64))).unwrap(),
5142 }]),
5143 network_allowlists: None,
5144 };
5145 let snapshot = NodeSnapshot {
5146 node_id: NodeId::new("node-a").unwrap(),
5147 enabled_providers: vec![provider("codex")],
5148 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5149 workspaces: vec![WorkspaceSnapshot {
5150 workspace_id: WorkspaceId::new("repo").unwrap(),
5151 canonical_root: host_path("repo"),
5152 sessions: Vec::new(),
5153 worktree_service_mode: Some(WorktreeServiceMode::Managed),
5154 managed_worktree_profiles: Some(worktree_profiles.clone()),
5155 }],
5156 session_records: Vec::new(),
5157 managed_worktrees: Vec::new(),
5158 launch_inventory: Some(launch_inventory.clone()),
5159 agent_progress: Vec::new(),
5160 };
5161
5162 let full = C2NodeSnapshot::from(&snapshot);
5163 let slim_from_node = SlimNodeInventory::from_snapshot(&snapshot);
5164 let slim_from_c2 = SlimNodeInventory::from_c2_snapshot(&full);
5165 assert_eq!(full.launch_inventory.as_ref(), Some(&launch_inventory));
5166 assert_eq!(slim_from_node.launch_inventory.as_ref(), Some(&launch_inventory));
5167 assert_eq!(slim_from_c2.launch_inventory.as_ref(), Some(&launch_inventory));
5168 assert_eq!(
5169 full.workspaces[0].worktree_service_mode,
5170 Some(WorktreeServiceMode::Managed),
5171 );
5172 assert_eq!(
5173 slim_from_node.workspaces[&WorkspaceId::new("repo").unwrap()]
5174 .worktree_service_mode,
5175 Some(WorktreeServiceMode::Managed),
5176 );
5177 assert_eq!(
5178 slim_from_c2.workspaces[&WorkspaceId::new("repo").unwrap()]
5179 .worktree_service_mode,
5180 Some(WorktreeServiceMode::Managed),
5181 );
5182 assert_eq!(
5183 full.workspaces[0].managed_worktree_profiles.as_ref(),
5184 Some(&worktree_profiles),
5185 );
5186 assert_eq!(
5187 slim_from_node.workspaces[&WorkspaceId::new("repo").unwrap()]
5188 .managed_worktree_profiles
5189 .as_ref(),
5190 Some(&worktree_profiles),
5191 );
5192
5193 let legacy = serde_json::from_value::<C2WorkspaceSnapshot>(serde_json::json!({
5194 "workspace_id": "legacy",
5195 "canonical_root": "legacy-root",
5196 "sessions": [],
5197 }))
5198 .unwrap();
5199 assert_eq!(legacy.worktree_service_mode, None);
5200 assert_eq!(legacy.managed_worktree_profiles, None);
5201 let encoded = serde_json::to_value(legacy).unwrap();
5202 assert!(encoded.get("worktree_service_mode").is_none());
5203 assert!(encoded.get("managed_worktree_profiles").is_none());
5204 }
5205
5206 #[test]
5207 fn slim_inventory_provider_contract_projection_is_exact_and_private() {
5208 let mut slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5209 node_id: NodeId::new("node-a").unwrap(),
5210 enabled_providers: vec![provider("codex")],
5211 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5212 workspaces: Vec::new(),
5213 session_records: Vec::new(),
5214 managed_worktrees: Vec::new(),
5215 launch_inventory: None,
5216 agent_progress: Vec::new(),
5217 });
5218 let (provider_contracts, provider_adapter_contracts) =
5219 provider_contract_manifest();
5220 slim.provider_contracts = provider_contracts;
5221 slim.provider_adapter_contracts = provider_adapter_contracts;
5222
5223 let value = serde_json::to_value(&slim).unwrap();
5224 assert_eq!(
5225 value["provider_contracts"],
5226 serde_json::json!([{
5227 "provider": "codex",
5228 "revision": "codex.2026-08",
5229 }]),
5230 );
5231 assert_eq!(
5232 value["provider_adapter_contracts"],
5233 serde_json::json!([{
5234 "provider": "codex",
5235 "family": "pty-semantic",
5236 "adapter_id": "codex",
5237 "revision": "pty-semantic.2026-08",
5238 }]),
5239 );
5240 let json = serde_json::to_string(&value).unwrap();
5241 for forbidden in [
5242 "installed_cli_version",
5243 "executable_path",
5244 "auth_state",
5245 "canary_verdict",
5246 "events",
5247 "routed_response",
5248 ] {
5249 assert!(!json.contains(forbidden), "leaked private field {forbidden}");
5250 }
5251 }
5252
5253 #[test]
5254 fn c2_projection_omits_path_stdout_stderr_env_and_fallback_reason() {
5255 let runtime_statuses = ProviderRuntimeStatuses::new([
5256 ProviderRuntimeStatus::raw_passthrough(
5257 provider("codex"),
5258 Some(ProviderRuntimeVersion::new("0.999.0").unwrap()),
5259 ),
5260 ])
5261 .unwrap();
5262 let snapshot = NodeSnapshot {
5263 node_id: NodeId::new("node-a").unwrap(),
5264 enabled_providers: vec![provider("codex")],
5265 provider_runtime_statuses: runtime_statuses.clone(),
5266 workspaces: Vec::new(),
5267 session_records: Vec::new(),
5268 managed_worktrees: Vec::new(),
5269 launch_inventory: None,
5270 agent_progress: Vec::new(),
5271 };
5272 let projected = C2NodeSnapshot::from(&snapshot);
5273 let inventory = SlimNodeInventory::from_snapshot(&snapshot);
5274 assert_eq!(projected.provider_runtime_statuses, runtime_statuses);
5275 assert_eq!(inventory.provider_runtime_statuses, runtime_statuses);
5276 for encoded in [
5277 serde_json::to_string(&projected).unwrap(),
5278 serde_json::to_string(&inventory).unwrap(),
5279 ] {
5280 assert!(encoded.contains("\"version\":\"0.999.0\""));
5281 for forbidden in [
5282 "launcher", "executable", "stdout", "stderr", "environment", "fallback",
5283 "reason", "arguments",
5284 ] {
5285 assert!(!encoded.contains(forbidden), "leaked field {forbidden}");
5286 }
5287 }
5288 }
5289
5290 #[test]
5291 fn slim_inventory_reports_sessions_hidden_by_workspace_truncation() {
5292 let mut workspaces = (0..MAX_C2_WORKSPACES_PER_NODE)
5293 .map(|index| WorkspaceSnapshot {
5294 workspace_id: WorkspaceId::new(format!("work-{index:02}")).unwrap(),
5295 canonical_root: host_path(format!("root-{index:02}")),
5296 sessions: Vec::new(),
5297 worktree_service_mode: None,
5298 managed_worktree_profiles: None,
5299 })
5300 .collect::<Vec<_>>();
5301 workspaces.push(WorkspaceSnapshot {
5302 workspace_id: WorkspaceId::new("work-zz").unwrap(),
5303 canonical_root: host_path("hidden-root"),
5304 sessions: vec![fixture_session()],
5305 worktree_service_mode: None,
5306 managed_worktree_profiles: None,
5307 });
5308 let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5309 node_id: NodeId::new("node-a").unwrap(),
5310 enabled_providers: Vec::new(),
5311 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5312 workspaces,
5313 session_records: Vec::new(),
5314 managed_worktrees: Vec::new(),
5315 launch_inventory: None,
5316 agent_progress: Vec::new(),
5317 });
5318 assert!(slim.workspaces_truncated);
5319 assert_eq!(slim.session_count, 1);
5320 assert!(slim.sessions_truncated);
5321 }
5322
5323 #[test]
5324 fn control_auth_transcript_is_direction_and_protocol_domain_separated() {
5325 assert_eq!(C2_API_VERSION, 2);
5326 let client_nonce = [3; C2_AUTH_NONCE_BYTES];
5327 let server_nonce = [7; C2_AUTH_NONCE_BYTES];
5328 let server = c2_auth_transcript(C2AuthDirection::Server, &client_nonce, &server_nonce);
5329 let client = c2_auth_transcript(C2AuthDirection::Client, &client_nonce, &server_nonce);
5330 assert_ne!(server, client);
5331 assert!(server.starts_with(b"gate4agent-c2-control-auth-v2\0"));
5332 assert!(!server.windows(b"gate4agent-node-auth-v3".len()).any(|window| window == b"gate4agent-node-auth-v3"));
5333 assert_eq!(&server[server.len() - (C2_AUTH_NONCE_BYTES * 2)..server.len() - C2_AUTH_NONCE_BYTES], &client_nonce);
5334 assert_eq!(&server[server.len() - C2_AUTH_NONCE_BYTES..], &server_nonce);
5335 }
5336
5337 #[test]
5338 fn topology_projection_is_sorted_bounded_and_minimal() {
5339 let nodes = (0..=MAX_C2_NODES).rev().map(|index| {
5340 let node_id = NodeId::new(format!("node-{index:03}")).unwrap();
5341 let incarnation_id = NodeIncarnationId::from_bytes([index as u8; 16]);
5342 let observed = ObservedNode {
5343 endpoint: format!(r"\\.\pipe\node-{index:03}"),
5344 transport_label: "windows-named-pipe".to_owned(),
5345 transport: NodeTransportState::Online,
5346 freshness: NodeFreshness::Fresh,
5347 cursor: Some(NodeCursor { incarnation_id, sequence: 99 }),
5348 inventory: None,
5349 last_attempt_unix_ms: Some(10),
5350 last_success_unix_ms: Some(10),
5351 consecutive_failures: 0,
5352 last_error: None,
5353 gaps: Vec::new(),
5354 gaps_truncated: 0,
5355 };
5356 (node_id, observed)
5357 }).collect();
5358 let topology = C2Topology::from_status(&StatusResponse {
5359 api_version: C2_API_VERSION,
5360 ready: true,
5361 observed_at_unix_ms: 10,
5362 nodes,
5363 });
5364
5365 assert_eq!(topology.nodes.len(), MAX_C2_NODES);
5366 assert_eq!(topology.nodes.first().unwrap().node_id.as_str(), "node-000");
5367 assert_eq!(topology.nodes.last().unwrap().node_id.as_str(), "node-063");
5368 assert_eq!(
5369 topology.nodes[0].current_incarnation_id,
5370 Some(NodeIncarnationId::from_bytes([0; 16])),
5371 );
5372 let json = serde_json::to_string(&C2ServerFrame::Topology(topology)).unwrap();
5373 assert!(!json.contains("observed_at_unix_ms"));
5374 assert!(!json.contains("sequence"));
5375 assert!(!json.contains("inventory"));
5376 assert!(!json.contains("managed_worktrees"));
5377 }
5378
5379 #[test]
5380 fn topology_node_without_relay_route_decodes_as_unknown() {
5381 let legacy = r#"{
5382 "node_id":"node-a",
5383 "endpoint":"legacy-endpoint",
5384 "transport":"offline",
5385 "current_incarnation_id":null
5386 }"#;
5387
5388 let decoded: C2TopologyNode = serde_json::from_str(legacy).unwrap();
5389
5390 assert_eq!(decoded.relay_route, C2RelayRoute::Unknown);
5391 assert!(!serde_json::to_string(&decoded).unwrap().contains("relay_route"));
5392 }
5393
5394 #[test]
5395 fn topology_provider_contract_projection_is_capability_gated_and_change_sensitive() {
5396 let (provider_contracts, provider_adapter_contracts) =
5397 provider_contract_manifest();
5398 let mut inventory = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5399 node_id: NodeId::new("node-a").unwrap(),
5400 enabled_providers: vec![provider("codex")],
5401 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5402 workspaces: Vec::new(),
5403 session_records: Vec::new(),
5404 managed_worktrees: Vec::new(),
5405 launch_inventory: None,
5406 agent_progress: Vec::new(),
5407 });
5408 inventory.provider_contracts = provider_contracts;
5409 inventory.provider_adapter_contracts = provider_adapter_contracts;
5410 let status = StatusResponse {
5411 api_version: C2_API_VERSION,
5412 ready: true,
5413 observed_at_unix_ms: 10,
5414 nodes: BTreeMap::from([(
5415 NodeId::new("node-a").unwrap(),
5416 ObservedNode {
5417 endpoint: r"\\.\pipe\node-a".to_owned(),
5418 transport_label: "windows-named-pipe".to_owned(),
5419 transport: NodeTransportState::Online,
5420 freshness: NodeFreshness::Fresh,
5421 cursor: Some(NodeCursor {
5422 incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5423 sequence: 9,
5424 }),
5425 inventory: Some(inventory),
5426 last_attempt_unix_ms: Some(10),
5427 last_success_unix_ms: Some(10),
5428 consecutive_failures: 0,
5429 last_error: None,
5430 gaps: Vec::new(),
5431 gaps_truncated: 0,
5432 },
5433 )]),
5434 };
5435
5436 let projected = C2Topology::from_status_with_provider_contracts(&status, true);
5437 assert_eq!(projected.nodes[0].provider_contracts.len(), 1);
5438 assert_eq!(projected.nodes[0].provider_adapter_contracts.len(), 1);
5439 let legacy = C2Topology::from_status_with_provider_contracts(&status, false);
5440 assert!(legacy.nodes[0].provider_contracts.is_empty());
5441 assert!(legacy.nodes[0].provider_adapter_contracts.is_empty());
5442 #[derive(Serialize)]
5443 struct LegacyTopologyNode<'a> {
5444 node_id: &'a NodeId,
5445 endpoint: &'a str,
5446 relay_route: C2RelayRoute,
5447 transport: NodeTransportState,
5448 current_incarnation_id: Option<NodeIncarnationId>,
5449 }
5450 #[derive(Serialize)]
5451 struct LegacyTopology<'a> {
5452 nodes: Vec<LegacyTopologyNode<'a>>,
5453 }
5454 let legacy_shape = LegacyTopology {
5455 nodes: legacy.nodes.iter().map(|node| LegacyTopologyNode {
5456 node_id: &node.node_id,
5457 endpoint: &node.endpoint,
5458 relay_route: node.relay_route,
5459 transport: node.transport,
5460 current_incarnation_id: node.current_incarnation_id,
5461 }).collect(),
5462 };
5463 let legacy_json = serde_json::to_string(&legacy).unwrap();
5464 assert_eq!(
5465 serde_json::to_vec(&legacy).unwrap(),
5466 serde_json::to_vec(&legacy_shape).unwrap(),
5467 );
5468 assert!(!legacy_json.contains("provider_contracts"));
5469 assert!(!legacy_json.contains("provider_adapter_contracts"));
5470 assert_ne!(legacy, projected);
5471
5472 let projected_json = serde_json::to_string(&projected).unwrap();
5473 assert!(projected_json.contains("provider_contracts"));
5474 assert!(projected_json.contains("provider_adapter_contracts"));
5475 assert!(!projected_json.contains("inventory"));
5476 assert!(!projected_json.contains("events"));
5477 assert!(!projected_json.contains("routed_response"));
5478 }
5479
5480 #[test]
5481 fn routed_durable_session_responses_round_trip_without_private_record_fields() {
5482 let record = private_session_record();
5483 let session = record.active_session.clone().unwrap();
5484 let native_selection = NativeSessionSelection {
5485 route: NativeSessionCatalogRoute::workspace(
5486 record.workspace_id.clone(),
5487 record.provider.clone(),
5488 ),
5489 catalog_revision: 7,
5490 recent_cutoff_unix_ms: 70,
5491 selection_id: "selection-private".to_owned(),
5492 };
5493 let responses = vec![
5494 NodeResponse::Snapshot {
5495 event_sequence: 4,
5496 controller: None,
5497 snapshot: NodeSnapshot {
5498 node_id: NodeId::new("node-a").unwrap(),
5499 enabled_providers: vec![provider("codex")],
5500 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5501 workspaces: Vec::new(),
5502 session_records: vec![record.clone()],
5503 managed_worktrees: Vec::new(),
5504 launch_inventory: None,
5505 agent_progress: Vec::new(),
5506 },
5507 },
5508 NodeResponse::ProviderSessionIndexed { record: record.clone() },
5509 NodeResponse::NativeSessionIndexed {
5510 selection: native_selection.clone(),
5511 record: record.clone(),
5512 },
5513 NodeResponse::SessionRecordUpdated { record: record.clone() },
5514 NodeResponse::SessionRecordResumed {
5515 record: record.clone(),
5516 session: session.clone(),
5517 },
5518 NodeResponse::Resync {
5519 event_sequence: 5,
5520 oldest_available_sequence: 1,
5521 snapshot: NodeSnapshot {
5522 node_id: NodeId::new("node-a").unwrap(),
5523 enabled_providers: vec![provider("codex")],
5524 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5525 workspaces: Vec::new(),
5526 session_records: vec![record.clone()],
5527 managed_worktrees: Vec::new(),
5528 launch_inventory: None,
5529 agent_progress: Vec::new(),
5530 },
5531 events: vec![gate4agent_node_protocol::NodeEventEnvelope {
5532 sequence: 5,
5533 event: NodeEvent::SessionRecordUpserted { record: record.clone() },
5534 }],
5535 },
5536 ];
5537
5538 for response in responses {
5539 let json = serde_json::to_string(&routed_response(response)).unwrap();
5540 assert_private_record_fields_absent(&json);
5541 assert!(json.contains("provider_identity_present"));
5542 assert!(json.contains(r#""profile_id":"local-default""#));
5543 assert!(json.contains(
5544 r#""profile_revision":"local-default.2026-08""#,
5545 ));
5546 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5547 let decoded_record = match decoded.response.unwrap() {
5548 C2NodeResponse::Snapshot { snapshot, .. } => snapshot.session_records.into_iter().next().unwrap(),
5549 C2NodeResponse::ProviderSessionIndexed { record }
5550 | C2NodeResponse::NativeSessionIndexed { record, .. }
5551 | C2NodeResponse::SessionRecordUpdated { record }
5552 | C2NodeResponse::SessionRecordResumed { record, .. } => record,
5553 C2NodeResponse::Resync { snapshot, events, .. } => {
5554 assert!(matches!(events.into_iter().next().unwrap().event,
5555 C2NodeEvent::SessionRecordUpserted { ref record }
5556 if record.provider_identity_present));
5557 snapshot.session_records.into_iter().next().unwrap()
5558 }
5559 response => panic!("unexpected response after C2 round trip: {response:?}"),
5560 };
5561 assert_eq!(decoded_record.record_id.as_str(), "session-private");
5562 assert_eq!(decoded_record.display_name, "release shepherd");
5563 assert_eq!(decoded_record.active_session.as_ref(), Some(&session));
5564 assert!(decoded_record.provider_identity_present);
5565 }
5566 }
5567
5568 #[test]
5569 fn c2_resync_projection_preserves_authoritative_replay_floor() {
5570 let source = NodeResponse::Resync {
5571 event_sequence: 12,
5572 oldest_available_sequence: 9,
5573 snapshot: NodeSnapshot {
5574 node_id: NodeId::new("node-a").unwrap(),
5575 enabled_providers: Vec::new(),
5576 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5577 workspaces: Vec::new(),
5578 session_records: Vec::new(),
5579 managed_worktrees: Vec::new(),
5580 launch_inventory: None,
5581 agent_progress: Vec::new(),
5582 },
5583 events: Vec::new(),
5584 };
5585 let projected = C2NodeResponse::from(&source);
5586 assert!(matches!(
5587 projected,
5588 C2NodeResponse::Resync {
5589 event_sequence: 12,
5590 oldest_available_sequence: 9,
5591 ..
5592 }
5593 ));
5594 let encoded = serde_json::to_vec(&projected).unwrap();
5595 assert_eq!(
5596 serde_json::from_slice::<C2NodeResponse>(&encoded).unwrap(),
5597 projected,
5598 );
5599 }
5600
5601 #[test]
5602 fn routed_session_record_event_round_trips_without_private_record_fields() {
5603 let routed = RoutedNodeEvent {
5604 node_id: NodeId::new("node-a").unwrap(),
5605 cursor: NodeCursor {
5606 incarnation_id: NodeIncarnationId::from_bytes([8; 16]),
5607 sequence: 9,
5608 },
5609 event: C2NodeEvent::from(&NodeEvent::SessionRecordUpserted {
5610 record: private_session_record(),
5611 }),
5612 };
5613
5614 let json = serde_json::to_string(&routed).unwrap();
5615 assert_private_record_fields_absent(&json);
5616 assert!(json.contains("provider_identity_present"));
5617 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
5618 let C2NodeEvent::SessionRecordUpserted { record } = decoded.event else {
5619 panic!("unexpected routed event after C2 round trip");
5620 };
5621 assert_eq!(record.record_id.as_str(), "session-private");
5622 assert_eq!(record.display_name, "release shepherd");
5623 assert!(record.provider_identity_present);
5624 }
5625
5626 #[test]
5627 fn routed_node_failure_replaces_raw_message_with_fixed_category() {
5628 let raw = NodeFailure {
5629 code: NodeFailureCode::BackendOperationFailed,
5630 message: r"provider token-secret failed at C:\private\relay.log".to_owned(),
5631 };
5632 let routed = RoutedNodeResponse {
5633 node_id: NodeId::new("node-a").unwrap(),
5634 incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5635 response: Err(C2NodeFailure::from(&raw)),
5636 };
5637
5638 let json = serde_json::to_string(&routed).unwrap();
5639 assert!(!json.contains("token-secret"));
5640 assert!(!json.contains("private"));
5641 assert!(!json.contains("relay.log"));
5642 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5643 assert_eq!(decoded.response.unwrap_err(), C2NodeFailure {
5644 code: NodeFailureCode::BackendOperationFailed,
5645 message: "node backend operation failed".to_owned(),
5646 });
5647 }
5648
5649 #[test]
5650 fn routed_workspace_inspection_omits_raw_git_diagnostics_and_reasons() {
5651 let worktree = GitWorktreeSnapshot {
5652 path: host_path(r"C:\work\feature"),
5653 head: "abc123".to_owned(),
5654 branch: Some("feature/privacy".to_owned()),
5655 is_bare: false,
5656 is_main: false,
5657 locked: true,
5658 lock_reason: Some("lock-secret-provider-token".to_owned()),
5659 prunable: true,
5660 prunable_reason: Some("prunable-secret-private-path".to_owned()),
5661 workspace_id: Some(WorkspaceId::new("feature").unwrap()),
5662 };
5663 let response = routed_response(NodeResponse::WorkspaceInspected {
5664 inspection: WorkspaceInspection {
5665 workspace_id: WorkspaceId::new("primary").unwrap(),
5666 entries: Vec::new(),
5667 tree_truncated: false,
5668 git: GitSnapshot {
5669 is_repository: true,
5670 branch: Some("main".to_owned()),
5671 status: Vec::new(),
5672 recent_commits: Vec::new(),
5673 worktrees: vec![worktree],
5674 managed_worktree: None,
5675 truncated: false,
5676 diagnostic: Some("diagnostic-secret C:\\private\\git.stderr".to_owned()),
5677 },
5678 truncation: None,
5679 },
5680 });
5681
5682 let json = serde_json::to_string(&response).unwrap();
5683 for secret in [
5684 "lock-secret-provider-token",
5685 "prunable-secret-private-path",
5686 "diagnostic-secret",
5687 "git.stderr",
5688 ] {
5689 assert!(!json.contains(secret));
5690 }
5691 assert!(!json.contains("lock_reason"));
5692 assert!(!json.contains("prunable_reason"));
5693 assert!(!json.contains("\"diagnostic\":"));
5694 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5695 let Ok(C2NodeResponse::WorkspaceInspected { inspection }) = decoded.response else {
5696 panic!("unexpected routed workspace response");
5697 };
5698 assert!(inspection.git.diagnostic_present);
5699 assert!(inspection.git.worktrees[0].locked);
5700 assert!(inspection.git.worktrees[0].prunable);
5701 assert_eq!(inspection.git.worktrees[0].path.display_text(), r"C:\work\feature");
5702 }
5703
5704 #[test]
5705 fn routed_workspace_file_read_projects_only_correlated_operator_content() {
5706 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
5707 let response = routed_response(NodeResponse::WorkspaceFileRead {
5708 file: WorkspaceFileRead {
5709 workspace_id: WorkspaceId::new("primary").unwrap(),
5710 path: path.clone(),
5711 content: WorkspaceFileContent::Utf8 {
5712 text: "pub fn fixture() {}\n".to_owned(),
5713 byte_len: 20,
5714 },
5715 revision: None,
5716 },
5717 });
5718
5719 let json = serde_json::to_string(&response).unwrap();
5720 assert!(!json.contains("canonical_root"));
5721 assert!(!json.contains("diagnostic"));
5722 assert!(!json.contains("controller"));
5723 assert!(!json.contains("inventory"));
5724 assert!(!json.contains("event_sequence"));
5725 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5726 assert_eq!(decoded.response, Ok(C2NodeResponse::WorkspaceFileRead {
5727 file: WorkspaceFileRead {
5728 workspace_id: WorkspaceId::new("primary").unwrap(),
5729 path,
5730 content: WorkspaceFileContent::Utf8 {
5731 text: "pub fn fixture() {}\n".to_owned(),
5732 byte_len: 20,
5733 },
5734 revision: None,
5735 },
5736 }));
5737 }
5738
5739 #[test]
5740 fn workspace_entry_create_projects_exact_bounded_c2_contracts() {
5741 assert_eq!(
5742 C2_WORKSPACE_ENTRY_CREATE_CAPABILITY,
5743 "workspace-entry-create-v1",
5744 );
5745 let workspace_id = WorkspaceId::new("primary").unwrap();
5746 let file = WorkspaceFileRead {
5747 workspace_id: workspace_id.clone(),
5748 path: repository_path("src/new.rs"),
5749 content: WorkspaceFileContent::Utf8 {
5750 text: String::new(),
5751 byte_len: 0,
5752 },
5753 revision: Some(
5754 gate4agent_node_protocol::WorkspaceFileRevision::new(
5755 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
5756 .to_owned(),
5757 )
5758 .unwrap(),
5759 ),
5760 };
5761 let file_response = C2NodeResponse::from(
5762 &NodeResponse::WorkspaceFileCreated { file: file.clone() },
5763 );
5764 assert_eq!(
5765 file_response,
5766 C2NodeResponse::WorkspaceFileCreated { file },
5767 );
5768 assert!(file_response.requires_workspace_entry_create_capability());
5769
5770 let entry = WorkspaceEntry {
5771 relative_path: repository_path("src/new"),
5772 kind: WorkspaceEntryKind::Directory,
5773 };
5774 let directory_response = C2NodeResponse::from(
5775 &NodeResponse::WorkspaceDirectoryCreated {
5776 workspace_id: workspace_id.clone(),
5777 entry: entry.clone(),
5778 },
5779 );
5780 assert_eq!(
5781 directory_response,
5782 C2NodeResponse::WorkspaceDirectoryCreated {
5783 workspace_id,
5784 entry,
5785 },
5786 );
5787 assert!(directory_response.requires_workspace_entry_create_capability());
5788 let encoded = serde_json::to_string(&directory_response).unwrap();
5789 assert_eq!(
5790 serde_json::from_str::<C2NodeResponse>(&encoded).unwrap(),
5791 directory_response,
5792 );
5793
5794 for (code, message) in [
5795 (NodeFailureCode::RepositoryEntryAlreadyExists, "repository entry already exists"),
5796 (NodeFailureCode::RepositoryParentNotFound, "repository parent directory unavailable"),
5797 (NodeFailureCode::RepositoryParentNotDirectory, "repository parent path is not a directory"),
5798 (NodeFailureCode::RepositoryEntryCreateTimedOut, "repository entry creation timed out"),
5799 (NodeFailureCode::RepositoryEntryCreateFailed, "repository entry creation failed"),
5800 ] {
5801 assert_eq!(
5802 C2NodeFailure::from(&NodeFailure {
5803 code,
5804 message: "private node detail".to_owned(),
5805 })
5806 .message,
5807 message,
5808 );
5809 }
5810 }
5811
5812 #[test]
5813 fn c2_host_directory_browse_projection_is_bounded_and_sanitizes_failures() {
5814 assert_eq!(C2_HOST_DIRECTORY_BROWSE_CAPABILITY, "host-directory-browse-v1");
5815 let directory = host_path(r"C:\Users");
5816 let entry = HostDirectoryEntry {
5817 path: host_path(r"C:\Users\Public"),
5818 display_name: "Public".to_owned(),
5819 is_link: false,
5820 };
5821 let response = routed_response(NodeResponse::HostDirectoriesBrowsed {
5822 listing: HostDirectoryListing {
5823 directory: Some(directory.clone()),
5824 parent: Some(host_path(r"C:\")),
5825 entries: vec![entry.clone()],
5826 next_after: Some(entry.path.clone()),
5827 incomplete: true,
5828 },
5829 });
5830 let json = serde_json::to_string(&response).unwrap();
5831 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5832 assert_eq!(decoded, response);
5833 assert!(matches!(
5834 decoded.response,
5835 Ok(C2NodeResponse::HostDirectoriesBrowsed {
5836 listing: HostDirectoryListing {
5837 directory: Some(ref actual),
5838 incomplete: true,
5839 ..
5840 }
5841 }) if actual == &directory
5842 ));
5843
5844 let mut oversized = serde_json::to_value(C2NodeResponse::HostDirectoriesBrowsed {
5845 listing: HostDirectoryListing {
5846 directory: None,
5847 parent: None,
5848 entries: vec![entry; gate4agent_node_protocol::MAX_HOST_DIRECTORY_ENTRIES + 1],
5849 next_after: None,
5850 incomplete: false,
5851 },
5852 }).unwrap();
5853 assert!(serde_json::from_value::<C2NodeResponse>(oversized.take()).is_err());
5854
5855 for (code, expected) in [
5856 (NodeFailureCode::HostDirectoryInvalid, "host directory invalid"),
5857 (NodeFailureCode::HostDirectoryReadFailed, "host directory read failed"),
5858 (NodeFailureCode::HostDirectoryReadTimedOut, "host directory read timed out"),
5859 ] {
5860 let failure = C2NodeFailure::from(&NodeFailure {
5861 code,
5862 message: r"private C:\Users\owner\secret".to_owned(),
5863 });
5864 assert_eq!(failure.message, expected);
5865 assert!(failure.requires_host_directory_browse_capability());
5866 assert!(!failure.message.contains("private"));
5867 }
5868 }
5869
5870 #[test]
5871 fn c2_projection_roundtrips_non_utf8_host_paths_without_interpretation() {
5872 let opaque = OpaqueHostPath::unix_bytes(vec![b'/', b's', b'r', b'v', b'/', 0xff, b'\n', 0x1b]).unwrap();
5873 let projected = C2NodeSnapshot::from(&NodeSnapshot {
5874 node_id: NodeId::new("remote-linux").unwrap(),
5875 enabled_providers: Vec::new(),
5876 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5877 workspaces: vec![WorkspaceSnapshot {
5878 workspace_id: WorkspaceId::new("repo").unwrap(),
5879 canonical_root: opaque.clone(),
5880 sessions: Vec::new(),
5881 worktree_service_mode: None,
5882 managed_worktree_profiles: None,
5883 }],
5884 session_records: Vec::new(),
5885 managed_worktrees: Vec::new(),
5886 launch_inventory: None,
5887 agent_progress: Vec::new(),
5888 });
5889
5890 let encoded = serde_json::to_string(&projected).unwrap();
5891 let decoded = serde_json::from_str::<C2NodeSnapshot>(&encoded).unwrap();
5892
5893 assert_eq!(decoded.workspaces[0].canonical_root, opaque);
5894 assert_eq!(decoded.workspaces[0].workspace_id.as_str(), "repo");
5895 let slim = SlimNodeInventory::from_c2_snapshot(&decoded);
5896 let display = &slim.workspaces[&WorkspaceId::new("repo").unwrap()].canonical_root;
5897 assert!(!display.chars().any(char::is_control));
5898 assert!(display.contains("\\n"));
5899 assert!(display.contains("\\u{1b}"));
5900 }
5901
5902 #[test]
5903 fn standalone_workspace_response_roundtrips_authoritative_snapshot() {
5904 assert_eq!(
5905 C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
5906 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
5907 );
5908 let workspace = WorkspaceSnapshot {
5909 workspace_id: WorkspaceId::new("standalone").unwrap(),
5910 canonical_root: host_path(r"C:\standalone"),
5911 sessions: Vec::new(),
5912 worktree_service_mode: Some(WorktreeServiceMode::Manual),
5913 managed_worktree_profiles: Some(WorktreeProfileInventory {
5914 profiles: Vec::new(),
5915 }),
5916 };
5917 let projected = C2NodeResponse::from(
5918 &NodeResponse::StandaloneWorkspaceCreated {
5919 workspace: workspace.clone(),
5920 },
5921 );
5922 let encoded = serde_json::to_string(&projected).unwrap();
5923 let decoded = serde_json::from_str::<C2NodeResponse>(&encoded).unwrap();
5924
5925 assert_eq!(
5926 decoded,
5927 C2NodeResponse::StandaloneWorkspaceCreated {
5928 workspace: C2WorkspaceSnapshot::from(&workspace),
5929 },
5930 );
5931 }
5932
5933 #[test]
5934 fn routed_worktree_created_omits_raw_git_reasons() {
5935 let response = routed_response(NodeResponse::WorktreeCreated {
5936 worktree: GitWorktreeSnapshot {
5937 path: host_path(r"C:\work\feature"),
5938 head: "abc123".to_owned(),
5939 branch: Some("feature/privacy".to_owned()),
5940 is_bare: false,
5941 is_main: false,
5942 locked: true,
5943 lock_reason: Some("created-lock-secret".to_owned()),
5944 prunable: true,
5945 prunable_reason: Some("created-prunable-secret".to_owned()),
5946 workspace_id: Some(WorkspaceId::new("feature").unwrap()),
5947 },
5948 workspace: WorkspaceSnapshot {
5949 workspace_id: WorkspaceId::new("feature").unwrap(),
5950 canonical_root: host_path(r"C:\work\feature"),
5951 sessions: Vec::new(),
5952 worktree_service_mode: None,
5953 managed_worktree_profiles: None,
5954 },
5955 });
5956
5957 let json = serde_json::to_string(&response).unwrap();
5958 assert!(!json.contains("created-lock-secret"));
5959 assert!(!json.contains("created-prunable-secret"));
5960 assert!(!json.contains("lock_reason"));
5961 assert!(!json.contains("prunable_reason"));
5962 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5963 assert!(matches!(decoded.response, Ok(C2NodeResponse::WorktreeCreated {
5964 worktree: C2GitWorktreeSnapshot { locked: true, prunable: true, .. },
5965 ..
5966 })));
5967 }
5968
5969 #[test]
5970 fn routed_resume_authorized_control_event_omits_provider_session_identity() {
5971 let routed = routed_control_event(ControlEventKind::ResumeAuthorized {
5972 session: ResumeSessionSummary {
5973 key: ProviderSessionKey::SessionId,
5974 id: "private-resume-session-id".to_owned(),
5975 },
5976 });
5977
5978 let json = serde_json::to_string(&routed).unwrap();
5979 assert!(json.contains("resume-authorized"));
5980 assert!(!json.contains("private-resume-session-id"));
5981 assert!(!json.contains("provider_session"));
5982 assert!(!json.contains("transcript_path"));
5983 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
5984 assert!(matches!(decoded.event, C2NodeEvent::Control {
5985 event: C2ControlEvent { event: C2ControlEventKind::ResumeAuthorized, .. },
5986 ..
5987 }));
5988 }
5989
5990 #[test]
5991 fn routed_session_identity_observed_control_event_omits_provider_identity_and_path() {
5992 let routed = routed_control_event(ControlEventKind::ProviderEvent {
5993 sequence: 19,
5994 source: provider_source(),
5995 source_sequence: 7,
5996 event: ProviderEvent::SessionIdentityObserved {
5997 identity: ProviderSessionIdentity {
5998 key: ProviderSessionKey::ConversationId,
5999 id: "private-observed-provider-id".to_owned(),
6000 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
6001 },
6002 },
6003 });
6004
6005 let json = serde_json::to_string(&routed).unwrap();
6006 assert!(json.contains("session-identity-observed"));
6007 assert!(!json.contains("private-observed-provider-id"));
6008 assert!(!json.contains("provider-transcript.jsonl"));
6009 assert!(!json.contains("transcript_path"));
6010 assert!(!json.contains("\"identity\":"));
6011 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
6012 assert!(matches!(decoded.event, C2NodeEvent::Control {
6013 event: C2ControlEvent {
6014 event: C2ControlEventKind::ProviderEvent {
6015 event: C2ProviderEventKind::SessionIdentityObserved,
6016 },
6017 ..
6018 },
6019 ..
6020 }));
6021 }
6022
6023 #[test]
6024 fn routed_provider_interaction_resolution_is_categorical_only() {
6025 let routed = routed_control_event(ControlEventKind::ProviderEvent {
6026 sequence: 20,
6027 source: provider_source(),
6028 source_sequence: 8,
6029 event: ProviderEvent::InteractionResolved {
6030 request_id: "private-provider-request-id".to_owned(),
6031 outcome: ProviderInteractionOutcome::Denied,
6032 },
6033 });
6034
6035 let json = serde_json::to_string(&routed).unwrap();
6036 assert!(json.contains("interaction-resolved"));
6037 assert!(!json.contains("private-provider-request-id"));
6038 assert!(!json.contains("denied"));
6039 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
6040 assert!(matches!(decoded.event, C2NodeEvent::Control {
6041 event: C2ControlEvent {
6042 event: C2ControlEventKind::ProviderEvent {
6043 event: C2ProviderEventKind::InteractionResolved,
6044 },
6045 ..
6046 },
6047 ..
6048 }));
6049 }
6050
6051 #[test]
6052 fn routed_snapshot_recursively_omits_provider_identity_and_error_state() {
6053 let mut session = fixture_session();
6054 session.status = SessionStatus::Failed {
6055 message: "private-session-failure".to_owned(),
6056 };
6057 session.terminal_stale = Some("private-terminal-error".to_owned());
6058 session.history.last_error = Some("private-history-error".to_owned());
6059 session.resume.last_session = Some(ResumeSessionSummary {
6060 key: ProviderSessionKey::SessionId,
6061 id: "private-resume-summary-id".to_owned(),
6062 });
6063 session.resume.last_error = Some("private-resume-error".to_owned());
6064 session.foreground.stale_reason = Some("private-foreground-error".to_owned());
6065 session.provider.session = Some(ProviderSessionIdentity {
6066 key: ProviderSessionKey::SessionId,
6067 id: "private-snapshot-provider-id".to_owned(),
6068 transcript_path: Some(r"C:\private\snapshot-transcript.jsonl".to_owned()),
6069 });
6070 session.provider.current_prompt = Some("private-current-prompt".to_owned());
6071 session.provider.last_event = Some(ProviderEvent::Error {
6072 message: "private-provider-error".to_owned(),
6073 });
6074 let routed = routed_response(NodeResponse::Snapshot {
6075 event_sequence: 4,
6076 controller: None,
6077 snapshot: NodeSnapshot {
6078 node_id: NodeId::new("node-a").unwrap(),
6079 enabled_providers: vec![provider("codex")],
6080 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6081 workspaces: vec![WorkspaceSnapshot {
6082 workspace_id: WorkspaceId::new("primary").unwrap(),
6083 canonical_root: host_path(r"C:\workspace"),
6084 sessions: vec![session],
6085 worktree_service_mode: None,
6086 managed_worktree_profiles: None,
6087 }],
6088 session_records: Vec::new(),
6089 managed_worktrees: Vec::new(),
6090 launch_inventory: None,
6091 agent_progress: Vec::new(),
6092 },
6093 });
6094
6095 let json = serde_json::to_string(&routed).unwrap();
6096 for secret in [
6097 "private-session-failure",
6098 "private-terminal-error",
6099 "private-history-error",
6100 "private-resume-summary-id",
6101 "private-resume-error",
6102 "private-foreground-error",
6103 "private-snapshot-provider-id",
6104 "snapshot-transcript.jsonl",
6105 "private-current-prompt",
6106 "private-provider-error",
6107 ] {
6108 assert!(!json.contains(secret));
6109 }
6110 assert!(!json.contains("provider_session"));
6111 assert!(!json.contains("transcript_path"));
6112 assert!(!json.contains("last_error"));
6113 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
6114 assert!(matches!(decoded.response, Ok(C2NodeResponse::Snapshot { snapshot, .. })
6115 if matches!(snapshot.workspaces[0].sessions[0].status, C2SessionStatus::Failed)
6116 && snapshot.workspaces[0].sessions[0].provider_identity_present));
6117 }
6118
6119 #[test]
6120 fn slim_managed_sessions_are_sorted_bounded_and_privacy_minimized() {
6121 let long_name = "ж".repeat((MAX_C2_SESSION_DISPLAY_NAME_BYTES / 2) + 4);
6122 let make_record = |record_id: &str, display_name: String| ManagedSessionRecord {
6123 record_id: SessionRecordId::new(record_id).unwrap(),
6124 display_name,
6125 provider: provider("codex"),
6126 mode: SessionMode::Pty,
6127 state: ManagedSessionState::Dormant,
6128 workspace_id: WorkspaceId::new("primary").unwrap(),
6129 canonical_root: host_path(r"C:\private\workspace"),
6130 provider_session: Some(ProviderSessionIdentity {
6131 key: ProviderSessionKey::SessionId,
6132 id: "5af75a6b-3e64-41dd-96fa-private-provider-id".to_owned(),
6133 transcript_path: Some(r"C:\private\transcript.jsonl".to_owned()),
6134 }),
6135 active_session: None,
6136 environment_profile: None,
6137 bundle: None,
6138 context_id: None,
6139 context: None,
6140 exported_context: None,
6141 task_binding: None,
6142 created_at_unix_ms: 10,
6143 updated_at_unix_ms: 20,
6144 last_error: Some("private backend diagnostic".to_owned()),
6145 };
6146 let snapshot = NodeSnapshot {
6147 node_id: NodeId::new("node-a").unwrap(),
6148 enabled_providers: vec![provider("codex")],
6149 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6150 workspaces: Vec::new(),
6151 session_records: vec![
6152 make_record("session-z", "z".to_owned()),
6153 make_record("session-a", long_name),
6154 ],
6155 managed_worktrees: Vec::new(),
6156 launch_inventory: None,
6157 agent_progress: Vec::new(),
6158 };
6159
6160 let slim = SlimNodeInventory::from_snapshot(&snapshot);
6161 assert_eq!(slim.managed_session_count, 2);
6162 assert!(!slim.managed_sessions_truncated);
6163 assert_eq!(
6164 slim.managed_sessions.iter().map(|record| record.record_id.as_str()).collect::<Vec<_>>(),
6165 vec!["session-a", "session-z"],
6166 );
6167 assert_eq!(slim.managed_sessions[0].display_name.len(), MAX_C2_SESSION_DISPLAY_NAME_BYTES);
6168 assert!(slim.managed_sessions[0].display_name_truncated);
6169 assert!(slim.managed_sessions[0].provider_identity_present);
6170
6171 let json = serde_json::to_string(&slim).unwrap();
6172 assert!(!json.contains("5af75a6b-3e64-41dd-96fa-private-provider-id"));
6173 assert!(!json.contains("transcript.jsonl"));
6174 assert!(!json.contains("private backend diagnostic"));
6175 assert!(!json.contains("private\\\\workspace"));
6176 assert!(!json.contains("provider_session"));
6177 assert!(!json.contains("last_error"));
6178 }
6179
6180 #[test]
6181 fn slim_inventory_defaults_managed_sessions_for_v1_payloads() {
6182 let legacy = r#"{"node_id":"node-a","enabled_providers":[],"workspaces":{},"workspace_count":0,"workspaces_truncated":false,"session_count":0,"sessions_truncated":false}"#;
6183 let inventory = serde_json::from_str::<SlimNodeInventory>(legacy).unwrap();
6184 assert!(inventory.provider_contracts.is_empty());
6185 assert!(inventory.provider_adapter_contracts.is_empty());
6186 assert!(inventory.managed_sessions.is_empty());
6187 assert_eq!(inventory.managed_session_count, 0);
6188 assert!(!inventory.managed_sessions_truncated);
6189 let reencoded = serde_json::to_string(&inventory).unwrap();
6190 assert!(!reencoded.contains("provider_contracts"));
6191 assert!(!reencoded.contains("provider_adapter_contracts"));
6192 assert_eq!(inventory.retired_count, 0);
6193 assert!(!reencoded.contains("retired_count"));
6194 }
6195
6196 #[test]
6201 fn slim_inventory_retired_count_round_trips_and_defaults_to_zero() {
6202 let legacy = r#"{"node_id":"node-a","enabled_providers":[],"workspaces":{},"workspace_count":0,"workspaces_truncated":false,"session_count":0,"sessions_truncated":false}"#;
6203 let inventory = serde_json::from_str::<SlimNodeInventory>(legacy).unwrap();
6204 assert_eq!(inventory.retired_count, 0);
6205
6206 let mut inventory = inventory;
6207 inventory.retired_count = 7;
6208 let encoded = serde_json::to_string(&inventory).unwrap();
6209 assert!(encoded.contains("\"retired_count\":7"));
6210 let decoded: SlimNodeInventory = serde_json::from_str(&encoded).unwrap();
6211 assert_eq!(decoded.retired_count, 7);
6212 }
6213
6214 #[test]
6215 fn slim_inventory_reports_managed_session_truncation() {
6216 let session_records = (0..=MAX_C2_MANAGED_SESSIONS_PER_NODE)
6217 .map(|index| ManagedSessionRecord {
6218 record_id: SessionRecordId::new(format!("session-{index:03}")).unwrap(),
6219 display_name: format!("session {index}"),
6220 provider: provider("claude"),
6221 mode: SessionMode::Inline,
6222 state: ManagedSessionState::Unavailable,
6223 workspace_id: WorkspaceId::new("primary").unwrap(),
6224 canonical_root: host_path(r"C:\repo"),
6225 provider_session: None,
6226 active_session: None,
6227 environment_profile: None,
6228 bundle: None,
6229 context_id: None,
6230 context: None,
6231 exported_context: None,
6232 task_binding: None,
6233 created_at_unix_ms: index as u64,
6234 updated_at_unix_ms: index as u64,
6235 last_error: None,
6236 })
6237 .collect::<Vec<_>>();
6238 let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
6239 node_id: NodeId::new("node-a").unwrap(),
6240 enabled_providers: Vec::new(),
6241 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6242 workspaces: Vec::new(),
6243 session_records,
6244 managed_worktrees: Vec::new(),
6245 launch_inventory: None,
6246 agent_progress: Vec::new(),
6247 });
6248 assert_eq!(slim.managed_session_count, MAX_C2_MANAGED_SESSIONS_PER_NODE + 1);
6249 assert_eq!(slim.managed_sessions.len(), MAX_C2_MANAGED_SESSIONS_PER_NODE);
6250 assert!(slim.managed_sessions_truncated);
6251 }
6252
6253 #[test]
6254 fn slim_managed_sessions_page_keeps_the_live_record_even_when_it_sorts_last_by_id() {
6255 let stale_count = MAX_C2_MANAGED_SESSIONS_PER_NODE + 2;
6265 let mut session_records = (0..stale_count)
6266 .map(|index| ManagedSessionRecord {
6267 record_id: SessionRecordId::new(format!("session-{index:03}")).unwrap(),
6268 display_name: format!("session {index}"),
6269 provider: provider("claude"),
6270 mode: SessionMode::Inline,
6271 state: ManagedSessionState::Unavailable,
6272 workspace_id: WorkspaceId::new("primary").unwrap(),
6273 canonical_root: host_path(r"C:\repo"),
6274 provider_session: None,
6275 active_session: None,
6276 environment_profile: None,
6277 bundle: None,
6278 context_id: None,
6279 context: None,
6280 exported_context: None,
6281 task_binding: None,
6282 created_at_unix_ms: index as u64,
6283 updated_at_unix_ms: index as u64,
6284 last_error: None,
6285 })
6286 .collect::<Vec<_>>();
6287 session_records.push(ManagedSessionRecord {
6288 record_id: SessionRecordId::new(format!("session-{stale_count:03}")).unwrap(),
6289 display_name: "live-run".to_owned(),
6290 provider: provider("claude"),
6291 mode: SessionMode::Pty,
6292 state: ManagedSessionState::Live,
6293 workspace_id: WorkspaceId::new("primary").unwrap(),
6294 canonical_root: host_path(r"C:\repo"),
6295 provider_session: None,
6296 active_session: None,
6297 environment_profile: None,
6298 bundle: None,
6299 context_id: None,
6300 context: None,
6301 exported_context: None,
6302 task_binding: None,
6303 created_at_unix_ms: stale_count as u64,
6304 updated_at_unix_ms: stale_count as u64,
6305 last_error: None,
6306 });
6307 let pending_index = stale_count + 1;
6308 session_records.push(ManagedSessionRecord {
6309 record_id: SessionRecordId::new(format!("session-{pending_index:03}")).unwrap(),
6310 display_name: "pending-spawn".to_owned(),
6311 provider: provider("claude"),
6312 mode: SessionMode::Pty,
6313 state: ManagedSessionState::IdentityPending,
6314 workspace_id: WorkspaceId::new("primary").unwrap(),
6315 canonical_root: host_path(r"C:\repo"),
6316 provider_session: None,
6317 active_session: None,
6318 environment_profile: None,
6319 bundle: None,
6320 context_id: None,
6321 context: None,
6322 exported_context: None,
6323 task_binding: None,
6324 created_at_unix_ms: pending_index as u64,
6325 updated_at_unix_ms: pending_index as u64,
6326 last_error: None,
6327 });
6328 let total = session_records.len();
6329 assert!(total > MAX_C2_MANAGED_SESSIONS_PER_NODE);
6330
6331 let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
6332 node_id: NodeId::new("node-a").unwrap(),
6333 enabled_providers: Vec::new(),
6334 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6335 workspaces: Vec::new(),
6336 session_records,
6337 managed_worktrees: Vec::new(),
6338 launch_inventory: None,
6339 agent_progress: Vec::new(),
6340 });
6341
6342 assert_eq!(slim.managed_session_count, total);
6343 assert!(slim.managed_sessions_truncated);
6344 assert_eq!(slim.managed_sessions.len(), MAX_C2_MANAGED_SESSIONS_PER_NODE);
6345 assert!(
6346 slim.managed_sessions.iter().any(|record| record.state == ManagedSessionState::Live),
6347 "the live record must survive the page cut even though its id sorts last",
6348 );
6349 assert!(
6350 slim.managed_sessions.iter()
6351 .any(|record| record.state == ManagedSessionState::IdentityPending),
6352 "the identity-pending record must survive the page cut even though its id sorts last",
6353 );
6354 assert_eq!(slim.managed_sessions[0].state, ManagedSessionState::Live);
6355 assert_eq!(slim.managed_sessions[1].state, ManagedSessionState::IdentityPending);
6356 assert!(
6357 slim.managed_sessions[2..].iter().all(|record| record.state == ManagedSessionState::Unavailable),
6358 "every Unavailable record on the page must rank after Live and IdentityPending",
6359 );
6360 }
6361
6362 #[test]
6363 fn managed_worktree_projection_is_bounded_and_contains_no_git_or_path_details() {
6364 fn lease(index: usize) -> ManagedWorktreeLeaseSnapshot {
6365 ManagedWorktreeLeaseSnapshot {
6366 lease_id: ManagedWorktreeLeaseId::new(format!("lease-{index}")).unwrap(),
6367 source_workspace_id: WorkspaceId::new("primary").unwrap(),
6368 workspace_id: WorkspaceId::new(format!("managed-{index}")).unwrap(),
6369 profile_id: WorktreeProfileId::new("review").unwrap(),
6370 profile_revision: WorktreeProfileRevision::new("review.r1").unwrap(),
6371 retention: ManagedWorktreeRetention::RemoveWhenReleased,
6372 state: ManagedWorktreeLeaseState::Ready,
6373 active_session_count: 0,
6374 managed_record_count: 0,
6375 cleanup_failure: None,
6376 created_at_unix_ms: 1,
6377 updated_at_unix_ms: 2,
6378 }
6379 }
6380
6381 let projected = C2NodeSnapshot::from(&NodeSnapshot {
6382 node_id: NodeId::new("node-a").unwrap(),
6383 enabled_providers: Vec::new(),
6384 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6385 workspaces: Vec::new(),
6386 session_records: Vec::new(),
6387 managed_worktrees: vec![lease(0)],
6388 launch_inventory: None,
6389 agent_progress: Vec::new(),
6390 });
6391 let json = serde_json::to_string(&projected).unwrap();
6392 assert!(json.contains("managed_worktrees"));
6393 for forbidden in ["canonical", "path", "gitdir", "branch", "base_commit", "diagnostic"] {
6394 assert!(!json.contains(forbidden), "managed projection leaked {forbidden}");
6395 }
6396
6397 let overflow = C2NodeSnapshot {
6398 node_id: NodeId::new("node-a").unwrap(),
6399 enabled_providers: Vec::new(),
6400 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6401 workspaces: Vec::new(),
6402 session_records: Vec::new(),
6403 agent_progress: Vec::new(),
6404 managed_worktrees: (0..=MAX_C2_MANAGED_WORKTREES_PER_NODE)
6405 .map(lease)
6406 .collect(),
6407 launch_inventory: None,
6408 };
6409 let encoded = serde_json::to_value(overflow).unwrap();
6410 assert!(serde_json::from_value::<C2NodeSnapshot>(encoded).is_err());
6411 }
6412}