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