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