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}
2543
2544#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2545pub struct C2ReplyEnvelope {
2546 pub request_id: C2RequestId,
2547 pub result: Result<RoutedNodeResponse, C2RelayFailure>,
2548}
2549
2550
2551#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2553pub struct BrowserClientCommand {
2554 pub node_id: String,
2555 pub op: BrowserClientOp,
2556}
2557
2558#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2559#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
2560pub enum C2ClientFrame {
2561 Hello(C2ClientHello),
2562 Authenticate(C2ClientAuthentication),
2563 Request(C2RequestEnvelope),
2564 Browser(BrowserClientCommand),
2566}
2567
2568#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2569#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
2570pub enum C2ServerFrame {
2571 Challenge(C2ServerChallenge),
2572 Hello(C2Hello),
2573 Reply(C2ReplyEnvelope),
2574 Event(RoutedNodeEvent),
2575 Browser(BrowserStreamEvent),
2577 Topology(C2Topology),
2578 Rejected(C2RelayFailure),
2579}
2580
2581#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2582pub enum C2AuthDirection {
2583 Server,
2584 Client,
2585}
2586
2587pub fn c2_auth_transcript(
2588 direction: C2AuthDirection,
2589 client_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2590 server_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2591) -> Vec<u8> {
2592 let mut message = Vec::with_capacity(32 + (C2_AUTH_NONCE_BYTES * 2));
2593 message.extend_from_slice(b"gate4agent-c2-control-auth-v2\0");
2594 encode_bounded_str(&mut message, BUILD_STAMP);
2595 message.push(match direction { C2AuthDirection::Server => 1, C2AuthDirection::Client => 2 });
2596 message.extend_from_slice(client_nonce);
2597 message.extend_from_slice(server_nonce);
2598 message
2599}
2600
2601#[derive(Clone, Debug, Eq, PartialEq)]
2602pub enum C2AuthTranscriptError {
2603 TooManyCapabilities {
2604 section: &'static str,
2605 count: usize,
2606 max: usize,
2607 },
2608 TooLong {
2609 len: usize,
2610 max: usize,
2611 },
2612}
2613
2614impl fmt::Display for C2AuthTranscriptError {
2615 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2616 match self {
2617 Self::TooManyCapabilities { section, count, max } => write!(
2618 formatter,
2619 "{section} contains {count} capabilities, exceeding the {max}-entry authentication limit",
2620 ),
2621 Self::TooLong { len, max } => write!(
2622 formatter,
2623 "C2 compatibility authentication transcript is {len} bytes, exceeding the {max}-byte limit",
2624 ),
2625 }
2626 }
2627}
2628
2629impl std::error::Error for C2AuthTranscriptError {}
2630
2631pub fn c2_bound_auth_transcript(
2632 direction: C2AuthDirection,
2633 client_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2634 server_nonce: &[u8; C2_AUTH_NONCE_BYTES],
2635 offer: &ClientCompatibilityOffer,
2636 selected: &NegotiatedC2ControlCompatibility,
2637) -> Result<Vec<u8>, C2AuthTranscriptError> {
2638 validate_auth_capabilities("offer", &offer.capabilities)?;
2639 validate_auth_capabilities("selection", &selected.capabilities)?;
2640
2641 let mut message = Vec::with_capacity(512);
2642 message.extend_from_slice(b"gate4agent-c2-control-auth-v2-compatibility\0");
2643 encode_bounded_str(&mut message, BUILD_STAMP);
2644 message.push(match direction { C2AuthDirection::Server => 1, C2AuthDirection::Client => 2 });
2645 message.extend_from_slice(client_nonce);
2646 message.extend_from_slice(server_nonce);
2647
2648 message.extend_from_slice(b"offer\0");
2649 encode_bounded_str(&mut message, &offer.build_stamp);
2650 encode_capabilities(&mut message, &offer.capabilities);
2651 match offer.state_schema {
2652 Some(state_schema) => {
2653 message.push(1);
2654 encode_protocol_range(&mut message, state_schema.versions);
2655 }
2656 None => message.push(0),
2657 }
2658
2659 message.extend_from_slice(b"selected\0");
2660 encode_bounded_str(&mut message, &selected.build_stamp);
2661 encode_capabilities(&mut message, &selected.capabilities);
2662 encode_bounded_str(&mut message, selected.host.operating_system.as_str());
2663 encode_bounded_str(&mut message, selected.host.architecture.as_str());
2664 message.push(match selected.path_semantics.style {
2665 PathStyle::Windows => 1,
2666 PathStyle::Posix => 2,
2667 });
2668 message.push(match selected.path_semantics.encoding {
2669 PathEncoding::Utf8 => 1,
2670 PathEncoding::UnixBytes => 2,
2671 });
2672
2673 if message.len() > MAX_C2_BOUND_AUTH_TRANSCRIPT_BYTES {
2674 return Err(C2AuthTranscriptError::TooLong {
2675 len: message.len(),
2676 max: MAX_C2_BOUND_AUTH_TRANSCRIPT_BYTES,
2677 });
2678 }
2679 Ok(message)
2680}
2681
2682fn validate_auth_capabilities(
2683 section: &'static str,
2684 capabilities: &[CapabilityId],
2685) -> Result<(), C2AuthTranscriptError> {
2686 if capabilities.len() > MAX_C2_AUTH_COMPATIBILITY_CAPABILITIES {
2687 return Err(C2AuthTranscriptError::TooManyCapabilities {
2688 section,
2689 count: capabilities.len(),
2690 max: MAX_C2_AUTH_COMPATIBILITY_CAPABILITIES,
2691 });
2692 }
2693 Ok(())
2694}
2695
2696fn encode_protocol_range(message: &mut Vec<u8>, range: ProtocolRange) {
2697 message.extend_from_slice(&range.minimum().to_le_bytes());
2698 message.extend_from_slice(&range.maximum().to_le_bytes());
2699}
2700
2701fn encode_capabilities(message: &mut Vec<u8>, capabilities: &[CapabilityId]) {
2702 message.extend_from_slice(&(capabilities.len() as u16).to_le_bytes());
2703 for capability in capabilities {
2704 encode_bounded_str(message, capability.as_str());
2705 }
2706}
2707
2708fn encode_bounded_str(message: &mut Vec<u8>, value: &str) {
2709 debug_assert!(value.len() <= u16::MAX as usize);
2710 message.extend_from_slice(&(value.len() as u16).to_le_bytes());
2711 message.extend_from_slice(value.as_bytes());
2712}
2713
2714#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2715#[serde(rename_all = "kebab-case")]
2716pub enum NodeTransportState {
2717 Online,
2718 Offline,
2719 Parked,
2720}
2721
2722#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2723#[serde(rename_all = "kebab-case")]
2724pub enum NodeFreshness {
2725 Fresh,
2726 Stale,
2727 Unavailable,
2728}
2729
2730#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2731#[serde(rename_all = "kebab-case")]
2732pub enum GapKind {
2733 IncarnationChanged,
2734 HistoryEvicted,
2735 NonContiguousEvents,
2736 CursorRegression,
2737}
2738
2739#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2740pub struct NodeGap {
2741 pub kind: GapKind,
2742 pub detected_at_unix_ms: u64,
2743 pub previous: Option<NodeCursor>,
2744 pub observed: NodeCursor,
2745}
2746
2747#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2748#[serde(rename_all = "kebab-case")]
2749pub enum C2ErrorCategory {
2750 Authentication,
2751 Identity,
2752 Protocol,
2753 Transport,
2754 Timeout,
2755 Internal,
2756}
2757
2758#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2759pub struct SanitizedError {
2760 pub category: C2ErrorCategory,
2761 pub message: String,
2762}
2763
2764#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2765pub struct SlimSession {
2766 pub instance_id: AgentInstanceId,
2767 pub generation: SessionGeneration,
2768 pub agent_id: String,
2769 pub transport: TransportKind,
2770 pub status: SlimSessionStatus,
2771 pub process_id: Option<u32>,
2772 pub terminal_size: Option<TerminalSize>,
2773 pub operation_pending: bool,
2774 pub input_pending: bool,
2775 #[serde(default)]
2781 pub screen_state: PtyScreenState,
2782}
2783
2784#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2785#[serde(rename_all = "kebab-case")]
2786pub enum SlimSessionStatus {
2787 Registered,
2788 Starting,
2789 Running,
2790 Stopping,
2791 Exited,
2792 Failed,
2793}
2794
2795impl From<&SessionStatus> for SlimSessionStatus {
2796 fn from(status: &SessionStatus) -> Self {
2797 match status {
2798 SessionStatus::Registered => Self::Registered,
2799 SessionStatus::Starting => Self::Starting,
2800 SessionStatus::Running => Self::Running,
2801 SessionStatus::Stopping => Self::Stopping,
2802 SessionStatus::Exited { .. } => Self::Exited,
2803 SessionStatus::Failed { .. } => Self::Failed,
2804 }
2805 }
2806}
2807
2808impl From<&C2SessionStatus> for SlimSessionStatus {
2809 fn from(status: &C2SessionStatus) -> Self {
2810 match status {
2811 C2SessionStatus::Registered => Self::Registered,
2812 C2SessionStatus::Starting => Self::Starting,
2813 C2SessionStatus::Running => Self::Running,
2814 C2SessionStatus::Stopping => Self::Stopping,
2815 C2SessionStatus::Exited { .. } => Self::Exited,
2816 C2SessionStatus::Failed => Self::Failed,
2817 }
2818 }
2819}
2820
2821#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2822pub struct SlimWorkspace {
2823 pub workspace_id: WorkspaceId,
2824 pub canonical_root: String,
2825 pub canonical_root_truncated: bool,
2826 pub sessions: Vec<SlimSession>,
2827 pub session_count: usize,
2828 pub sessions_truncated: bool,
2829 #[serde(default, skip_serializing_if = "Option::is_none")]
2830 pub worktree_service_mode: Option<WorktreeServiceMode>,
2831 #[serde(default, skip_serializing_if = "Option::is_none")]
2832 pub managed_worktree_profiles: Option<WorktreeProfileInventory>,
2833}
2834
2835#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2836pub struct SlimManagedSessionRecord {
2837 pub record_id: SessionRecordId,
2838 pub display_name: String,
2839 pub display_name_truncated: bool,
2840 pub provider: AgentId,
2841 pub mode: SessionMode,
2842 pub state: ManagedSessionState,
2843 pub workspace_id: WorkspaceId,
2844 pub active_session: Option<SessionAddress>,
2845 #[serde(default, skip_serializing_if = "Option::is_none")]
2846 pub environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
2847 #[serde(default, skip_serializing_if = "Option::is_none")]
2848 pub bundle: Option<ResolvedBundleReceipt>,
2849 pub provider_identity_present: bool,
2850 pub updated_at_unix_ms: u64,
2851}
2852
2853#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2854pub struct SlimNodeInventory {
2855 pub node_id: NodeId,
2856 pub enabled_providers: Vec<AgentId>,
2857 #[serde(default, skip_serializing_if = "ProviderRuntimeStatuses::is_empty")]
2858 pub provider_runtime_statuses: ProviderRuntimeStatuses,
2859 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2860 pub provider_contracts: Vec<ProviderContractSupport>,
2861 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2862 pub provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
2863 pub workspaces: BTreeMap<WorkspaceId, SlimWorkspace>,
2864 pub workspace_count: usize,
2865 pub workspaces_truncated: bool,
2866 pub session_count: usize,
2867 pub sessions_truncated: bool,
2868 #[serde(default)]
2869 pub managed_sessions: Vec<SlimManagedSessionRecord>,
2870 #[serde(default)]
2871 pub managed_session_count: usize,
2872 #[serde(default)]
2873 pub managed_sessions_truncated: bool,
2874 #[serde(default, skip_serializing_if = "usize_is_zero")]
2882 pub retired_count: usize,
2883 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2884 pub managed_worktrees: Vec<ManagedWorktreeLeaseSnapshot>,
2885 #[serde(default, skip_serializing_if = "usize_is_zero")]
2886 pub managed_worktree_count: usize,
2887 #[serde(default, skip_serializing_if = "bool_is_false")]
2888 pub managed_worktrees_truncated: bool,
2889 #[serde(default, skip_serializing_if = "Option::is_none")]
2890 pub launch_inventory: Option<LaunchInventory>,
2891}
2892
2893fn usize_is_zero(value: &usize) -> bool { *value == 0 }
2894
2895fn bool_is_false(value: &bool) -> bool { !*value }
2896
2897fn managed_session_liveness_rank(state: &ManagedSessionState) -> u8 {
2909 match state {
2910 ManagedSessionState::Live => 0,
2911 ManagedSessionState::IdentityPending => 1,
2912 ManagedSessionState::Dormant => 2,
2913 ManagedSessionState::Unavailable => 3,
2914 }
2915}
2916
2917fn session_liveness_rank(status: &SessionStatus) -> u8 {
2924 match status {
2925 SessionStatus::Running => 0,
2926 SessionStatus::Starting | SessionStatus::Stopping => 1,
2927 SessionStatus::Registered => 2,
2928 SessionStatus::Exited { .. } | SessionStatus::Failed { .. } => 3,
2929 }
2930}
2931
2932fn c2_session_liveness_rank(status: &C2SessionStatus) -> u8 {
2934 match status {
2935 C2SessionStatus::Running => 0,
2936 C2SessionStatus::Starting | C2SessionStatus::Stopping => 1,
2937 C2SessionStatus::Registered => 2,
2938 C2SessionStatus::Exited { .. } | C2SessionStatus::Failed => 3,
2939 }
2940}
2941
2942impl SlimNodeInventory {
2943 pub fn from_snapshot(snapshot: &NodeSnapshot) -> Self {
2944 let mut providers = snapshot.enabled_providers.clone();
2945 providers.sort();
2946 providers.dedup();
2947 let workspace_count = snapshot.workspaces.len();
2948 let session_count = snapshot.workspaces.iter().map(|workspace| workspace.sessions.len()).sum();
2949 let mut remaining_sessions = MAX_C2_SESSIONS_PER_NODE;
2950 let mut workspaces = BTreeMap::new();
2951 let mut ordered = snapshot.workspaces.iter().collect::<Vec<_>>();
2952 ordered.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id));
2953 for workspace in ordered.into_iter().take(MAX_C2_WORKSPACES_PER_NODE) {
2954 let mut sessions = workspace.sessions.iter().collect::<Vec<_>>();
2955 sessions.sort_by(|left, right| {
2956 session_liveness_rank(&left.status)
2957 .cmp(&session_liveness_rank(&right.status))
2958 .then_with(|| left.instance_id.cmp(&right.instance_id))
2959 .then_with(|| left.generation.cmp(&right.generation))
2960 });
2961 let take = remaining_sessions.min(sessions.len());
2962 let slim_sessions = sessions.into_iter().take(take).map(|session| SlimSession {
2963 instance_id: session.instance_id,
2964 generation: session.generation,
2965 agent_id: session.agent_id.as_str().to_owned(),
2966 transport: session.transport,
2967 status: SlimSessionStatus::from(&session.status),
2968 process_id: session.process_id,
2969 terminal_size: session.terminal_size,
2970 operation_pending: session.pending_operation.is_some(),
2971 input_pending: session.pending_input.is_some(),
2972 screen_state: session.screen_state.clone(),
2973 }).collect();
2974 remaining_sessions -= take;
2975 let display_root = sanitize_host_path_display(&workspace.canonical_root);
2976 let (canonical_root, canonical_root_truncated) =
2977 truncate_utf8(&display_root, MAX_C2_ROOT_BYTES);
2978 workspaces.insert(workspace.workspace_id.clone(), SlimWorkspace {
2979 workspace_id: workspace.workspace_id.clone(),
2980 canonical_root,
2981 canonical_root_truncated,
2982 sessions: slim_sessions,
2983 session_count: workspace.sessions.len(),
2984 sessions_truncated: workspace.sessions.len() > take,
2985 worktree_service_mode: workspace.worktree_service_mode,
2986 managed_worktree_profiles: workspace.managed_worktree_profiles.clone(),
2987 });
2988 }
2989 let included_session_count = workspaces
2990 .values()
2991 .map(|workspace| workspace.sessions.len())
2992 .sum::<usize>();
2993 let managed_session_count = snapshot.session_records.len();
2994 let mut ordered_records = snapshot.session_records.iter().collect::<Vec<_>>();
2995 ordered_records.sort_by(|left, right| {
2996 managed_session_liveness_rank(&left.state)
2997 .cmp(&managed_session_liveness_rank(&right.state))
2998 .then_with(|| left.record_id.cmp(&right.record_id))
2999 });
3000 let managed_sessions = ordered_records
3001 .into_iter()
3002 .take(MAX_C2_MANAGED_SESSIONS_PER_NODE)
3003 .map(SlimManagedSessionRecord::from)
3004 .collect::<Vec<_>>();
3005 let managed_worktree_count = snapshot.managed_worktrees.len();
3006 let mut managed_worktrees = snapshot.managed_worktrees.clone();
3007 managed_worktrees.sort_by(|left, right| left.lease_id.cmp(&right.lease_id));
3008 managed_worktrees.truncate(MAX_C2_MANAGED_WORKTREES_PER_NODE);
3009 Self {
3010 node_id: snapshot.node_id.clone(),
3011 enabled_providers: providers,
3012 provider_runtime_statuses: snapshot.provider_runtime_statuses.clone(),
3013 provider_contracts: Vec::new(),
3014 provider_adapter_contracts: Vec::new(),
3015 workspaces,
3016 workspace_count,
3017 workspaces_truncated: workspace_count > MAX_C2_WORKSPACES_PER_NODE,
3018 session_count,
3019 sessions_truncated: included_session_count < session_count,
3020 managed_sessions_truncated: managed_sessions.len() < managed_session_count,
3021 managed_sessions,
3022 managed_session_count,
3023 retired_count: 0,
3029 managed_worktrees_truncated:
3030 managed_worktrees.len() < managed_worktree_count,
3031 managed_worktrees,
3032 managed_worktree_count,
3033 launch_inventory: snapshot.launch_inventory.clone(),
3034 }
3035 }
3036
3037 pub fn from_c2_snapshot(snapshot: &C2NodeSnapshot) -> Self {
3038 let mut providers = snapshot.enabled_providers.clone();
3039 providers.sort();
3040 providers.dedup();
3041 let workspace_count = snapshot.workspaces.len();
3042 let session_count = snapshot.workspaces.iter().map(|workspace| workspace.sessions.len()).sum();
3043 let mut remaining_sessions = MAX_C2_SESSIONS_PER_NODE;
3044 let mut workspaces = BTreeMap::new();
3045 let mut ordered = snapshot.workspaces.iter().collect::<Vec<_>>();
3046 ordered.sort_by(|left, right| left.workspace_id.cmp(&right.workspace_id));
3047 for workspace in ordered.into_iter().take(MAX_C2_WORKSPACES_PER_NODE) {
3048 let mut sessions = workspace.sessions.iter().collect::<Vec<_>>();
3049 sessions.sort_by(|left, right| {
3050 c2_session_liveness_rank(&left.status)
3051 .cmp(&c2_session_liveness_rank(&right.status))
3052 .then_with(|| left.instance_id.cmp(&right.instance_id))
3053 .then_with(|| left.generation.cmp(&right.generation))
3054 });
3055 let take = remaining_sessions.min(sessions.len());
3056 let slim_sessions = sessions.into_iter().take(take).map(|session| SlimSession {
3057 instance_id: session.instance_id,
3058 generation: session.generation,
3059 agent_id: session.agent_id.as_str().to_owned(),
3060 transport: session.transport,
3061 status: SlimSessionStatus::from(&session.status),
3062 process_id: session.process_id,
3063 terminal_size: session.terminal_size,
3064 operation_pending: session.pending_operation.is_some(),
3065 input_pending: session.pending_input.is_some(),
3066 screen_state: session.screen_state.clone(),
3067 }).collect();
3068 remaining_sessions -= take;
3069 let display_root = sanitize_host_path_display(&workspace.canonical_root);
3070 let (canonical_root, canonical_root_truncated) =
3071 truncate_utf8(&display_root, MAX_C2_ROOT_BYTES);
3072 workspaces.insert(workspace.workspace_id.clone(), SlimWorkspace {
3073 workspace_id: workspace.workspace_id.clone(),
3074 canonical_root,
3075 canonical_root_truncated,
3076 sessions: slim_sessions,
3077 session_count: workspace.sessions.len(),
3078 sessions_truncated: workspace.sessions.len() > take,
3079 worktree_service_mode: workspace.worktree_service_mode,
3080 managed_worktree_profiles: workspace.managed_worktree_profiles.clone(),
3081 });
3082 }
3083 let included_session_count = workspaces.values()
3084 .map(|workspace| workspace.sessions.len())
3085 .sum::<usize>();
3086 let managed_session_count = snapshot.session_records.len();
3087 let mut ordered_records = snapshot.session_records.iter().collect::<Vec<_>>();
3088 ordered_records.sort_by(|left, right| {
3089 managed_session_liveness_rank(&left.state)
3090 .cmp(&managed_session_liveness_rank(&right.state))
3091 .then_with(|| left.record_id.cmp(&right.record_id))
3092 });
3093 let managed_sessions = ordered_records.into_iter()
3094 .take(MAX_C2_MANAGED_SESSIONS_PER_NODE)
3095 .map(SlimManagedSessionRecord::from)
3096 .collect::<Vec<_>>();
3097 let managed_worktree_count = snapshot.managed_worktrees.len();
3098 let mut managed_worktrees = snapshot.managed_worktrees.clone();
3099 managed_worktrees.sort_by(|left, right| left.lease_id.cmp(&right.lease_id));
3100 managed_worktrees.truncate(MAX_C2_MANAGED_WORKTREES_PER_NODE);
3101 Self {
3102 node_id: snapshot.node_id.clone(),
3103 enabled_providers: providers,
3104 provider_runtime_statuses: snapshot.provider_runtime_statuses.clone(),
3105 provider_contracts: Vec::new(),
3106 provider_adapter_contracts: Vec::new(),
3107 workspaces,
3108 workspace_count,
3109 workspaces_truncated: workspace_count > MAX_C2_WORKSPACES_PER_NODE,
3110 session_count,
3111 sessions_truncated: included_session_count < session_count,
3112 managed_sessions_truncated: managed_sessions.len() < managed_session_count,
3113 managed_sessions,
3114 managed_session_count,
3115 retired_count: 0,
3121 managed_worktrees_truncated:
3122 managed_worktrees.len() < managed_worktree_count,
3123 managed_worktrees,
3124 managed_worktree_count,
3125 launch_inventory: snapshot.launch_inventory.clone(),
3126 }
3127 }
3128}
3129
3130impl From<&ManagedSessionRecord> for SlimManagedSessionRecord {
3131 fn from(record: &ManagedSessionRecord) -> Self {
3132 let (display_name, display_name_truncated) =
3133 truncate_utf8(&record.display_name, MAX_C2_SESSION_DISPLAY_NAME_BYTES);
3134 Self {
3135 record_id: record.record_id.clone(),
3137 display_name,
3138 display_name_truncated,
3139 provider: record.provider.clone(),
3140 mode: record.mode,
3141 state: record.state,
3142 workspace_id: record.workspace_id.clone(),
3143 active_session: record.active_session.clone(),
3144 environment_profile: record.environment_profile.clone(),
3145 bundle: record.bundle.clone(),
3146 provider_identity_present: record.provider_session.is_some(),
3147 updated_at_unix_ms: record.updated_at_unix_ms,
3148 }
3149 }
3150}
3151
3152impl From<&C2ManagedSessionRecord> for SlimManagedSessionRecord {
3153 fn from(record: &C2ManagedSessionRecord) -> Self {
3154 let (display_name, display_name_truncated) =
3155 truncate_utf8(&record.display_name, MAX_C2_SESSION_DISPLAY_NAME_BYTES);
3156 Self {
3157 record_id: record.record_id.clone(),
3159 display_name,
3160 display_name_truncated,
3161 provider: record.provider.clone(),
3162 mode: record.mode,
3163 state: record.state,
3164 workspace_id: record.workspace_id.clone(),
3165 active_session: record.active_session.clone(),
3166 environment_profile: record.environment_profile.clone(),
3167 bundle: record.bundle.clone(),
3168 provider_identity_present: record.provider_identity_present,
3169 updated_at_unix_ms: record.updated_at_unix_ms,
3170 }
3171 }
3172}
3173
3174fn truncate_utf8(value: &str, max_bytes: usize) -> (String, bool) {
3175 if value.len() <= max_bytes {
3176 return (value.to_owned(), false);
3177 }
3178 let mut end = max_bytes;
3179 while !value.is_char_boundary(end) {
3180 end -= 1;
3181 }
3182 (value[..end].to_owned(), true)
3183}
3184
3185fn sanitize_host_path_display(path: &OpaqueHostPath) -> String {
3186 let mut sanitized = String::new();
3187 for ch in path.display_text().chars() {
3188 if ch.is_control() {
3189 sanitized.extend(ch.escape_default());
3190 } else {
3191 sanitized.push(ch);
3192 }
3193 }
3194 sanitized
3195}
3196
3197#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3198pub struct ProviderCliReport {
3199 pub id: String,
3200 #[serde(default, skip_serializing_if = "Option::is_none")]
3201 pub version: Option<String>,
3202 #[serde(default, skip_serializing_if = "Option::is_none")]
3203 pub path: Option<String>,
3204}
3205
3206#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3208pub struct MachineEnvironment {
3209 pub os: String,
3210 pub kernel: String,
3211 pub arch: String,
3212 pub hostname: String,
3213 pub uptime_secs: u64,
3214 pub cpu_cores: u32,
3215 #[serde(default, skip_serializing_if = "Option::is_none")]
3216 pub cpu_model: Option<String>,
3217 pub ram_total_bytes: u64,
3218 pub ram_available_bytes: u64,
3219 pub disk_total_bytes: u64,
3220 pub disk_available_bytes: u64,
3221 #[serde(default, skip_serializing_if = "Vec::is_empty")]
3222 pub provider_clis: Vec<ProviderCliReport>,
3223}
3224
3225#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3226pub struct ObservedNode {
3227 pub endpoint: String,
3228 pub transport_label: String,
3229 pub transport: NodeTransportState,
3230 pub freshness: NodeFreshness,
3231 pub cursor: Option<NodeCursor>,
3232 pub inventory: Option<SlimNodeInventory>,
3233 #[serde(default, skip_serializing_if = "Option::is_none")]
3234 pub machine: Option<MachineEnvironment>,
3235 pub last_attempt_unix_ms: Option<u64>,
3236 pub last_success_unix_ms: Option<u64>,
3237 pub consecutive_failures: u32,
3238 pub last_error: Option<SanitizedError>,
3239 pub gaps: Vec<NodeGap>,
3240 pub gaps_truncated: u64,
3241}
3242
3243#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3244pub struct HealthResponse {
3245 pub ok: bool,
3246 pub service: String,
3247 pub api_version: u16,
3248 pub pid: u32,
3249 pub version: String,
3250}
3251
3252#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3253pub struct ReadyResponse {
3254 pub ready: bool,
3255 pub api_version: u16,
3256 pub configured_nodes: usize,
3257 pub attempted_nodes: usize,
3258 pub online_nodes: usize,
3259 pub offline_nodes: usize,
3260 pub parked_nodes: usize,
3261}
3262
3263#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3264pub struct StatusResponse {
3265 pub api_version: u16,
3266 pub ready: bool,
3267 pub observed_at_unix_ms: u64,
3268 pub nodes: BTreeMap<NodeId, ObservedNode>,
3269}
3270
3271#[cfg(test)]
3272mod tests {
3273 use super::*;
3274 use gate4agent_node_protocol::{
3275 GitSnapshot, GitStatusEntry, GitWorktreeSnapshot, NodeFailureCode, NodeSnapshot,
3276 WorkspaceEntry, WorkspaceEntryKind, WorkspaceInspection, WorkspaceSnapshot,
3277 };
3278 use gate4agent_types::{
3279 AdapterBinding, AdapterFamily, AdapterId, AdapterVerification, AgentId, AgentInstanceId,
3280 CapabilitySnapshot, ControlEvent, ControlEventKind, ForegroundSnapshot, HistorySnapshot,
3281 OperationId, PreparedInputKind, ProviderEvent, ProviderInteractionOutcome,
3282 ProviderSessionIdentity, ProviderSessionKey, ProviderSnapshot, ProviderSource,
3283 PtyScreenState, ResumeSessionSummary, ResumeSnapshot, SessionGeneration, SessionSnapshot,
3284 SessionStatus, TerminalSize, TransportKind,
3285 };
3286
3287 #[test]
3288 fn c2_harness_mcp_projection_is_value_exact_and_sanitized() {
3289 let reservation_id = HarnessMcpReservationId::new(format!(
3290 "hmcpres_{}", "a".repeat(24),
3291 )).unwrap();
3292 let activation_digest = HarnessMcpActivationDigest::new(format!(
3293 "sha256:{}", "b".repeat(64),
3294 )).unwrap();
3295 let call_id = HarnessMcpCallId::new(format!("hmcpcall_{}", "c".repeat(24))).unwrap();
3296 let session = SessionAddress {
3297 workspace_id: WorkspaceId::new("primary").unwrap(),
3298 session: gate4agent_node_protocol::SessionKey {
3299 instance_id: AgentInstanceId(7),
3300 generation: SessionGeneration(1),
3301 },
3302 };
3303 let source = NodeEvent::HarnessMcpReadCall {
3304 reservation_id: reservation_id.clone(),
3305 activation_digest: activation_digest.clone(),
3306 record_id: SessionRecordId::new("record-a").unwrap(),
3307 session: session.clone(),
3308 call_id: call_id.clone(),
3309 request: HarnessMcpOpaquePayloadV1 {
3310 content_type: HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
3311 body: br#"{"kind":"context-get"}"#.to_vec(),
3312 },
3313 deadline_unix_ms: 4_000,
3314 };
3315 let projected = C2NodeEvent::from(&source);
3316 assert!(projected.requires_harness_mcp_proxy_capability());
3317 assert!(projected.harness_mcp_contract_is_valid_at(1_000));
3318 let json = serde_json::to_string(&projected).unwrap();
3319 assert!(!json.contains("g4ah3_"));
3320 assert!(!json.contains("endpoint"));
3321 assert!(!json.contains("path"));
3322 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), projected);
3323
3324 let response = NodeResponse::ReplyChunkAccepted {
3325 reservation_id,
3326 activation_digest,
3327 record_id: SessionRecordId::new("record-a").unwrap(),
3328 session,
3329 call_id,
3330 next_offset: 2,
3331 completed: true,
3332 };
3333 let projected_response = C2NodeResponse::from(&response);
3334 assert!(projected_response.requires_harness_mcp_proxy_capability());
3335 assert_eq!(
3336 serde_json::from_value::<C2NodeResponse>(
3337 serde_json::to_value(&projected_response).unwrap(),
3338 ).unwrap(),
3339 projected_response,
3340 );
3341
3342 let failure = C2NodeFailure::from(&NodeFailure {
3343 code: NodeFailureCode::ChunkOutOfOrder,
3344 message: "secret raw detail".to_owned(),
3345 });
3346 assert_eq!(failure.message, "harness MCP reply chunk out of order");
3347 assert!(!failure.message.contains("secret"));
3348 assert!(failure.requires_harness_mcp_proxy_capability());
3349 assert_eq!(C2_HARNESS_MCP_READ_PROXY_CAPABILITY, "harness-mcp-read-proxy-v1");
3350 }
3351
3352 fn host_path(value: impl Into<String>) -> OpaqueHostPath {
3353 OpaqueHostPath::utf8(value.into()).unwrap()
3354 }
3355
3356 fn repository_path(value: impl Into<String>) -> RepositoryPath {
3357 RepositoryPath::utf8(value.into()).unwrap()
3358 }
3359
3360 fn provider(value: &str) -> AgentId {
3361 AgentId::new(value).unwrap()
3362 }
3363
3364 fn fixture_session() -> SessionSnapshot {
3365 SessionSnapshot {
3366 instance_id: AgentInstanceId(7),
3367 agent_id: AgentId::new("codex").unwrap(),
3368 transport: TransportKind::Pty,
3369 generation: SessionGeneration(2),
3370 status: SessionStatus::Running,
3371 pending_operation: Some(OperationId(9)),
3372 pending_input: Some(PreparedInputKind::TerminalText),
3373 process_id: Some(1234),
3374 terminal_size: Some(TerminalSize { rows: 40, columns: 120 }),
3375 terminal_frame: None,
3376 terminal_stale: None,
3377 session_options: None,
3378 capabilities: CapabilitySnapshot::default(),
3379 history: HistorySnapshot::default(),
3380 resume: ResumeSnapshot::default(),
3381 foreground: ForegroundSnapshot::default(),
3382 provider: ProviderSnapshot::default(),
3383 screen_state: PtyScreenState::default(),
3384 }
3385 }
3386
3387 fn fixture_agent_progress() -> SessionAgentProgress {
3388 SessionAgentProgress {
3389 address: SessionAddress {
3390 workspace_id: WorkspaceId::new("primary").unwrap(),
3391 session: gate4agent_node_protocol::SessionKey {
3392 instance_id: AgentInstanceId(7),
3393 generation: SessionGeneration(2),
3394 },
3395 },
3396 progress: AgentProgressV1 {
3397 provider_sequence: 19,
3398 activity: ProviderActivity::Working,
3399 completed_turns: 3,
3400 usage: Some(AgentProgressUsageV1 {
3401 input_tokens: 101,
3402 output_tokens: 37,
3403 cache_read_tokens: 11,
3404 cache_write_tokens: 5,
3405 reasoning_tokens: 13,
3406 }),
3407 current: AgentProgressCurrentV1::Working,
3408 active_tool_labels: vec!["Shell".to_owned()],
3413 active_tool_count: 1,
3414 attention: None,
3415 subagent_count: 2,
3416 last_event_kind: Some(AgentProgressEventKindV1::ToolStarted),
3417 gap_count: 0,
3418 stale: false,
3419 truncated: false,
3420 },
3421 }
3422 }
3423
3424 #[test]
3425 fn c2_agent_progress_snapshot_roundtrip_is_exact_and_private() {
3426 assert_eq!(
3427 C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3428 "agent-progress-snapshot-v1",
3429 );
3430 assert_eq!(
3431 C2_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3432 gate4agent_node_protocol::NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3433 );
3434 let progress = fixture_agent_progress();
3435 let snapshot = C2NodeSnapshot {
3436 node_id: NodeId::new("node-a").unwrap(),
3437 enabled_providers: vec![provider("codex")],
3438 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
3439 workspaces: Vec::new(),
3440 session_records: Vec::new(),
3441 agent_progress: vec![progress.clone()],
3442 managed_worktrees: Vec::new(),
3443 launch_inventory: None,
3444 machine: None,
3445 };
3446 let json = serde_json::to_string(&snapshot).unwrap();
3447 let decoded = serde_json::from_str::<C2NodeSnapshot>(&json).unwrap();
3448 assert_eq!(decoded.agent_progress, vec![progress]);
3449 for forbidden in [
3450 "prompt", "arguments", "contents", "transcript", "provider_session",
3451 "canonical_root", "executable",
3452 ] {
3453 assert!(!json.contains(forbidden), "agent progress leaked {forbidden}");
3454 }
3455
3456 let mut value = serde_json::to_value(snapshot).unwrap();
3457 value["agent_progress"][0]["progress"]["prompt"] =
3458 serde_json::json!("private prompt");
3459 assert!(serde_json::from_value::<C2NodeSnapshot>(value).is_err());
3460
3461 let empty = serde_json::json!({
3462 "node_id": "node-a",
3463 "enabled_providers": [],
3464 "workspaces": [],
3465 "session_records": [],
3466 "managed_worktrees": [],
3467 "launch_inventory": null
3468 });
3469 let empty = serde_json::from_value::<C2NodeSnapshot>(empty).unwrap();
3470 assert!(empty.agent_progress.is_empty());
3471 assert!(serde_json::to_value(empty).unwrap().get("agent_progress").is_none());
3472 }
3473
3474 #[test]
3475 fn c2_workspace_event_does_not_synthesize_agent_progress() {
3476 let event = C2NodeEvent::from(&NodeEvent::WorkspaceAdded {
3477 workspace: WorkspaceSnapshot {
3478 workspace_id: WorkspaceId::new("primary").unwrap(),
3479 canonical_root: host_path(r"C:\private\workspace"),
3480 sessions: vec![fixture_session()],
3481 worktree_service_mode: None,
3482 managed_worktree_profiles: None,
3483 },
3484 });
3485 let json = serde_json::to_string(&event).unwrap();
3486 assert!(!json.contains("agent_progress"));
3487 assert!(!json.contains("provider_sequence"));
3488 }
3489
3490 fn private_session_record() -> ManagedSessionRecord {
3491 ManagedSessionRecord {
3492 record_id: SessionRecordId::new("session-private").unwrap(),
3493 display_name: "release shepherd".to_owned(),
3494 provider: provider("codex"),
3495 mode: SessionMode::Pty,
3496 state: ManagedSessionState::Live,
3497 workspace_id: WorkspaceId::new("primary").unwrap(),
3498 canonical_root: host_path(r"C:\private\canonical-root"),
3499 provider_session: Some(ProviderSessionIdentity {
3500 key: ProviderSessionKey::SessionId,
3501 id: "private-provider-session-id".to_owned(),
3502 transcript_path: Some(r"C:\private\transcript-secret.jsonl".to_owned()),
3503 }),
3504 active_session: Some(SessionAddress {
3505 workspace_id: WorkspaceId::new("primary").unwrap(),
3506 session: gate4agent_node_protocol::SessionKey {
3507 instance_id: AgentInstanceId(41),
3508 generation: SessionGeneration(3),
3509 },
3510 }),
3511 environment_profile: Some(ResolvedEnvironmentProfileReceipt {
3512 profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
3513 profile_revision: SpawnEnvironmentProfileRevision::new(
3514 "local-default.2026-08",
3515 )
3516 .unwrap(),
3517 network_allowlist: None,
3518 browser_profile_id: None,
3519 }),
3520 bundle: None,
3521 context_id: None,
3522 context: None,
3523 exported_context: None,
3524 task_binding: None,
3525 created_at_unix_ms: 10,
3526 updated_at_unix_ms: 20,
3527 last_error: Some("private-error-with-secret-token".to_owned()),
3528 }
3529 }
3530
3531 fn context_pack_receipt() -> ResolvedContextPackReceipt {
3532 ResolvedContextPackReceipt {
3533 id: SpawnContextId::new("context-review-7").unwrap(),
3534 digest: SpawnContextDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap(),
3535 lineage: ContextPackLineageReceipt {
3536 source_node_id: NodeId::new("node-source").unwrap(),
3537 source_session: SessionAddress {
3538 workspace_id: WorkspaceId::new("source-workspace").unwrap(),
3539 session: gate4agent_node_protocol::SessionKey {
3540 instance_id: AgentInstanceId(17),
3541 generation: SessionGeneration(4),
3542 },
3543 },
3544 source_provider: provider("codex"),
3545 },
3546 source_message_count: 9,
3547 retained_message_count: 7,
3548 byte_len: 4096,
3549 truncated: true,
3550 }
3551 }
3552
3553 fn assert_private_record_fields_absent(json: &str) {
3554 assert!(!json.contains("canonical_root"));
3555 assert!(!json.contains("provider_session"));
3556 assert!(!json.contains("private-provider-session-id"));
3557 assert!(!json.contains("transcript_path"));
3558 assert!(!json.contains("transcript-secret.jsonl"));
3559 assert!(!json.contains("last_error"));
3560 assert!(!json.contains("private-error-with-secret-token"));
3561 }
3562
3563 #[test]
3564 fn c2_task_binding_projection_is_exact_safe_and_legacy_slim_omits_it() {
3565 let mut source = private_session_record();
3566 source.task_binding = Some(SessionTaskBindingV1 {
3567 revision: 3,
3568 task_id: Some(TaskId::from_nonce([3; 12])),
3569 changed_at_unix_ms: 15,
3570 });
3571 let projected = C2ManagedSessionRecord::from(&source);
3572 assert_eq!(projected.task_binding, source.task_binding);
3573 assert!(projected.task_binding_is_valid());
3574 let slim = serde_json::to_value(SlimManagedSessionRecord::from(&projected)).unwrap();
3575 assert!(slim.get("task_binding").is_none());
3576
3577 source.task_binding.as_mut().unwrap().changed_at_unix_ms = 21;
3578 assert!(C2ManagedSessionRecord::from(&source).task_binding.is_none());
3579
3580 let mut invalid = serde_json::to_value(projected).unwrap();
3581 invalid["task_binding"]["changed_at_unix_ms"] = serde_json::json!(9);
3582 assert!(serde_json::from_value::<C2ManagedSessionRecord>(invalid).is_err());
3583 }
3584
3585 fn routed_response(response: NodeResponse) -> RoutedNodeResponse {
3586 RoutedNodeResponse {
3587 node_id: NodeId::new("node-a").unwrap(),
3588 incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
3589 response: Ok(C2NodeResponse::from(&response)),
3590 }
3591 }
3592
3593 fn control_node_event(event: ControlEventKind) -> NodeEvent {
3594 NodeEvent::Control {
3595 address: SessionAddress {
3596 workspace_id: WorkspaceId::new("primary").unwrap(),
3597 session: gate4agent_node_protocol::SessionKey {
3598 instance_id: AgentInstanceId(41),
3599 generation: SessionGeneration(3),
3600 },
3601 },
3602 event: ControlEvent {
3603 sequence: 12,
3604 command_id: None,
3605 instance_id: AgentInstanceId(41),
3606 generation: SessionGeneration(3),
3607 event,
3608 },
3609 }
3610 }
3611
3612 #[test]
3613 fn history_summary_event_projects_exactly_and_carries_counts_only() {
3614 let summary = gate4agent_node_protocol::SessionHistorySummaryV1 {
3615 message_count: 12,
3616 message_count_exact: true,
3617 completed_turn_count: Some(5),
3618 total_tokens: Some(4_000),
3619 modified_at_unix_ms: Some(1_786_000_000_000),
3620 };
3621 let source = NodeEvent::SessionRecordHistorySummarized {
3622 record_id: SessionRecordId::new("record-a").unwrap(),
3623 summary,
3624 };
3625 let projected = C2NodeEvent::from(&source);
3626 assert_eq!(
3627 projected,
3628 C2NodeEvent::SessionRecordHistorySummarized {
3629 record_id: SessionRecordId::new("record-a").unwrap(),
3630 summary,
3631 },
3632 );
3633 assert_eq!(projected, C2NodeEvent::from_node_event_with_control_detail(&source));
3634 let json = serde_json::to_string(&projected).unwrap();
3635 assert_eq!(
3636 json,
3637 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}}"#,
3638 );
3639 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), projected);
3640 }
3641
3642 fn tool_started_event() -> ControlEventKind {
3643 ControlEventKind::ProviderEvent {
3644 sequence: 19,
3645 source: provider_source(),
3646 source_sequence: 7,
3647 event: ProviderEvent::ToolStarted {
3648 id: "toolu_1".to_owned(),
3649 name: "Bash".to_owned(),
3650 input_json: r#"{"command":"cat private.txt"}"#.to_owned(),
3651 agent_id: None,
3652 },
3653 }
3654 }
3655
3656 #[test]
3657 fn control_detail_is_absent_by_default_and_sanitized_when_requested() {
3658 let source = control_node_event(tool_started_event());
3659
3660 let default = C2NodeEvent::from(&source);
3661 assert!(!default.requires_control_detail_capability());
3662 assert!(!serde_json::to_string(&default).unwrap().contains("detail"));
3663
3664 let detailed = C2NodeEvent::from_node_event_with_control_detail(&source);
3665 assert!(detailed.requires_control_detail_capability());
3666 let json = serde_json::to_string(&detailed).unwrap();
3667 assert!(json.contains("toolu_1"));
3668 assert!(json.contains("Bash"));
3669 assert!(!json.contains("private.txt"));
3670 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), detailed);
3671
3672 let mut stripped = detailed;
3673 stripped.strip_control_detail();
3674 assert_eq!(stripped, default);
3675 }
3676
3677 #[test]
3678 fn content_only_control_events_carry_their_kind_but_never_their_content() {
3679 let source = control_node_event(ControlEventKind::ProviderEvent {
3680 sequence: 20,
3681 source: provider_source(),
3682 source_sequence: 8,
3683 event: ProviderEvent::Text { text: "private words".to_owned(), is_delta: true },
3684 });
3685 let detailed = C2NodeEvent::from_node_event_with_control_detail(&source);
3686 assert!(detailed.requires_control_detail_capability());
3687 assert!(!serde_json::to_string(&detailed).unwrap().contains("private words"));
3688 }
3689
3690 #[test]
3691 fn control_plane_bookkeeping_has_no_detail_even_when_requested() {
3692 let source = control_node_event(ControlEventKind::Registered);
3693 let detailed = C2NodeEvent::from_node_event_with_control_detail(&source);
3694 assert!(!detailed.requires_control_detail_capability());
3695 assert_eq!(detailed, C2NodeEvent::from(&source));
3696 }
3697
3698 #[test]
3699 fn resync_control_detail_follows_the_same_rule_and_strips_to_the_default() {
3700 let events = vec![
3701 gate4agent_node_protocol::NodeEventEnvelope {
3702 sequence: 3,
3703 event: control_node_event(tool_started_event()),
3704 },
3705 gate4agent_node_protocol::NodeEventEnvelope {
3706 sequence: 4,
3707 event: control_node_event(ControlEventKind::Removed),
3708 },
3709 ];
3710 let snapshot = NodeSnapshot {
3711 node_id: NodeId::new("node-a").unwrap(),
3712 enabled_providers: Vec::new(),
3713 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
3714 workspaces: Vec::new(),
3715 session_records: Vec::new(),
3716 managed_worktrees: Vec::new(),
3717 launch_inventory: None,
3718 agent_progress: Vec::new(),
3719 };
3720 let source = NodeResponse::Resync {
3721 event_sequence: 4,
3722 oldest_available_sequence: 1,
3723 snapshot,
3724 events,
3725 };
3726 let default = C2NodeResponse::from(&source);
3727 assert!(!default.requires_control_detail_capability());
3728 let mut detailed = C2NodeResponse::from_node_response_with_control_detail(&source);
3729 assert!(detailed.requires_control_detail_capability());
3730 detailed.strip_control_detail();
3731 assert_eq!(detailed, default);
3732 }
3733
3734 fn routed_control_event(event: ControlEventKind) -> RoutedNodeEvent {
3735 let address = SessionAddress {
3736 workspace_id: WorkspaceId::new("primary").unwrap(),
3737 session: gate4agent_node_protocol::SessionKey {
3738 instance_id: AgentInstanceId(41),
3739 generation: SessionGeneration(3),
3740 },
3741 };
3742 RoutedNodeEvent {
3743 node_id: NodeId::new("node-a").unwrap(),
3744 cursor: NodeCursor {
3745 incarnation_id: NodeIncarnationId::from_bytes([8; 16]),
3746 sequence: 9,
3747 },
3748 event: C2NodeEvent::from(&NodeEvent::Control {
3749 address,
3750 event: ControlEvent {
3751 sequence: 12,
3752 command_id: None,
3753 instance_id: AgentInstanceId(41),
3754 generation: SessionGeneration(3),
3755 event,
3756 },
3757 }),
3758 }
3759 }
3760
3761 fn provider_source() -> ProviderSource {
3762 ProviderSource {
3763 family: AdapterFamily::PtySemantic,
3764 binding: AdapterBinding::new(
3765 AdapterId::new("codex").unwrap(),
3766 "fixture/v1",
3767 AdapterVerification::SyntheticFixture,
3768 ).unwrap(),
3769 }
3770 }
3771
3772 fn provider_contract_manifest() -> (
3773 Vec<ProviderContractSupport>,
3774 Vec<ProviderAdapterContractSupport>,
3775 ) {
3776 (
3777 vec![ProviderContractSupport {
3778 provider: provider("codex"),
3779 revision: ProviderContractRevision::new("codex.2026-08").unwrap(),
3780 }],
3781 vec![ProviderAdapterContractSupport {
3782 provider: provider("codex"),
3783 family: AdapterFamily::PtySemantic,
3784 adapter_id: AdapterId::new("codex").unwrap(),
3785 revision: AdapterContractRevision::new("pty-semantic.2026-08").unwrap(),
3786 }],
3787 )
3788 }
3789
3790 fn c2_compatibility_support(
3791 capabilities: Vec<CapabilityId>,
3792 ) -> C2ControlCompatibilitySupport {
3793 C2ControlCompatibilitySupport {
3794 build_stamp: BUILD_STAMP.to_owned(),
3795 capabilities,
3796 host: HostDescriptor {
3797 operating_system: OperatingSystemId::new("darwin").unwrap(),
3798 architecture: ArchitectureId::new("aarch64").unwrap(),
3799 },
3800 path_semantics: PathSemantics {
3801 style: PathStyle::Posix,
3802 encoding: PathEncoding::Utf8,
3803 },
3804 }
3805 }
3806
3807 #[test]
3820 fn the_legacy_client_hello_json_carries_exactly_these_keys_in_this_order() {
3821 let hello = C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]);
3822 let json = serde_json::to_string(&hello).unwrap();
3823 let owned = format!(
3824 concat!(
3825 r#"{{"build_stamp":"{}","client_nonce":["#,
3826 "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,",
3827 "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}}"
3828 ),
3829 BUILD_STAMP,
3830 );
3831 let expected = owned.as_str();
3832
3833 assert_eq!(json, expected);
3834 assert!(!json.contains("compatibility"));
3838 assert_eq!(
3839 serde_json::from_str::<C2ClientHello>(expected).unwrap(),
3840 hello,
3841 );
3842 }
3843
3844 fn build_stamp_hex() -> String {
3858 let mut bytes = Vec::with_capacity(2 + BUILD_STAMP.len());
3859 bytes.extend_from_slice(&(BUILD_STAMP.len() as u16).to_le_bytes());
3860 bytes.extend_from_slice(BUILD_STAMP.as_bytes());
3861 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
3862 }
3863
3864 #[test]
3865 fn c2_compatibility_legacy_server_json_is_byte_equivalent() {
3866 #[derive(Serialize)]
3867 struct LegacyChallenge {
3868 build_stamp: String,
3869 server_nonce: [u8; C2_AUTH_NONCE_BYTES],
3870 server_proof: [u8; C2_AUTH_PROOF_BYTES],
3871 }
3872
3873 #[derive(Serialize)]
3874 struct LegacyHello<'a> {
3875 build_stamp: String,
3876 connection_id: u64,
3877 status: &'a StatusResponse,
3878 }
3879
3880 let challenge = C2ServerChallenge {
3881 build_stamp: BUILD_STAMP.to_owned(),
3882 server_nonce: [1; C2_AUTH_NONCE_BYTES],
3883 server_proof: [2; C2_AUTH_PROOF_BYTES],
3884 compatibility: None,
3885 };
3886 let legacy_challenge = LegacyChallenge {
3887 build_stamp: BUILD_STAMP.to_owned(),
3888 server_nonce: [1; C2_AUTH_NONCE_BYTES],
3889 server_proof: [2; C2_AUTH_PROOF_BYTES],
3890 };
3891 assert_eq!(
3892 serde_json::to_vec(&challenge).unwrap(),
3893 serde_json::to_vec(&legacy_challenge).unwrap(),
3894 );
3895
3896 let status = StatusResponse {
3897 api_version: C2_API_VERSION,
3898 ready: true,
3899 observed_at_unix_ms: 7,
3900 nodes: BTreeMap::new(),
3901 };
3902 let hello = C2Hello {
3903 build_stamp: BUILD_STAMP.to_owned(),
3904 connection_id: 11,
3905 status: status.clone(),
3906 compatibility: None,
3907 };
3908 let legacy_hello = LegacyHello {
3909 build_stamp: BUILD_STAMP.to_owned(),
3910 connection_id: 11,
3911 status: &status,
3912 };
3913 assert_eq!(
3914 serde_json::to_vec(&hello).unwrap(),
3915 serde_json::to_vec(&legacy_hello).unwrap(),
3916 );
3917 }
3918
3919 #[test]
3920 fn c2_terminal_frame_event_projection_wire_contract_is_exact() {
3921 let source = NodeEvent::TerminalFrame {
3922 address: SessionAddress {
3923 workspace_id: WorkspaceId::new("primary").unwrap(),
3924 session: gate4agent_node_protocol::SessionKey {
3925 instance_id: AgentInstanceId(7),
3926 generation: SessionGeneration(3),
3927 },
3928 },
3929 frame: TerminalFrame {
3930 sequence: 11,
3931 size: TerminalSize { rows: 24, columns: 80 },
3932 cursor_row: 2,
3933 cursor_column: 4,
3934 contents: "ready".to_owned(),
3935 formatted: b"ready".to_vec(),
3936 scrollback_formatted: vec![b"previous".to_vec()],
3937 alternate_screen: false,
3938 mouse_protocol_enabled: false,
3939 mouse_protocol_encoding:
3940 gate4agent_types::TerminalMouseProtocolEncoding::Default,
3941 produced_at_unix_ms: 0,
3942 screen_state: gate4agent_types::PtyScreenState::default(),
3943 bracketed_paste: None,
3944 },
3945 };
3946 let event = C2NodeEvent::from(&source);
3947 let json = serde_json::to_string(&event).unwrap();
3948 assert_eq!(
3949 json,
3950 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"}}}"#,
3951 );
3952 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), event);
3953 }
3954
3955 #[test]
3961 fn c2_agent_stream_projection_passes_the_blocked_chunk_through_unchanged() {
3962 let address = SessionAddress {
3963 workspace_id: WorkspaceId::new("primary").unwrap(),
3964 session: gate4agent_node_protocol::SessionKey {
3965 instance_id: AgentInstanceId(7),
3966 generation: SessionGeneration(3),
3967 },
3968 };
3969 let chunk = AgentStreamChunkV1 {
3970 source_sequence: 11,
3971 kind: gate4agent_node_protocol::AgentStreamChunkKindV1::Blocked {
3972 correlation_id: None,
3973 tool_class: "Write".to_owned(),
3974 authority: gate4agent_node_protocol::BlockAuthorityV1::HostGate,
3975 reason_kind: None,
3976 reason: "blocked by dangerous-command gate: rule=filesystem-wipe".to_owned(),
3977 help: None,
3978 },
3979 };
3980 let source = NodeEvent::AgentStream { address: address.clone(), chunk: chunk.clone() };
3981 let projected = C2NodeEvent::from(&source);
3982 assert_eq!(projected, C2NodeEvent::AgentStream { address, chunk: chunk.clone() });
3983 let json = serde_json::to_string(&projected).unwrap();
3984 assert_eq!(
3985 json,
3986 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}}}"#,
3987 );
3988 assert_eq!(serde_json::from_str::<C2NodeEvent>(&json).unwrap(), projected);
3989 }
3990
3991 #[test]
3992 fn c2_terminal_frame_events_capability_is_optional_and_auth_bound_exactly() {
3993 assert_eq!(
3994 C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
3995 "terminal-frame-events-v1",
3996 );
3997 assert_eq!(
3998 C2_TERMINAL_FRAME_EVENTS_CAPABILITY,
3999 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
4000 );
4001 let capability = CapabilityId::new(C2_TERMINAL_FRAME_EVENTS_CAPABILITY).unwrap();
4002 let support = c2_compatibility_support(vec![capability.clone()],
4003 );
4004 assert!(support
4005 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4006 .unwrap()
4007 .capabilities
4008 .is_empty());
4009
4010 let offer = ClientCompatibilityOffer {
4011 build_stamp: BUILD_STAMP.to_owned(),
4012 capabilities: vec![capability.clone()],
4013 state_schema: None,
4014 };
4015 let selected = support
4016 .negotiate(&C2ClientHello::negotiating(
4017 [0; C2_AUTH_NONCE_BYTES],
4018 offer.clone(),
4019 ))
4020 .unwrap();
4021 assert_eq!(selected.capabilities, vec![capability]);
4022 let transcript = c2_bound_auth_transcript(
4023 C2AuthDirection::Server,
4024 &[0x11; C2_AUTH_NONCE_BYTES],
4025 &[0x22; C2_AUTH_NONCE_BYTES],
4026 &offer,
4027 &selected,
4028 )
4029 .unwrap();
4030 let hex = transcript
4031 .iter()
4032 .map(|byte| format!("{byte:02x}"))
4033 .collect::<String>();
4034 let stamp = build_stamp_hex();
4035 assert_eq!(
4036 hex,
4037 format!(
4038 concat!(
4039 "67617465346167656e742d63322d636f6e74726f6c2d617574682d76322d636f6d7061746962696c69747900",
4040 "{stamp}",
4041 "01",
4042 "1111111111111111111111111111111111111111111111111111111111111111",
4043 "2222222222222222222222222222222222222222222222222222222222222222",
4044 "6f6666657200",
4045 "{stamp}",
4046 "0100",
4047 "18007465726d696e616c2d6672616d652d6576656e74732d763100",
4048 "73656c656374656400",
4049 "{stamp}",
4050 "0100",
4051 "18007465726d696e616c2d6672616d652d6576656e74732d7631",
4052 "060064617277696e0700616172636836340201",
4053 ),
4054 stamp = stamp,
4055 ),
4056 );
4057 }
4058
4059 #[test]
4060 fn c2_acp_control_capability_is_optional_and_negotiable() {
4061 assert_eq!(C2_ACP_CONTROL_CAPABILITY, "acp-control-v1");
4062 assert_eq!(C2_ACP_CONTROL_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY);
4063 let capability = CapabilityId::new(C2_ACP_CONTROL_CAPABILITY).unwrap();
4064 let support = c2_compatibility_support(vec![capability.clone()],
4065 );
4066 assert!(support
4067 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4068 .unwrap()
4069 .capabilities
4070 .is_empty());
4071
4072 let offer = ClientCompatibilityOffer {
4073 build_stamp: BUILD_STAMP.to_owned(),
4074 capabilities: vec![capability.clone()],
4075 state_schema: None,
4076 };
4077 let selected = support
4078 .negotiate(&C2ClientHello::negotiating(
4079 [0; C2_AUTH_NONCE_BYTES],
4080 offer,
4081 ))
4082 .unwrap();
4083 assert_eq!(selected.capabilities, vec![capability]);
4084 }
4085
4086 #[test]
4087 fn c2_spawn_spec_defaults_overrides_capability_is_optional_and_auth_bound_exactly() {
4088 assert_eq!(
4089 C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
4090 "spawn-spec.defaults-overrides-v1",
4091 );
4092 assert_eq!(
4093 C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
4094 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
4095 );
4096 let capability =
4097 CapabilityId::new(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
4098 let support = c2_compatibility_support(vec![capability.clone()],
4099 );
4100 assert!(support
4101 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4102 .unwrap()
4103 .capabilities
4104 .is_empty());
4105
4106 let offer = ClientCompatibilityOffer {
4107 build_stamp: BUILD_STAMP.to_owned(),
4108 capabilities: vec![capability.clone()],
4109 state_schema: None,
4110 };
4111 let selected = support
4112 .negotiate(&C2ClientHello::negotiating(
4113 [0; C2_AUTH_NONCE_BYTES],
4114 offer.clone(),
4115 ))
4116 .unwrap();
4117 assert_eq!(selected.capabilities, vec![capability]);
4118 let bound = c2_bound_auth_transcript(
4119 C2AuthDirection::Server,
4120 &[0x11; C2_AUTH_NONCE_BYTES],
4121 &[0x22; C2_AUTH_NONCE_BYTES],
4122 &offer,
4123 &selected,
4124 )
4125 .unwrap();
4126 let without_spawn_spec = c2_bound_auth_transcript(
4127 C2AuthDirection::Server,
4128 &[0x11; C2_AUTH_NONCE_BYTES],
4129 &[0x22; C2_AUTH_NONCE_BYTES],
4130 &ClientCompatibilityOffer {
4131 build_stamp: offer.build_stamp.clone(),
4132 capabilities: Vec::new(),
4133 state_schema: None,
4134 },
4135 &NegotiatedC2ControlCompatibility {
4136 build_stamp: selected.build_stamp.clone(),
4137 capabilities: Vec::new(),
4138 host: selected.host.clone(),
4139 path_semantics: selected.path_semantics.clone(),
4140 },
4141 )
4142 .unwrap();
4143 assert_ne!(bound, without_spawn_spec);
4144 assert!(bound.windows(C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY.len()).any(
4145 |window| window == C2_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY.as_bytes()
4146 ));
4147 }
4148
4149 #[test]
4150 fn c2_spawn_profile_revision_capability_is_optional_and_auth_bound_exactly() {
4151 assert_eq!(
4152 C2_SPAWN_PROFILE_REVISION_CAPABILITY,
4153 "spawn-spec.profile-revision-v1",
4154 );
4155 assert_eq!(
4156 C2_SPAWN_PROFILE_REVISION_CAPABILITY,
4157 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
4158 );
4159 let capability =
4160 CapabilityId::new(C2_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
4161 let support = c2_compatibility_support(vec![capability.clone()],
4162 );
4163 assert!(support
4164 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4165 .unwrap()
4166 .capabilities
4167 .is_empty());
4168
4169 let offer = ClientCompatibilityOffer {
4170 build_stamp: BUILD_STAMP.to_owned(),
4171 capabilities: vec![capability.clone()],
4172 state_schema: None,
4173 };
4174 let selected = support
4175 .negotiate(&C2ClientHello::negotiating(
4176 [0; C2_AUTH_NONCE_BYTES],
4177 offer.clone(),
4178 ))
4179 .unwrap();
4180 assert_eq!(selected.capabilities, vec![capability]);
4181 let bound = c2_bound_auth_transcript(
4182 C2AuthDirection::Server,
4183 &[0x11; C2_AUTH_NONCE_BYTES],
4184 &[0x22; C2_AUTH_NONCE_BYTES],
4185 &offer,
4186 &selected,
4187 )
4188 .unwrap();
4189 assert!(bound.windows(C2_SPAWN_PROFILE_REVISION_CAPABILITY.len()).any(
4190 |window| window == C2_SPAWN_PROFILE_REVISION_CAPABILITY.as_bytes()
4191 ));
4192 }
4193
4194 #[test]
4195 fn c2_worktree_selection_capability_is_optional_and_auth_bound_exactly() {
4196 assert_eq!(C2_WORKTREE_SELECTION_CAPABILITY, "worktree-selection-v1");
4197 assert_eq!(
4198 C2_WORKTREE_SELECTION_CAPABILITY,
4199 NODE_WORKTREE_SELECTION_CAPABILITY,
4200 );
4201 let capability = CapabilityId::new(C2_WORKTREE_SELECTION_CAPABILITY).unwrap();
4202 let support = c2_compatibility_support(vec![capability.clone()],
4203 );
4204 assert!(support
4205 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4206 .unwrap()
4207 .capabilities
4208 .is_empty());
4209
4210 let offer = ClientCompatibilityOffer {
4211 build_stamp: BUILD_STAMP.to_owned(),
4212 capabilities: vec![capability.clone()],
4213 state_schema: None,
4214 };
4215 let selected = support
4216 .negotiate(&C2ClientHello::negotiating(
4217 [0; C2_AUTH_NONCE_BYTES],
4218 offer.clone(),
4219 ))
4220 .unwrap();
4221 assert_eq!(selected.capabilities, vec![capability]);
4222 let bound = c2_bound_auth_transcript(
4223 C2AuthDirection::Server,
4224 &[0x11; C2_AUTH_NONCE_BYTES],
4225 &[0x22; C2_AUTH_NONCE_BYTES],
4226 &offer,
4227 &selected,
4228 )
4229 .unwrap();
4230 let without_worktree_selection = c2_bound_auth_transcript(
4231 C2AuthDirection::Server,
4232 &[0x11; C2_AUTH_NONCE_BYTES],
4233 &[0x22; C2_AUTH_NONCE_BYTES],
4234 &ClientCompatibilityOffer {
4235 build_stamp: offer.build_stamp.clone(),
4236 capabilities: Vec::new(),
4237 state_schema: None,
4238 },
4239 &NegotiatedC2ControlCompatibility {
4240 build_stamp: selected.build_stamp.clone(),
4241 capabilities: Vec::new(),
4242 host: selected.host.clone(),
4243 path_semantics: selected.path_semantics.clone(),
4244 },
4245 )
4246 .unwrap();
4247 assert_ne!(bound, without_worktree_selection);
4248 assert!(bound.windows(C2_WORKTREE_SELECTION_CAPABILITY.len()).any(
4249 |window| window == C2_WORKTREE_SELECTION_CAPABILITY.as_bytes()
4250 ));
4251 }
4252
4253 #[test]
4254 fn c2_managed_worktree_capability_is_optional_and_auth_bound_exactly() {
4255 assert_eq!(
4256 C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
4257 "managed-worktree-lifecycle-v1",
4258 );
4259 assert_eq!(
4260 C2_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
4261 "managed-worktree-spawn-v2",
4262 );
4263 assert_eq!(
4264 C2NodeFailure::from(&NodeFailure {
4265 code: NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
4266 message: "private node detail".to_owned(),
4267 })
4268 .message,
4269 "managed worktree profile revision mismatch",
4270 );
4271 let capability =
4272 CapabilityId::new(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY).unwrap();
4273 let support = c2_compatibility_support(vec![capability.clone()],
4274 );
4275 let offer = ClientCompatibilityOffer {
4276 build_stamp: BUILD_STAMP.to_owned(),
4277 capabilities: vec![capability],
4278 state_schema: None,
4279 };
4280 let selected = support
4281 .negotiate(&C2ClientHello::negotiating(
4282 [0; C2_AUTH_NONCE_BYTES],
4283 offer.clone(),
4284 ))
4285 .unwrap();
4286 let bound = c2_bound_auth_transcript(
4287 C2AuthDirection::Server,
4288 &[0x11; C2_AUTH_NONCE_BYTES],
4289 &[0x22; C2_AUTH_NONCE_BYTES],
4290 &offer,
4291 &selected,
4292 )
4293 .unwrap();
4294 assert!(bound
4295 .windows(C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY.len())
4296 .any(|window| {
4297 window == C2_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY.as_bytes()
4298 }));
4299 assert!(support
4300 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4301 .unwrap()
4302 .capabilities
4303 .is_empty());
4304 }
4305
4306 #[test]
4307 fn c2_child_environment_profile_capability_is_optional_and_auth_bound_exactly() {
4308 assert_eq!(
4309 C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
4310 "child-environment-profile-v1",
4311 );
4312 let capability =
4313 CapabilityId::new(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).unwrap();
4314 let support = c2_compatibility_support(vec![capability.clone()],
4315 );
4316 let offer = ClientCompatibilityOffer {
4317 build_stamp: BUILD_STAMP.to_owned(),
4318 capabilities: vec![capability],
4319 state_schema: None,
4320 };
4321 let selected = support
4322 .negotiate(&C2ClientHello::negotiating(
4323 [0; C2_AUTH_NONCE_BYTES],
4324 offer.clone(),
4325 ))
4326 .unwrap();
4327 let bound = c2_bound_auth_transcript(
4328 C2AuthDirection::Server,
4329 &[0x11; C2_AUTH_NONCE_BYTES],
4330 &[0x22; C2_AUTH_NONCE_BYTES],
4331 &offer,
4332 &selected,
4333 )
4334 .unwrap();
4335 assert!(bound
4336 .windows(C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.len())
4337 .any(|window| {
4338 window == C2_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.as_bytes()
4339 }));
4340 assert!(support
4341 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4342 .unwrap()
4343 .capabilities
4344 .is_empty());
4345 }
4346
4347 #[test]
4348 fn c2_session_bundle_materialization_is_auth_bound_and_projected_as_opaque_metadata() {
4349 assert_eq!(
4350 C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
4351 "session-bundle-materialization-v1",
4352 );
4353 assert_eq!(
4354 C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
4355 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
4356 );
4357 let capability =
4358 CapabilityId::new(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
4359 let support = c2_compatibility_support(vec![capability.clone()],
4360 );
4361 let offer = ClientCompatibilityOffer {
4362 build_stamp: BUILD_STAMP.to_owned(),
4363 capabilities: vec![capability.clone()],
4364 state_schema: None,
4365 };
4366 let selected = support
4367 .negotiate(&C2ClientHello::negotiating(
4368 [0; C2_AUTH_NONCE_BYTES],
4369 offer.clone(),
4370 ))
4371 .unwrap();
4372 assert_eq!(selected.capabilities, vec![capability]);
4373 let bound = c2_bound_auth_transcript(
4374 C2AuthDirection::Server,
4375 &[0x11; C2_AUTH_NONCE_BYTES],
4376 &[0x22; C2_AUTH_NONCE_BYTES],
4377 &offer,
4378 &selected,
4379 )
4380 .unwrap();
4381 assert!(bound
4382 .windows(C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.len())
4383 .any(|window| {
4384 window == C2_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.as_bytes()
4385 }));
4386
4387 let mut record = private_session_record();
4388 record.bundle = Some(ResolvedBundleReceipt {
4389 id: SpawnBundleId::new("review-bundle").unwrap(),
4390 revision: SpawnBundleRevision::new("review-bundle.r1").unwrap(),
4391 digest: SpawnBundleDigest::new(format!("sha256:{}", "a".repeat(64)))
4392 .unwrap(),
4393 });
4394 let projected = C2ManagedSessionRecord::from(&record);
4395 let json = serde_json::to_string(&projected).unwrap();
4396 assert!(json.contains(
4397 r#""bundle":{"id":"review-bundle","revision":"review-bundle.r1","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#,
4398 ));
4399 assert_private_record_fields_absent(&json);
4400 assert!(C2NodeEvent::SessionRecordUpserted { record: projected }
4401 .requires_session_bundle_materialization_capability());
4402 }
4403
4404 #[test]
4405 fn c2_history_context_pack_capability_is_optional_and_auth_bound_exactly() {
4406 assert_eq!(C2_HISTORY_CONTEXT_PACK_CAPABILITY, "history-context-pack-v1");
4407 assert_eq!(
4408 C2_HISTORY_CONTEXT_PACK_CAPABILITY,
4409 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
4410 );
4411 let capability = CapabilityId::new(C2_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
4412 let support = c2_compatibility_support(vec![capability.clone()],
4413 );
4414 assert!(support
4415 .negotiate(&C2ClientHello::new([0; C2_AUTH_NONCE_BYTES]))
4416 .unwrap()
4417 .capabilities
4418 .is_empty());
4419
4420 let offer = ClientCompatibilityOffer {
4421 build_stamp: BUILD_STAMP.to_owned(),
4422 capabilities: vec![capability.clone()],
4423 state_schema: None,
4424 };
4425 let selected = support
4426 .negotiate(&C2ClientHello::negotiating(
4427 [0; C2_AUTH_NONCE_BYTES],
4428 offer.clone(),
4429 ))
4430 .unwrap();
4431 assert_eq!(selected.capabilities, vec![capability]);
4432 let bound = c2_bound_auth_transcript(
4433 C2AuthDirection::Server,
4434 &[0x11; C2_AUTH_NONCE_BYTES],
4435 &[0x22; C2_AUTH_NONCE_BYTES],
4436 &offer,
4437 &selected,
4438 )
4439 .unwrap();
4440 assert!(bound
4441 .windows(C2_HISTORY_CONTEXT_PACK_CAPABILITY.len())
4442 .any(|window| window == C2_HISTORY_CONTEXT_PACK_CAPABILITY.as_bytes()));
4443
4444 for code in [
4445 NodeFailureCode::UnknownContextPack,
4446 NodeFailureCode::ContextPackBusy,
4447 NodeFailureCode::ContextPackMaterializationFailed,
4448 ] {
4449 let failure = C2NodeFailure::from(&NodeFailure {
4450 code,
4451 message: "private context backend detail".to_owned(),
4452 });
4453 assert!(failure.requires_history_context_pack_capability());
4454 assert!(!failure.message.contains("private"));
4455 }
4456 assert!(!C2NodeFailure::from(&NodeFailure {
4457 code: NodeFailureCode::UnknownSession,
4458 message: "private session backend detail".to_owned(),
4459 })
4460 .requires_history_context_pack_capability());
4461 }
4462
4463 #[test]
4464 fn c2_history_context_pack_requests_are_exact_and_capability_gated() {
4465 let session = SessionAddress {
4466 workspace_id: WorkspaceId::new("primary").unwrap(),
4467 session: gate4agent_node_protocol::SessionKey {
4468 instance_id: AgentInstanceId(41),
4469 generation: SessionGeneration(3),
4470 },
4471 };
4472 let requests = [
4473 (
4474 NodeRequest::DiscoverHistory { session: session.clone(), limit: 4 },
4475 r#"{"kind":"discover-history","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"limit":4}"#,
4476 ),
4477 (
4478 NodeRequest::LoadHistory {
4479 session: session.clone(),
4480 candidate_id: "candidate-7".to_owned(),
4481 },
4482 r#"{"kind":"load-history","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"candidate_id":"candidate-7"}"#,
4483 ),
4484 (
4485 NodeRequest::ExportContextPack { session },
4486 r#"{"kind":"export-context-pack","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}}}"#,
4487 ),
4488 (
4489 NodeRequest::ForgetContextPack {
4490 context_id: SpawnContextId::new("context-review-7").unwrap(),
4491 },
4492 r#"{"kind":"forget-context-pack","context_id":"context-review-7"}"#,
4493 ),
4494 ];
4495
4496 for (request, expected) in requests {
4497 assert_eq!(request.required_capability(), Some(C2_HISTORY_CONTEXT_PACK_CAPABILITY));
4498 assert!(request.requires_history_context_pack_capability());
4499 assert_eq!(serde_json::to_string(&request).unwrap(), expected);
4500 assert_eq!(serde_json::from_str::<NodeRequest>(expected).unwrap(), request);
4501 }
4502 assert!(serde_json::from_str::<NodeRequest>(
4503 r#"{"kind":"export-context-pack","session":{"workspace_id":"primary","session":{"instance_id":41,"generation":3}},"path":"private.jsonl"}"#,
4504 )
4505 .is_err());
4506 }
4507
4508 #[test]
4509 fn c2_session_record_context_export_is_exact_correlated_and_private() {
4510 assert_eq!(
4511 C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
4512 "session-record-context-export-v1",
4513 );
4514 let session = SessionAddress {
4515 workspace_id: WorkspaceId::new("primary").unwrap(),
4516 session: gate4agent_node_protocol::SessionKey {
4517 instance_id: AgentInstanceId(41),
4518 generation: SessionGeneration(3),
4519 },
4520 };
4521 let record_id = SessionRecordId::new("record-context-41").unwrap();
4522 let request = NodeRequest::ExportContextPackForSessionRecord {
4523 record_id: record_id.clone(),
4524 session: session.clone(),
4525 };
4526 assert_eq!(
4527 request.required_capability(),
4528 Some(C2_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY),
4529 );
4530 assert!(request.requires_history_context_pack_capability());
4531 let request_json = serde_json::to_string(&request).unwrap();
4532 assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
4533
4534 let response = C2NodeResponse::from(
4535 &NodeResponse::ContextPackForSessionRecordExported {
4536 record_id,
4537 session,
4538 context: context_pack_receipt(),
4539 },
4540 );
4541 assert!(response.requires_history_context_pack_capability());
4542 assert!(response.requires_session_record_context_export_capability());
4543 let response_json = serde_json::to_string(&response).unwrap();
4544 assert_eq!(
4545 serde_json::from_str::<C2NodeResponse>(&response_json).unwrap(),
4546 response,
4547 );
4548 for private in [
4549 "candidate_id",
4550 "session_id_hint",
4551 "provider_session",
4552 "messages",
4553 "model",
4554 "path",
4555 ] {
4556 assert!(!request_json.contains(private), "request leaked {private}");
4557 assert!(!response_json.contains(private), "response leaked {private}");
4558 }
4559 }
4560
4561 #[test]
4562 fn c2_context_metadata_projects_through_records_snapshots_events_and_responses() {
4563 let context = context_pack_receipt();
4564 let mut source = private_session_record();
4565 source.context_id = Some(context.id.clone());
4566 source.context = Some(context.clone());
4567 let projected = C2ManagedSessionRecord::from(&source);
4568 assert!(projected.context_binding_is_valid());
4569 assert!(projected.requires_history_context_pack_capability());
4570 assert_eq!(projected.context_id.as_ref(), Some(&context.id));
4571 assert_eq!(projected.context.as_ref(), Some(&context));
4572
4573 let record_json = serde_json::to_string(&projected).unwrap();
4574 assert_private_record_fields_absent(&record_json);
4575 assert_eq!(
4576 serde_json::from_str::<C2ManagedSessionRecord>(&record_json).unwrap(),
4577 projected,
4578 );
4579 let mut mismatched = serde_json::from_str::<serde_json::Value>(&record_json).unwrap();
4580 mismatched["context_id"] = serde_json::json!("context-other");
4581 assert!(serde_json::from_value::<C2ManagedSessionRecord>(mismatched).is_err());
4582
4583 let snapshot = C2NodeSnapshot::from(&NodeSnapshot {
4584 node_id: NodeId::new("node-a").unwrap(),
4585 enabled_providers: vec![provider("codex")],
4586 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
4587 workspaces: Vec::new(),
4588 session_records: vec![source.clone()],
4589 managed_worktrees: Vec::new(),
4590 launch_inventory: None,
4591 agent_progress: Vec::new(),
4592 });
4593 assert!(snapshot.requires_history_context_pack_capability());
4594 let event = C2NodeEvent::from(&NodeEvent::SessionRecordUpserted {
4595 record: source.clone(),
4596 });
4597 assert!(event.requires_history_context_pack_capability());
4598
4599 let updated = C2NodeResponse::from(&NodeResponse::SessionRecordUpdated {
4600 record: source,
4601 });
4602 assert!(updated.requires_history_context_pack_capability());
4603 assert!(serde_json::to_string(&updated).unwrap().contains("context-review-7"));
4604
4605 let exported = C2NodeResponse::from(&NodeResponse::ContextPackExported {
4606 context: context.clone(),
4607 });
4608 assert!(exported.requires_history_context_pack_capability());
4609 let json = serde_json::to_string(&exported).unwrap();
4610 assert!(json.contains(r#""kind":"context-pack-exported""#));
4611 assert!(json.contains(r#""source_message_count":9"#));
4612 assert!(!json.contains("messages"));
4613 assert!(!json.contains("path"));
4614 assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), exported);
4615 }
4616
4617 #[test]
4618 fn c2_exported_context_projects_through_records_and_is_provider_correlated() {
4619 let exported = context_pack_receipt();
4620 let mut source = private_session_record();
4621 source.exported_context = Some(exported.clone());
4622 let projected = C2ManagedSessionRecord::from(&source);
4623 assert!(projected.exported_context_is_valid());
4624 assert_eq!(projected.exported_context.as_ref(), Some(&exported));
4625
4626 let record_json = serde_json::to_string(&projected).unwrap();
4627 assert_private_record_fields_absent(&record_json);
4628 assert_eq!(
4629 serde_json::from_str::<C2ManagedSessionRecord>(&record_json).unwrap(),
4630 projected,
4631 );
4632
4633 let mut mismatched_provider =
4634 serde_json::from_str::<serde_json::Value>(&record_json).unwrap();
4635 mismatched_provider["exported_context"]["lineage"]["source_provider"] =
4636 serde_json::json!("claude");
4637 assert!(serde_json::from_value::<C2ManagedSessionRecord>(mismatched_provider).is_err());
4638 }
4639
4640 #[test]
4641 fn c2_history_responses_are_exact_bounded_metadata_without_messages_or_paths() {
4642 let session = SessionAddress {
4643 workspace_id: WorkspaceId::new("primary").unwrap(),
4644 session: gate4agent_node_protocol::SessionKey {
4645 instance_id: AgentInstanceId(41),
4646 generation: SessionGeneration(3),
4647 },
4648 };
4649 let discovered = C2NodeResponse::from(&NodeResponse::HistoryDiscovered {
4650 session: session.clone(),
4651 candidates: vec![HistoryCandidateSummary {
4652 id: "candidate-7".to_owned(),
4653 session_id_hint: "session-hint-7".to_owned(),
4654 modified_at_unix_ms: Some(77),
4655 }],
4656 });
4657 let loaded = C2NodeResponse::from(&NodeResponse::HistoryLoaded {
4658 session,
4659 session_id: "session-loaded-7".to_owned(),
4660 message_count: 12,
4661 completed_turn_count: Some(5),
4662 });
4663 let forgotten = C2NodeResponse::from(&NodeResponse::ContextPackForgotten {
4664 context_id: SpawnContextId::new("context-review-7").unwrap(),
4665 });
4666 assert_eq!(
4667 serde_json::to_string(&discovered).unwrap(),
4668 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}]}"#,
4669 );
4670 assert_eq!(
4671 serde_json::to_string(&loaded).unwrap(),
4672 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}"#,
4673 );
4674 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}"#;
4675 let legacy_loaded = serde_json::from_str::<C2NodeResponse>(legacy_loaded_json).unwrap();
4676 assert_eq!(serde_json::to_string(&legacy_loaded).unwrap(), legacy_loaded_json);
4677 assert!(matches!(
4678 legacy_loaded,
4679 C2NodeResponse::HistoryLoaded {
4680 completed_turn_count: None,
4681 ..
4682 }
4683 ));
4684 assert_eq!(
4685 serde_json::to_string(&forgotten).unwrap(),
4686 r#"{"kind":"context-pack-forgotten","context_id":"context-review-7"}"#,
4687 );
4688 for response in [discovered, loaded, forgotten] {
4689 assert!(response.requires_history_context_pack_capability());
4690 let json = serde_json::to_string(&response).unwrap();
4691 assert!(!json.contains("messages"));
4692 assert!(!json.contains("path"));
4693 assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), response);
4694 }
4695 assert!(serde_json::from_str::<C2NodeResponse>(
4696 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}]}"#,
4697 )
4698 .is_err());
4699 }
4700
4701 #[test]
4702 fn native_session_catalog_projection_is_exact_and_capability_bound() {
4703 assert_eq!(
4704 C2_NATIVE_SESSION_CATALOG_CAPABILITY,
4705 "native-session-catalog-v2"
4706 );
4707 let route = NativeSessionCatalogRoute::workspace(
4708 WorkspaceId::new("primary").unwrap(),
4709 provider("codex"),
4710 );
4711 let projected = C2NodeResponse::from(&NodeResponse::NativeSessionsCataloged {
4712 route: route.clone(),
4713 entries: vec![NativeSessionCatalogEntry {
4714 selection_id: "hist_selection_7".to_owned(),
4715 title: Some("Review".to_owned()),
4716 modified_at_unix_ms: Some(77),
4717 model: Some("model-7".to_owned()),
4718 message_count: 8,
4719 completed_turn_count: Some(4),
4720 external_group: None,
4721 record_id: Some(SessionRecordId::new("record-7").unwrap()),
4722 }],
4723 summary: Some(NativeSessionCatalogSummary {
4724 catalog_revision: 7,
4725 recent_cutoff_unix_ms: 70,
4726 recent_total_count: 1,
4727 older_total_count: 3,
4728 recent_next_after_selection_id: None,
4729 recent_has_more: false,
4730 }),
4731 });
4732 assert!(projected.requires_native_session_catalog_capability());
4733 let json = serde_json::to_string(&projected).unwrap();
4734 for forbidden in [
4735 "session_id",
4736 "cwd",
4737 "candidate",
4738 "path",
4739 "messages",
4740 "tokens",
4741 "documents",
4742 "raw",
4743 ] {
4744 assert!(!json.contains(forbidden));
4745 }
4746 assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), projected);
4747 assert!(serde_json::from_str::<C2NodeResponse>(
4748 r#"{"kind":"native-sessions-cataloged","workspace_id":"primary","provider":"codex","entries":[]}"#,
4749 )
4750 .is_err());
4751
4752 let paged = C2NodeResponse::from(&NodeResponse::NativeSessionsPaged {
4753 route,
4754 page: NativeSessionCatalogPage {
4755 window: NativeSessionCatalogWindow::Older,
4756 revision: 7,
4757 entries: Vec::new(),
4758 next_after_selection_id: None,
4759 remaining_count: 0,
4760 has_more: false,
4761 },
4762 });
4763 assert!(paged.requires_native_session_catalog_paging_capability());
4764 }
4765
4766 #[test]
4767 fn session_record_preview_projection_does_not_expose_native_identity() {
4768 let response = NodeResponse::SessionRecordPreviewed {
4769 record_id: SessionRecordId::new("record-7").unwrap(),
4770 preview: gate4agent_types::SessionRecordPreview {
4771 title: Some("Review".to_owned()),
4772 modified_at_unix_ms: Some(77),
4773 model: Some("model-7".to_owned()),
4774 message_count: 3,
4775 message_count_exact: true,
4776 completed_turn_count: Some(1),
4777 total_tokens: None,
4778 truncated: true,
4779 messages: vec![gate4agent_types::NativeSessionPreviewMessage {
4780 role: gate4agent_types::HistoryMessageRole::User,
4781 text: "visible dialogue".to_owned(),
4782 }],
4783 },
4784 };
4785 let projected = C2NodeResponse::from(&response);
4786 assert!(projected.requires_native_session_preview_capability());
4787 let json = serde_json::to_string(&projected).unwrap();
4788 for forbidden in ["session_id", "provider", "workspace_id", "cwd", "path", "tokens"] {
4789 assert!(!json.contains(forbidden));
4790 }
4791 assert_eq!(serde_json::from_str::<C2NodeResponse>(&json).unwrap(), projected);
4792 }
4793
4794 #[test]
4795 fn legacy_slim_projection_deliberately_strips_context_metadata() {
4796 let context = context_pack_receipt();
4797 let mut source = private_session_record();
4798 source.context_id = Some(context.id.clone());
4799 source.context = Some(context);
4800 let c2 = C2ManagedSessionRecord::from(&source);
4801
4802 for json in [
4803 serde_json::to_value(SlimManagedSessionRecord::from(&source)).unwrap(),
4804 serde_json::to_value(SlimManagedSessionRecord::from(&c2)).unwrap(),
4805 ] {
4806 assert!(json.get("context_id").is_none());
4807 assert!(json.get("context").is_none());
4808 }
4809 }
4810
4811 #[test]
4812 fn c2_legacy_node_event_bytes_remain_exact_after_terminal_frame_addition() {
4813 assert_eq!(
4814 serde_json::to_vec(&C2NodeEvent::ResyncRequired {
4815 oldest_available_sequence: 7,
4816 })
4817 .unwrap(),
4818 br#"{"kind":"resync-required","oldest_available_sequence":7}"#,
4819 );
4820 }
4821
4822 #[test]
4823 fn c2_compatibility_missing_offer_negotiates_local_build_stamp() {
4824 let support = c2_compatibility_support(Vec::new());
4825
4826 let negotiated = support
4827 .negotiate(&C2ClientHello::new([1; C2_AUTH_NONCE_BYTES]))
4828 .unwrap();
4829
4830 assert_eq!(negotiated.build_stamp, BUILD_STAMP);
4831 assert!(negotiated.capabilities.is_empty());
4832 }
4833
4834 #[test]
4835 fn c2_compatibility_selects_local_build_stamp_and_capability_intersection() {
4836 let shared = CapabilityId::new("terminal-stream").unwrap();
4837 let server_only = CapabilityId::new("server-only").unwrap();
4838 let client_only = CapabilityId::new("client-only").unwrap();
4839 let support = c2_compatibility_support(vec![shared.clone(), server_only]);
4840 let hello = C2ClientHello::negotiating(
4841 [2; C2_AUTH_NONCE_BYTES],
4842 ClientCompatibilityOffer {
4843 build_stamp: BUILD_STAMP.to_owned(),
4844 capabilities: vec![client_only, shared.clone()],
4845 state_schema: None,
4846 },
4847 );
4848
4849 let negotiated = support.negotiate(&hello).unwrap();
4850
4851 assert_eq!(negotiated.build_stamp, BUILD_STAMP);
4852 assert_eq!(negotiated.capabilities, vec![shared]);
4853 }
4854
4855 #[test]
4856 fn c2_compatibility_negotiate_rejects_a_foreign_build_stamp_naming_both_values() {
4857 let support = c2_compatibility_support(Vec::new());
4858 let foreign_stamp = "f".repeat(40);
4859 let hello = C2ClientHello {
4860 build_stamp: foreign_stamp.clone(),
4861 client_nonce: [3; C2_AUTH_NONCE_BYTES],
4862 compatibility: None,
4863 };
4864
4865 let error = support.negotiate(&hello).unwrap_err();
4866 assert!(matches!(
4867 &error,
4868 ProtocolNegotiationError::BuildStampMismatch { local, remote }
4869 if local == BUILD_STAMP && remote == &foreign_stamp,
4870 ));
4871 assert_eq!(
4872 error.to_string(),
4873 format!("build stamp mismatch: local={BUILD_STAMP} remote={foreign_stamp}"),
4874 );
4875 }
4876
4877 #[test]
4878 fn c2_compatibility_bound_auth_transcript_is_exact_and_selection_sensitive() {
4879 let capability = CapabilityId::new(C2_COMPATIBILITY_METADATA_CAPABILITY).unwrap();
4880 let offer = ClientCompatibilityOffer {
4881 build_stamp: BUILD_STAMP.to_owned(),
4882 capabilities: vec![capability.clone()],
4883 state_schema: None,
4884 };
4885 let selected = NegotiatedC2ControlCompatibility {
4886 build_stamp: BUILD_STAMP.to_owned(),
4887 capabilities: vec![capability],
4888 host: HostDescriptor {
4889 operating_system: OperatingSystemId::new("windows").unwrap(),
4890 architecture: ArchitectureId::new("x86_64").unwrap(),
4891 },
4892 path_semantics: PathSemantics {
4893 style: PathStyle::Windows,
4894 encoding: PathEncoding::Utf8,
4895 },
4896 };
4897 let transcript = c2_bound_auth_transcript(
4898 C2AuthDirection::Server,
4899 &[0x11; C2_AUTH_NONCE_BYTES],
4900 &[0x22; C2_AUTH_NONCE_BYTES],
4901 &offer,
4902 &selected,
4903 ).unwrap();
4904 let hex = transcript.iter().map(|byte| format!("{byte:02x}")).collect::<String>();
4905
4906 let stamp = build_stamp_hex();
4907 assert_eq!(
4908 hex,
4909 format!(
4910 concat!(
4911 "67617465346167656e742d63322d636f6e74726f6c2d617574682d76322d636f6d7061746962696c69747900",
4912 "{stamp}",
4913 "01",
4914 "1111111111111111111111111111111111111111111111111111111111111111",
4915 "2222222222222222222222222222222222222222222222222222222222222222",
4916 "6f6666657200",
4917 "{stamp}",
4918 "0100",
4919 "1600636f6d7061746962696c6974792e6d6574616461746100",
4920 "73656c656374656400",
4921 "{stamp}",
4922 "0100",
4923 "1600636f6d7061746962696c6974792e6d65746164617461",
4924 "070077696e646f777306007838365f36340101",
4925 ),
4926 stamp = stamp,
4927 ),
4928 );
4929
4930 let mut tampered = selected;
4931 tampered.path_semantics.style = PathStyle::Posix;
4932 assert_ne!(
4933 transcript,
4934 c2_bound_auth_transcript(
4935 C2AuthDirection::Server,
4936 &[0x11; C2_AUTH_NONCE_BYTES],
4937 &[0x22; C2_AUTH_NONCE_BYTES],
4938 &offer,
4939 &tampered,
4940 ).unwrap(),
4941 );
4942 }
4943
4944 #[test]
4945 fn c2_compatibility_preserves_foreign_host_and_opaque_path() {
4946 let support = c2_compatibility_support(Vec::new(),
4947 );
4948 let negotiated = support
4949 .negotiate(&C2ClientHello::new([4; C2_AUTH_NONCE_BYTES]))
4950 .unwrap();
4951 let projected = C2NodeSnapshot::from(&NodeSnapshot {
4952 node_id: NodeId::new("remote-mac").unwrap(),
4953 enabled_providers: Vec::new(),
4954 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
4955 workspaces: vec![WorkspaceSnapshot {
4956 workspace_id: WorkspaceId::new("repo").unwrap(),
4957 canonical_root: host_path("/srv/CaseSensitive/../literal-root"),
4958 sessions: Vec::new(),
4959 worktree_service_mode: None,
4960 managed_worktree_profiles: None,
4961 }],
4962 session_records: Vec::new(),
4963 managed_worktrees: Vec::new(),
4964 launch_inventory: None,
4965 agent_progress: Vec::new(),
4966 });
4967 let json = serde_json::to_string(&(projected, negotiated)).unwrap();
4968 let (projected, negotiated) = serde_json::from_str::<(
4969 C2NodeSnapshot,
4970 NegotiatedC2ControlCompatibility,
4971 )>(&json).unwrap();
4972
4973 assert_eq!(
4974 projected.workspaces[0].canonical_root.display_text(),
4975 "/srv/CaseSensitive/../literal-root",
4976 );
4977 assert_eq!(negotiated.host.operating_system.as_str(), "darwin");
4978 assert_eq!(negotiated.host.architecture.as_str(), "aarch64");
4979 assert_eq!(negotiated.path_semantics.style, PathStyle::Posix);
4980 }
4981
4982 #[test]
4983 fn c2_workspace_inspection_preserves_legacy_utf8_repository_path_shape() {
4984 let inspection = C2WorkspaceInspection {
4985 workspace_id: WorkspaceId::new("repo").unwrap(),
4986 entries: vec![WorkspaceEntry {
4987 relative_path: repository_path(r"src\literal/main.rs"),
4988 kind: WorkspaceEntryKind::File,
4989 }],
4990 tree_truncated: false,
4991 git: C2GitSnapshot {
4992 is_repository: true,
4993 branch: Some("main".to_owned()),
4994 status: Vec::new(),
4995 recent_commits: Vec::new(),
4996 worktrees: Vec::new(),
4997 managed_worktree: None,
4998 truncated: false,
4999 diagnostic_present: false,
5000 },
5001 truncation: None,
5002 };
5003
5004 let json = serde_json::to_string(&inspection).unwrap();
5005 assert_eq!(
5006 json,
5007 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}}"#,
5008 );
5009 assert_eq!(serde_json::from_str::<C2WorkspaceInspection>(&json).unwrap(), inspection);
5010 }
5011
5012 #[test]
5013 fn c2_workspace_inspection_roundtrips_all_tagged_repository_path_fields() {
5014 let entry_path = RepositoryPath::unix_bytes(vec![b's', b'r', b'c', b'/', 0xfe]).unwrap();
5015 let status_path = RepositoryPath::unix_bytes(vec![b's', b'r', b'c', b'/', 0xff]).unwrap();
5016 let previous_path = RepositoryPath::unix_bytes(vec![b'o', b'l', b'd', b'/', 0xfd]).unwrap();
5017 let inspection = C2WorkspaceInspection {
5018 workspace_id: WorkspaceId::new("repo").unwrap(),
5019 entries: vec![WorkspaceEntry {
5020 relative_path: entry_path,
5021 kind: WorkspaceEntryKind::File,
5022 }],
5023 tree_truncated: false,
5024 git: C2GitSnapshot {
5025 is_repository: true,
5026 branch: None,
5027 status: vec![GitStatusEntry {
5028 index_status: "R".to_owned(),
5029 worktree_status: " ".to_owned(),
5030 path: status_path,
5031 previous_path: Some(previous_path),
5032 }],
5033 recent_commits: Vec::new(),
5034 worktrees: Vec::new(),
5035 managed_worktree: None,
5036 truncated: false,
5037 diagnostic_present: false,
5038 },
5039 truncation: None,
5040 };
5041
5042 let json = serde_json::to_string(&inspection).unwrap();
5043 assert!(json.contains(r#""relative_path":{"kind":"unix-bytes""#));
5044 assert!(json.contains(r#""path":{"kind":"unix-bytes""#));
5045 assert!(json.contains(r#""previous_path":{"kind":"unix-bytes""#));
5046 assert_eq!(serde_json::from_str::<C2WorkspaceInspection>(&json).unwrap(), inspection);
5047 }
5048
5049 #[test]
5050 fn c2_workspace_inspection_projects_and_validates_managed_git_scope_without_paths() {
5051 let workspace_id = WorkspaceId::new("managed-a").unwrap();
5052 let source_workspace_id = WorkspaceId::new("primary").unwrap();
5053 let inspection = WorkspaceInspection {
5054 workspace_id: workspace_id.clone(),
5055 entries: Vec::new(),
5056 tree_truncated: false,
5057 git: GitSnapshot {
5058 is_repository: true,
5059 branch: Some("gate4agent/a".to_owned()),
5060 status: Vec::new(),
5061 recent_commits: Vec::new(),
5062 worktrees: Vec::new(),
5063 managed_worktree: Some(ManagedWorktreeGitScope {
5064 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
5065 source_workspace_id: source_workspace_id.clone(),
5066 branch: "gate4agent/a".to_owned(),
5067 base_commit: gate4agent_node_protocol::GitObjectId::new(
5068 "0123456789abcdef0123456789abcdef01234567".to_owned(),
5069 )
5070 .unwrap(),
5071 active_session_count: 1,
5072 managed_record_count: 1,
5073 }),
5074 truncated: false,
5075 diagnostic: None,
5076 },
5077 truncation: None,
5078 };
5079 let projected = C2WorkspaceInspection::from(&inspection);
5080 let json = serde_json::to_string(&projected).unwrap();
5081 assert!(json.contains("\"managed_worktree\""));
5082 assert!(json.contains("\"lease_id\":\"mw-a\""));
5083 assert!(!json.contains("target_root"));
5084 assert_eq!(
5085 serde_json::from_str::<C2WorkspaceInspection>(&json).unwrap(),
5086 projected,
5087 );
5088
5089 let invalid = json.replace(
5090 "\"source_workspace_id\":\"primary\"",
5091 "\"source_workspace_id\":\"managed-a\"",
5092 );
5093 assert!(serde_json::from_str::<C2WorkspaceInspection>(&invalid).is_err());
5094
5095 let mut invalid_node = inspection;
5096 invalid_node.git.branch = Some("gate4agent/b".to_owned());
5097 assert!(C2WorkspaceInspection::from(&invalid_node)
5098 .git
5099 .managed_worktree
5100 .is_none());
5101 }
5102
5103 #[test]
5104 fn slim_inventory_is_deterministic_and_excludes_terminal_history() {
5105 let snapshot = NodeSnapshot {
5106 node_id: NodeId::new("node-a").unwrap(),
5107 enabled_providers: vec![provider("codex"), provider("claude"), provider("codex")],
5108 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5109 workspaces: vec![
5110 WorkspaceSnapshot { workspace_id: WorkspaceId::new("z-work").unwrap(), canonical_root: host_path("z"), sessions: Vec::new(), worktree_service_mode: None, managed_worktree_profiles: None },
5111 WorkspaceSnapshot {
5112 workspace_id: WorkspaceId::new("a-work").unwrap(),
5113 canonical_root: host_path("a"),
5114 sessions: vec![fixture_session()],
5115 worktree_service_mode: None,
5116 managed_worktree_profiles: None,
5117 },
5118 ],
5119 session_records: Vec::new(),
5120 managed_worktrees: Vec::new(),
5121 launch_inventory: None,
5122 agent_progress: Vec::new(),
5123 };
5124 let slim = SlimNodeInventory::from_snapshot(&snapshot);
5125 assert_eq!(slim.enabled_providers, vec![provider("claude"), provider("codex")]);
5126 assert_eq!(slim.workspaces.keys().map(WorkspaceId::as_str).collect::<Vec<_>>(), vec!["a-work", "z-work"]);
5127 let session = &slim.workspaces[&WorkspaceId::new("a-work").unwrap()].sessions[0];
5128 assert_eq!(session.transport, TransportKind::Pty);
5129 assert_eq!(session.process_id, Some(1234));
5130 assert_eq!(session.terminal_size, Some(TerminalSize { rows: 40, columns: 120 }));
5131 assert!(session.operation_pending);
5132 assert!(session.input_pending);
5133 let json = serde_json::to_string(&slim).unwrap();
5134 assert!(!json.contains("terminal_frame"));
5135 assert!(!json.contains("history"));
5136 }
5137
5138 #[test]
5142 fn slim_session_json_omitting_screen_state_decodes_to_unknown_not_ready() {
5143 let json = r#"{
5144 "instance_id": 7,
5145 "generation": 2,
5146 "agent_id": "codex",
5147 "transport": "pty",
5148 "status": "running",
5149 "process_id": 1234,
5150 "terminal_size": null,
5151 "operation_pending": true,
5152 "input_pending": false
5153 }"#;
5154 let session = serde_json::from_str::<SlimSession>(json).unwrap();
5155 assert_eq!(session.screen_state, PtyScreenState::Unknown);
5156 assert_ne!(session.screen_state, PtyScreenState::Ready);
5157 }
5158
5159 #[test]
5164 fn slim_inventory_projection_carries_a_non_default_screen_state_from_the_session_snapshot() {
5165 let mut session = fixture_session();
5166 session.screen_state = PtyScreenState::OperatorGate {
5167 gate: gate4agent_types::OperatorGateState::new(gate4agent_types::OperatorGateKind::WorkspaceTrust),
5168 };
5169 let snapshot = NodeSnapshot {
5170 node_id: NodeId::new("node-a").unwrap(),
5171 enabled_providers: vec![provider("codex")],
5172 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5173 workspaces: vec![WorkspaceSnapshot {
5174 workspace_id: WorkspaceId::new("a-work").unwrap(),
5175 canonical_root: host_path("a"),
5176 sessions: vec![session.clone()],
5177 worktree_service_mode: None,
5178 managed_worktree_profiles: None,
5179 }],
5180 session_records: Vec::new(),
5181 managed_worktrees: Vec::new(),
5182 launch_inventory: None,
5183 agent_progress: Vec::new(),
5184 };
5185 let slim = SlimNodeInventory::from_snapshot(&snapshot);
5186 let slim_session = &slim.workspaces[&WorkspaceId::new("a-work").unwrap()].sessions[0];
5187 assert_eq!(slim_session.screen_state, session.screen_state);
5188 }
5189
5190 #[test]
5191 fn c2_full_and_slim_snapshots_preserve_launch_and_worktree_inventory() {
5192 let worktree_profiles = WorktreeProfileInventory {
5193 profiles: vec![ManagedWorktreeProfileSummary {
5194 id: WorktreeProfileId::new("review").unwrap(),
5195 revision: WorktreeProfileRevision::new("v1").unwrap(),
5196 retention: ManagedWorktreeRetention::Retain,
5197 }],
5198 };
5199 let launch_inventory = LaunchInventory {
5200 spawn_profiles: Some(vec![SpawnProfileSummary {
5201 id: SpawnProfileId::new("default").unwrap(),
5202 revision: SpawnProfileRevision::new("v1").unwrap(),
5203 environment_profile: None,
5204 }]),
5205 bundles: Some(vec![ResolvedBundleReceipt {
5206 id: SpawnBundleId::new("review").unwrap(),
5207 revision: SpawnBundleRevision::new("v1").unwrap(),
5208 digest: SpawnBundleDigest::new(format!("sha256:{}", "0".repeat(64))).unwrap(),
5209 }]),
5210 network_allowlists: None,
5211 };
5212 let snapshot = NodeSnapshot {
5213 node_id: NodeId::new("node-a").unwrap(),
5214 enabled_providers: vec![provider("codex")],
5215 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5216 workspaces: vec![WorkspaceSnapshot {
5217 workspace_id: WorkspaceId::new("repo").unwrap(),
5218 canonical_root: host_path("repo"),
5219 sessions: Vec::new(),
5220 worktree_service_mode: Some(WorktreeServiceMode::Managed),
5221 managed_worktree_profiles: Some(worktree_profiles.clone()),
5222 }],
5223 session_records: Vec::new(),
5224 managed_worktrees: Vec::new(),
5225 launch_inventory: Some(launch_inventory.clone()),
5226 agent_progress: Vec::new(),
5227 };
5228
5229 let full = C2NodeSnapshot::from(&snapshot);
5230 let slim_from_node = SlimNodeInventory::from_snapshot(&snapshot);
5231 let slim_from_c2 = SlimNodeInventory::from_c2_snapshot(&full);
5232 assert_eq!(full.launch_inventory.as_ref(), Some(&launch_inventory));
5233 assert_eq!(slim_from_node.launch_inventory.as_ref(), Some(&launch_inventory));
5234 assert_eq!(slim_from_c2.launch_inventory.as_ref(), Some(&launch_inventory));
5235 assert_eq!(
5236 full.workspaces[0].worktree_service_mode,
5237 Some(WorktreeServiceMode::Managed),
5238 );
5239 assert_eq!(
5240 slim_from_node.workspaces[&WorkspaceId::new("repo").unwrap()]
5241 .worktree_service_mode,
5242 Some(WorktreeServiceMode::Managed),
5243 );
5244 assert_eq!(
5245 slim_from_c2.workspaces[&WorkspaceId::new("repo").unwrap()]
5246 .worktree_service_mode,
5247 Some(WorktreeServiceMode::Managed),
5248 );
5249 assert_eq!(
5250 full.workspaces[0].managed_worktree_profiles.as_ref(),
5251 Some(&worktree_profiles),
5252 );
5253 assert_eq!(
5254 slim_from_node.workspaces[&WorkspaceId::new("repo").unwrap()]
5255 .managed_worktree_profiles
5256 .as_ref(),
5257 Some(&worktree_profiles),
5258 );
5259
5260 let legacy = serde_json::from_value::<C2WorkspaceSnapshot>(serde_json::json!({
5261 "workspace_id": "legacy",
5262 "canonical_root": "legacy-root",
5263 "sessions": [],
5264 }))
5265 .unwrap();
5266 assert_eq!(legacy.worktree_service_mode, None);
5267 assert_eq!(legacy.managed_worktree_profiles, None);
5268 let encoded = serde_json::to_value(legacy).unwrap();
5269 assert!(encoded.get("worktree_service_mode").is_none());
5270 assert!(encoded.get("managed_worktree_profiles").is_none());
5271 }
5272
5273 #[test]
5274 fn slim_inventory_provider_contract_projection_is_exact_and_private() {
5275 let mut slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5276 node_id: NodeId::new("node-a").unwrap(),
5277 enabled_providers: vec![provider("codex")],
5278 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5279 workspaces: Vec::new(),
5280 session_records: Vec::new(),
5281 managed_worktrees: Vec::new(),
5282 launch_inventory: None,
5283 agent_progress: Vec::new(),
5284 });
5285 let (provider_contracts, provider_adapter_contracts) =
5286 provider_contract_manifest();
5287 slim.provider_contracts = provider_contracts;
5288 slim.provider_adapter_contracts = provider_adapter_contracts;
5289
5290 let value = serde_json::to_value(&slim).unwrap();
5291 assert_eq!(
5292 value["provider_contracts"],
5293 serde_json::json!([{
5294 "provider": "codex",
5295 "revision": "codex.2026-08",
5296 }]),
5297 );
5298 assert_eq!(
5299 value["provider_adapter_contracts"],
5300 serde_json::json!([{
5301 "provider": "codex",
5302 "family": "pty-semantic",
5303 "adapter_id": "codex",
5304 "revision": "pty-semantic.2026-08",
5305 }]),
5306 );
5307 let json = serde_json::to_string(&value).unwrap();
5308 for forbidden in [
5309 "installed_cli_version",
5310 "executable_path",
5311 "auth_state",
5312 "canary_verdict",
5313 "events",
5314 "routed_response",
5315 ] {
5316 assert!(!json.contains(forbidden), "leaked private field {forbidden}");
5317 }
5318 }
5319
5320 #[test]
5321 fn c2_projection_omits_path_stdout_stderr_env_and_fallback_reason() {
5322 let runtime_statuses = ProviderRuntimeStatuses::new([
5323 ProviderRuntimeStatus::raw_passthrough(
5324 provider("codex"),
5325 Some(ProviderRuntimeVersion::new("0.999.0").unwrap()),
5326 ),
5327 ])
5328 .unwrap();
5329 let snapshot = NodeSnapshot {
5330 node_id: NodeId::new("node-a").unwrap(),
5331 enabled_providers: vec![provider("codex")],
5332 provider_runtime_statuses: runtime_statuses.clone(),
5333 workspaces: Vec::new(),
5334 session_records: Vec::new(),
5335 managed_worktrees: Vec::new(),
5336 launch_inventory: None,
5337 agent_progress: Vec::new(),
5338 };
5339 let projected = C2NodeSnapshot::from(&snapshot);
5340 let inventory = SlimNodeInventory::from_snapshot(&snapshot);
5341 assert_eq!(projected.provider_runtime_statuses, runtime_statuses);
5342 assert_eq!(inventory.provider_runtime_statuses, runtime_statuses);
5343 for encoded in [
5344 serde_json::to_string(&projected).unwrap(),
5345 serde_json::to_string(&inventory).unwrap(),
5346 ] {
5347 assert!(encoded.contains("\"version\":\"0.999.0\""));
5348 for forbidden in [
5349 "launcher", "executable", "stdout", "stderr", "environment", "fallback",
5350 "reason", "arguments",
5351 ] {
5352 assert!(!encoded.contains(forbidden), "leaked field {forbidden}");
5353 }
5354 }
5355 }
5356
5357 #[test]
5358 fn slim_inventory_reports_sessions_hidden_by_workspace_truncation() {
5359 let mut workspaces = (0..MAX_C2_WORKSPACES_PER_NODE)
5360 .map(|index| WorkspaceSnapshot {
5361 workspace_id: WorkspaceId::new(format!("work-{index:02}")).unwrap(),
5362 canonical_root: host_path(format!("root-{index:02}")),
5363 sessions: Vec::new(),
5364 worktree_service_mode: None,
5365 managed_worktree_profiles: None,
5366 })
5367 .collect::<Vec<_>>();
5368 workspaces.push(WorkspaceSnapshot {
5369 workspace_id: WorkspaceId::new("work-zz").unwrap(),
5370 canonical_root: host_path("hidden-root"),
5371 sessions: vec![fixture_session()],
5372 worktree_service_mode: None,
5373 managed_worktree_profiles: None,
5374 });
5375 let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5376 node_id: NodeId::new("node-a").unwrap(),
5377 enabled_providers: Vec::new(),
5378 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5379 workspaces,
5380 session_records: Vec::new(),
5381 managed_worktrees: Vec::new(),
5382 launch_inventory: None,
5383 agent_progress: Vec::new(),
5384 });
5385 assert!(slim.workspaces_truncated);
5386 assert_eq!(slim.session_count, 1);
5387 assert!(slim.sessions_truncated);
5388 }
5389
5390 #[test]
5391 fn control_auth_transcript_is_direction_and_protocol_domain_separated() {
5392 assert_eq!(C2_API_VERSION, 2);
5393 let client_nonce = [3; C2_AUTH_NONCE_BYTES];
5394 let server_nonce = [7; C2_AUTH_NONCE_BYTES];
5395 let server = c2_auth_transcript(C2AuthDirection::Server, &client_nonce, &server_nonce);
5396 let client = c2_auth_transcript(C2AuthDirection::Client, &client_nonce, &server_nonce);
5397 assert_ne!(server, client);
5398 assert!(server.starts_with(b"gate4agent-c2-control-auth-v2\0"));
5399 assert!(!server.windows(b"gate4agent-node-auth-v3".len()).any(|window| window == b"gate4agent-node-auth-v3"));
5400 assert_eq!(&server[server.len() - (C2_AUTH_NONCE_BYTES * 2)..server.len() - C2_AUTH_NONCE_BYTES], &client_nonce);
5401 assert_eq!(&server[server.len() - C2_AUTH_NONCE_BYTES..], &server_nonce);
5402 }
5403
5404 #[test]
5405 fn topology_projection_is_sorted_bounded_and_minimal() {
5406 let nodes = (0..=MAX_C2_NODES).rev().map(|index| {
5407 let node_id = NodeId::new(format!("node-{index:03}")).unwrap();
5408 let incarnation_id = NodeIncarnationId::from_bytes([index as u8; 16]);
5409 let observed = ObservedNode {
5410 endpoint: format!(r"\\.\pipe\node-{index:03}"),
5411 transport_label: "windows-named-pipe".to_owned(),
5412 transport: NodeTransportState::Online,
5413 freshness: NodeFreshness::Fresh,
5414 cursor: Some(NodeCursor { incarnation_id, sequence: 99 }),
5415 inventory: None,
5416 machine: None,
5417 last_attempt_unix_ms: Some(10),
5418 last_success_unix_ms: Some(10),
5419 consecutive_failures: 0,
5420 last_error: None,
5421 gaps: Vec::new(),
5422 gaps_truncated: 0,
5423 };
5424 (node_id, observed)
5425 }).collect();
5426 let topology = C2Topology::from_status(&StatusResponse {
5427 api_version: C2_API_VERSION,
5428 ready: true,
5429 observed_at_unix_ms: 10,
5430 nodes,
5431 });
5432
5433 assert_eq!(topology.nodes.len(), MAX_C2_NODES);
5434 assert_eq!(topology.nodes.first().unwrap().node_id.as_str(), "node-000");
5435 assert_eq!(topology.nodes.last().unwrap().node_id.as_str(), "node-063");
5436 assert_eq!(
5437 topology.nodes[0].current_incarnation_id,
5438 Some(NodeIncarnationId::from_bytes([0; 16])),
5439 );
5440 let json = serde_json::to_string(&C2ServerFrame::Topology(topology)).unwrap();
5441 assert!(!json.contains("observed_at_unix_ms"));
5442 assert!(!json.contains("sequence"));
5443 assert!(!json.contains("inventory"));
5444 assert!(!json.contains("managed_worktrees"));
5445 }
5446
5447 #[test]
5448 fn topology_node_without_relay_route_decodes_as_unknown() {
5449 let legacy = r#"{
5450 "node_id":"node-a",
5451 "endpoint":"legacy-endpoint",
5452 "transport":"offline",
5453 "current_incarnation_id":null
5454 }"#;
5455
5456 let decoded: C2TopologyNode = serde_json::from_str(legacy).unwrap();
5457
5458 assert_eq!(decoded.relay_route, C2RelayRoute::Unknown);
5459 assert!(!serde_json::to_string(&decoded).unwrap().contains("relay_route"));
5460 }
5461
5462 #[test]
5463 fn topology_provider_contract_projection_is_capability_gated_and_change_sensitive() {
5464 let (provider_contracts, provider_adapter_contracts) =
5465 provider_contract_manifest();
5466 let mut inventory = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5467 node_id: NodeId::new("node-a").unwrap(),
5468 enabled_providers: vec![provider("codex")],
5469 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5470 workspaces: Vec::new(),
5471 session_records: Vec::new(),
5472 managed_worktrees: Vec::new(),
5473 launch_inventory: None,
5474 agent_progress: Vec::new(),
5475 });
5476 inventory.provider_contracts = provider_contracts;
5477 inventory.provider_adapter_contracts = provider_adapter_contracts;
5478 let status = StatusResponse {
5479 api_version: C2_API_VERSION,
5480 ready: true,
5481 observed_at_unix_ms: 10,
5482 nodes: BTreeMap::from([(
5483 NodeId::new("node-a").unwrap(),
5484 ObservedNode {
5485 endpoint: r"\\.\pipe\node-a".to_owned(),
5486 transport_label: "windows-named-pipe".to_owned(),
5487 transport: NodeTransportState::Online,
5488 freshness: NodeFreshness::Fresh,
5489 cursor: Some(NodeCursor {
5490 incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5491 sequence: 9,
5492 }),
5493 inventory: Some(inventory),
5494 machine: None,
5495 last_attempt_unix_ms: Some(10),
5496 last_success_unix_ms: Some(10),
5497 consecutive_failures: 0,
5498 last_error: None,
5499 gaps: Vec::new(),
5500 gaps_truncated: 0,
5501 },
5502 )]),
5503 };
5504
5505 let projected = C2Topology::from_status_with_provider_contracts(&status, true);
5506 assert_eq!(projected.nodes[0].provider_contracts.len(), 1);
5507 assert_eq!(projected.nodes[0].provider_adapter_contracts.len(), 1);
5508 let legacy = C2Topology::from_status_with_provider_contracts(&status, false);
5509 assert!(legacy.nodes[0].provider_contracts.is_empty());
5510 assert!(legacy.nodes[0].provider_adapter_contracts.is_empty());
5511 #[derive(Serialize)]
5512 struct LegacyTopologyNode<'a> {
5513 node_id: &'a NodeId,
5514 endpoint: &'a str,
5515 relay_route: C2RelayRoute,
5516 transport: NodeTransportState,
5517 current_incarnation_id: Option<NodeIncarnationId>,
5518 }
5519 #[derive(Serialize)]
5520 struct LegacyTopology<'a> {
5521 nodes: Vec<LegacyTopologyNode<'a>>,
5522 }
5523 let legacy_shape = LegacyTopology {
5524 nodes: legacy.nodes.iter().map(|node| LegacyTopologyNode {
5525 node_id: &node.node_id,
5526 endpoint: &node.endpoint,
5527 relay_route: node.relay_route,
5528 transport: node.transport,
5529 current_incarnation_id: node.current_incarnation_id,
5530 }).collect(),
5531 };
5532 let legacy_json = serde_json::to_string(&legacy).unwrap();
5533 assert_eq!(
5534 serde_json::to_vec(&legacy).unwrap(),
5535 serde_json::to_vec(&legacy_shape).unwrap(),
5536 );
5537 assert!(!legacy_json.contains("provider_contracts"));
5538 assert!(!legacy_json.contains("provider_adapter_contracts"));
5539 assert_ne!(legacy, projected);
5540
5541 let projected_json = serde_json::to_string(&projected).unwrap();
5542 assert!(projected_json.contains("provider_contracts"));
5543 assert!(projected_json.contains("provider_adapter_contracts"));
5544 assert!(!projected_json.contains("inventory"));
5545 assert!(!projected_json.contains("events"));
5546 assert!(!projected_json.contains("routed_response"));
5547 }
5548
5549 #[test]
5550 fn routed_durable_session_responses_round_trip_without_private_record_fields() {
5551 let record = private_session_record();
5552 let session = record.active_session.clone().unwrap();
5553 let native_selection = NativeSessionSelection {
5554 route: NativeSessionCatalogRoute::workspace(
5555 record.workspace_id.clone(),
5556 record.provider.clone(),
5557 ),
5558 catalog_revision: 7,
5559 recent_cutoff_unix_ms: 70,
5560 selection_id: "selection-private".to_owned(),
5561 };
5562 let responses = vec![
5563 NodeResponse::Snapshot {
5564 event_sequence: 4,
5565 controller: None,
5566 snapshot: NodeSnapshot {
5567 node_id: NodeId::new("node-a").unwrap(),
5568 enabled_providers: vec![provider("codex")],
5569 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5570 workspaces: Vec::new(),
5571 session_records: vec![record.clone()],
5572 managed_worktrees: Vec::new(),
5573 launch_inventory: None,
5574 agent_progress: Vec::new(),
5575 },
5576 },
5577 NodeResponse::ProviderSessionIndexed { record: record.clone() },
5578 NodeResponse::NativeSessionIndexed {
5579 selection: native_selection.clone(),
5580 record: record.clone(),
5581 },
5582 NodeResponse::SessionRecordUpdated { record: record.clone() },
5583 NodeResponse::SessionRecordResumed {
5584 record: record.clone(),
5585 session: session.clone(),
5586 },
5587 NodeResponse::Resync {
5588 event_sequence: 5,
5589 oldest_available_sequence: 1,
5590 snapshot: NodeSnapshot {
5591 node_id: NodeId::new("node-a").unwrap(),
5592 enabled_providers: vec![provider("codex")],
5593 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5594 workspaces: Vec::new(),
5595 session_records: vec![record.clone()],
5596 managed_worktrees: Vec::new(),
5597 launch_inventory: None,
5598 agent_progress: Vec::new(),
5599 },
5600 events: vec![gate4agent_node_protocol::NodeEventEnvelope {
5601 sequence: 5,
5602 event: NodeEvent::SessionRecordUpserted { record: record.clone() },
5603 }],
5604 },
5605 ];
5606
5607 for response in responses {
5608 let json = serde_json::to_string(&routed_response(response)).unwrap();
5609 assert_private_record_fields_absent(&json);
5610 assert!(json.contains("provider_identity_present"));
5611 assert!(json.contains(r#""profile_id":"local-default""#));
5612 assert!(json.contains(
5613 r#""profile_revision":"local-default.2026-08""#,
5614 ));
5615 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5616 let decoded_record = match decoded.response.unwrap() {
5617 C2NodeResponse::Snapshot { snapshot, .. } => snapshot.session_records.into_iter().next().unwrap(),
5618 C2NodeResponse::ProviderSessionIndexed { record }
5619 | C2NodeResponse::NativeSessionIndexed { record, .. }
5620 | C2NodeResponse::SessionRecordUpdated { record }
5621 | C2NodeResponse::SessionRecordResumed { record, .. } => record,
5622 C2NodeResponse::Resync { snapshot, events, .. } => {
5623 assert!(matches!(events.into_iter().next().unwrap().event,
5624 C2NodeEvent::SessionRecordUpserted { ref record }
5625 if record.provider_identity_present));
5626 snapshot.session_records.into_iter().next().unwrap()
5627 }
5628 response => panic!("unexpected response after C2 round trip: {response:?}"),
5629 };
5630 assert_eq!(decoded_record.record_id.as_str(), "session-private");
5631 assert_eq!(decoded_record.display_name, "release shepherd");
5632 assert_eq!(decoded_record.active_session.as_ref(), Some(&session));
5633 assert!(decoded_record.provider_identity_present);
5634 }
5635 }
5636
5637 #[test]
5638 fn c2_resync_projection_preserves_authoritative_replay_floor() {
5639 let source = NodeResponse::Resync {
5640 event_sequence: 12,
5641 oldest_available_sequence: 9,
5642 snapshot: NodeSnapshot {
5643 node_id: NodeId::new("node-a").unwrap(),
5644 enabled_providers: Vec::new(),
5645 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5646 workspaces: Vec::new(),
5647 session_records: Vec::new(),
5648 managed_worktrees: Vec::new(),
5649 launch_inventory: None,
5650 agent_progress: Vec::new(),
5651 },
5652 events: Vec::new(),
5653 };
5654 let projected = C2NodeResponse::from(&source);
5655 assert!(matches!(
5656 projected,
5657 C2NodeResponse::Resync {
5658 event_sequence: 12,
5659 oldest_available_sequence: 9,
5660 ..
5661 }
5662 ));
5663 let encoded = serde_json::to_vec(&projected).unwrap();
5664 assert_eq!(
5665 serde_json::from_slice::<C2NodeResponse>(&encoded).unwrap(),
5666 projected,
5667 );
5668 }
5669
5670 #[test]
5671 fn routed_session_record_event_round_trips_without_private_record_fields() {
5672 let routed = RoutedNodeEvent {
5673 node_id: NodeId::new("node-a").unwrap(),
5674 cursor: NodeCursor {
5675 incarnation_id: NodeIncarnationId::from_bytes([8; 16]),
5676 sequence: 9,
5677 },
5678 event: C2NodeEvent::from(&NodeEvent::SessionRecordUpserted {
5679 record: private_session_record(),
5680 }),
5681 };
5682
5683 let json = serde_json::to_string(&routed).unwrap();
5684 assert_private_record_fields_absent(&json);
5685 assert!(json.contains("provider_identity_present"));
5686 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
5687 let C2NodeEvent::SessionRecordUpserted { record } = decoded.event else {
5688 panic!("unexpected routed event after C2 round trip");
5689 };
5690 assert_eq!(record.record_id.as_str(), "session-private");
5691 assert_eq!(record.display_name, "release shepherd");
5692 assert!(record.provider_identity_present);
5693 }
5694
5695 #[test]
5696 fn routed_node_failure_replaces_raw_message_with_fixed_category() {
5697 let raw = NodeFailure {
5698 code: NodeFailureCode::BackendOperationFailed,
5699 message: r"provider token-secret failed at C:\private\relay.log".to_owned(),
5700 };
5701 let routed = RoutedNodeResponse {
5702 node_id: NodeId::new("node-a").unwrap(),
5703 incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5704 response: Err(C2NodeFailure::from(&raw)),
5705 };
5706
5707 let json = serde_json::to_string(&routed).unwrap();
5708 assert!(!json.contains("token-secret"));
5709 assert!(!json.contains("private"));
5710 assert!(!json.contains("relay.log"));
5711 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5712 assert_eq!(decoded.response.unwrap_err(), C2NodeFailure {
5713 code: NodeFailureCode::BackendOperationFailed,
5714 message: "node backend operation failed".to_owned(),
5715 });
5716 }
5717
5718 #[test]
5719 fn routed_workspace_inspection_omits_raw_git_diagnostics_and_reasons() {
5720 let worktree = GitWorktreeSnapshot {
5721 path: host_path(r"C:\work\feature"),
5722 head: "abc123".to_owned(),
5723 branch: Some("feature/privacy".to_owned()),
5724 is_bare: false,
5725 is_main: false,
5726 locked: true,
5727 lock_reason: Some("lock-secret-provider-token".to_owned()),
5728 prunable: true,
5729 prunable_reason: Some("prunable-secret-private-path".to_owned()),
5730 workspace_id: Some(WorkspaceId::new("feature").unwrap()),
5731 };
5732 let response = routed_response(NodeResponse::WorkspaceInspected {
5733 inspection: WorkspaceInspection {
5734 workspace_id: WorkspaceId::new("primary").unwrap(),
5735 entries: Vec::new(),
5736 tree_truncated: false,
5737 git: GitSnapshot {
5738 is_repository: true,
5739 branch: Some("main".to_owned()),
5740 status: Vec::new(),
5741 recent_commits: Vec::new(),
5742 worktrees: vec![worktree],
5743 managed_worktree: None,
5744 truncated: false,
5745 diagnostic: Some("diagnostic-secret C:\\private\\git.stderr".to_owned()),
5746 },
5747 truncation: None,
5748 },
5749 });
5750
5751 let json = serde_json::to_string(&response).unwrap();
5752 for secret in [
5753 "lock-secret-provider-token",
5754 "prunable-secret-private-path",
5755 "diagnostic-secret",
5756 "git.stderr",
5757 ] {
5758 assert!(!json.contains(secret));
5759 }
5760 assert!(!json.contains("lock_reason"));
5761 assert!(!json.contains("prunable_reason"));
5762 assert!(!json.contains("\"diagnostic\":"));
5763 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5764 let Ok(C2NodeResponse::WorkspaceInspected { inspection }) = decoded.response else {
5765 panic!("unexpected routed workspace response");
5766 };
5767 assert!(inspection.git.diagnostic_present);
5768 assert!(inspection.git.worktrees[0].locked);
5769 assert!(inspection.git.worktrees[0].prunable);
5770 assert_eq!(inspection.git.worktrees[0].path.display_text(), r"C:\work\feature");
5771 }
5772
5773 #[test]
5774 fn routed_workspace_file_read_projects_only_correlated_operator_content() {
5775 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
5776 let response = routed_response(NodeResponse::WorkspaceFileRead {
5777 file: WorkspaceFileRead {
5778 workspace_id: WorkspaceId::new("primary").unwrap(),
5779 path: path.clone(),
5780 content: WorkspaceFileContent::Utf8 {
5781 text: "pub fn fixture() {}\n".to_owned(),
5782 byte_len: 20,
5783 },
5784 revision: None,
5785 },
5786 });
5787
5788 let json = serde_json::to_string(&response).unwrap();
5789 assert!(!json.contains("canonical_root"));
5790 assert!(!json.contains("diagnostic"));
5791 assert!(!json.contains("controller"));
5792 assert!(!json.contains("inventory"));
5793 assert!(!json.contains("event_sequence"));
5794 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5795 assert_eq!(decoded.response, Ok(C2NodeResponse::WorkspaceFileRead {
5796 file: WorkspaceFileRead {
5797 workspace_id: WorkspaceId::new("primary").unwrap(),
5798 path,
5799 content: WorkspaceFileContent::Utf8 {
5800 text: "pub fn fixture() {}\n".to_owned(),
5801 byte_len: 20,
5802 },
5803 revision: None,
5804 },
5805 }));
5806 }
5807
5808 #[test]
5809 fn workspace_entry_create_projects_exact_bounded_c2_contracts() {
5810 assert_eq!(
5811 C2_WORKSPACE_ENTRY_CREATE_CAPABILITY,
5812 "workspace-entry-create-v1",
5813 );
5814 let workspace_id = WorkspaceId::new("primary").unwrap();
5815 let file = WorkspaceFileRead {
5816 workspace_id: workspace_id.clone(),
5817 path: repository_path("src/new.rs"),
5818 content: WorkspaceFileContent::Utf8 {
5819 text: String::new(),
5820 byte_len: 0,
5821 },
5822 revision: Some(
5823 gate4agent_node_protocol::WorkspaceFileRevision::new(
5824 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
5825 .to_owned(),
5826 )
5827 .unwrap(),
5828 ),
5829 };
5830 let file_response = C2NodeResponse::from(
5831 &NodeResponse::WorkspaceFileCreated { file: file.clone() },
5832 );
5833 assert_eq!(
5834 file_response,
5835 C2NodeResponse::WorkspaceFileCreated { file },
5836 );
5837 assert!(file_response.requires_workspace_entry_create_capability());
5838
5839 let entry = WorkspaceEntry {
5840 relative_path: repository_path("src/new"),
5841 kind: WorkspaceEntryKind::Directory,
5842 };
5843 let directory_response = C2NodeResponse::from(
5844 &NodeResponse::WorkspaceDirectoryCreated {
5845 workspace_id: workspace_id.clone(),
5846 entry: entry.clone(),
5847 },
5848 );
5849 assert_eq!(
5850 directory_response,
5851 C2NodeResponse::WorkspaceDirectoryCreated {
5852 workspace_id,
5853 entry,
5854 },
5855 );
5856 assert!(directory_response.requires_workspace_entry_create_capability());
5857 let encoded = serde_json::to_string(&directory_response).unwrap();
5858 assert_eq!(
5859 serde_json::from_str::<C2NodeResponse>(&encoded).unwrap(),
5860 directory_response,
5861 );
5862
5863 for (code, message) in [
5864 (NodeFailureCode::RepositoryEntryAlreadyExists, "repository entry already exists"),
5865 (NodeFailureCode::RepositoryParentNotFound, "repository parent directory unavailable"),
5866 (NodeFailureCode::RepositoryParentNotDirectory, "repository parent path is not a directory"),
5867 (NodeFailureCode::RepositoryEntryCreateTimedOut, "repository entry creation timed out"),
5868 (NodeFailureCode::RepositoryEntryCreateFailed, "repository entry creation failed"),
5869 ] {
5870 assert_eq!(
5871 C2NodeFailure::from(&NodeFailure {
5872 code,
5873 message: "private node detail".to_owned(),
5874 })
5875 .message,
5876 message,
5877 );
5878 }
5879 }
5880
5881 #[test]
5882 fn c2_host_directory_browse_projection_is_bounded_and_sanitizes_failures() {
5883 assert_eq!(C2_HOST_DIRECTORY_BROWSE_CAPABILITY, "host-directory-browse-v1");
5884 let directory = host_path(r"C:\Users");
5885 let entry = HostDirectoryEntry {
5886 path: host_path(r"C:\Users\Public"),
5887 display_name: "Public".to_owned(),
5888 is_link: false,
5889 };
5890 let response = routed_response(NodeResponse::HostDirectoriesBrowsed {
5891 listing: HostDirectoryListing {
5892 directory: Some(directory.clone()),
5893 parent: Some(host_path(r"C:\")),
5894 entries: vec![entry.clone()],
5895 next_after: Some(entry.path.clone()),
5896 incomplete: true,
5897 },
5898 });
5899 let json = serde_json::to_string(&response).unwrap();
5900 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5901 assert_eq!(decoded, response);
5902 assert!(matches!(
5903 decoded.response,
5904 Ok(C2NodeResponse::HostDirectoriesBrowsed {
5905 listing: HostDirectoryListing {
5906 directory: Some(ref actual),
5907 incomplete: true,
5908 ..
5909 }
5910 }) if actual == &directory
5911 ));
5912
5913 let mut oversized = serde_json::to_value(C2NodeResponse::HostDirectoriesBrowsed {
5914 listing: HostDirectoryListing {
5915 directory: None,
5916 parent: None,
5917 entries: vec![entry; gate4agent_node_protocol::MAX_HOST_DIRECTORY_ENTRIES + 1],
5918 next_after: None,
5919 incomplete: false,
5920 },
5921 }).unwrap();
5922 assert!(serde_json::from_value::<C2NodeResponse>(oversized.take()).is_err());
5923
5924 for (code, expected) in [
5925 (NodeFailureCode::HostDirectoryInvalid, "host directory invalid"),
5926 (NodeFailureCode::HostDirectoryReadFailed, "host directory read failed"),
5927 (NodeFailureCode::HostDirectoryReadTimedOut, "host directory read timed out"),
5928 ] {
5929 let failure = C2NodeFailure::from(&NodeFailure {
5930 code,
5931 message: r"private C:\Users\owner\secret".to_owned(),
5932 });
5933 assert_eq!(failure.message, expected);
5934 assert!(failure.requires_host_directory_browse_capability());
5935 assert!(!failure.message.contains("private"));
5936 }
5937 }
5938
5939 #[test]
5940 fn c2_projection_roundtrips_non_utf8_host_paths_without_interpretation() {
5941 let opaque = OpaqueHostPath::unix_bytes(vec![b'/', b's', b'r', b'v', b'/', 0xff, b'\n', 0x1b]).unwrap();
5942 let projected = C2NodeSnapshot::from(&NodeSnapshot {
5943 node_id: NodeId::new("remote-linux").unwrap(),
5944 enabled_providers: Vec::new(),
5945 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5946 workspaces: vec![WorkspaceSnapshot {
5947 workspace_id: WorkspaceId::new("repo").unwrap(),
5948 canonical_root: opaque.clone(),
5949 sessions: Vec::new(),
5950 worktree_service_mode: None,
5951 managed_worktree_profiles: None,
5952 }],
5953 session_records: Vec::new(),
5954 managed_worktrees: Vec::new(),
5955 launch_inventory: None,
5956 agent_progress: Vec::new(),
5957 });
5958
5959 let encoded = serde_json::to_string(&projected).unwrap();
5960 let decoded = serde_json::from_str::<C2NodeSnapshot>(&encoded).unwrap();
5961
5962 assert_eq!(decoded.workspaces[0].canonical_root, opaque);
5963 assert_eq!(decoded.workspaces[0].workspace_id.as_str(), "repo");
5964 let slim = SlimNodeInventory::from_c2_snapshot(&decoded);
5965 let display = &slim.workspaces[&WorkspaceId::new("repo").unwrap()].canonical_root;
5966 assert!(!display.chars().any(char::is_control));
5967 assert!(display.contains("\\n"));
5968 assert!(display.contains("\\u{1b}"));
5969 }
5970
5971 #[test]
5972 fn standalone_workspace_response_roundtrips_authoritative_snapshot() {
5973 assert_eq!(
5974 C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
5975 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
5976 );
5977 let workspace = WorkspaceSnapshot {
5978 workspace_id: WorkspaceId::new("standalone").unwrap(),
5979 canonical_root: host_path(r"C:\standalone"),
5980 sessions: Vec::new(),
5981 worktree_service_mode: Some(WorktreeServiceMode::Manual),
5982 managed_worktree_profiles: Some(WorktreeProfileInventory {
5983 profiles: Vec::new(),
5984 }),
5985 };
5986 let projected = C2NodeResponse::from(
5987 &NodeResponse::StandaloneWorkspaceCreated {
5988 workspace: workspace.clone(),
5989 },
5990 );
5991 let encoded = serde_json::to_string(&projected).unwrap();
5992 let decoded = serde_json::from_str::<C2NodeResponse>(&encoded).unwrap();
5993
5994 assert_eq!(
5995 decoded,
5996 C2NodeResponse::StandaloneWorkspaceCreated {
5997 workspace: C2WorkspaceSnapshot::from(&workspace),
5998 },
5999 );
6000 }
6001
6002 #[test]
6003 fn routed_worktree_created_omits_raw_git_reasons() {
6004 let response = routed_response(NodeResponse::WorktreeCreated {
6005 worktree: GitWorktreeSnapshot {
6006 path: host_path(r"C:\work\feature"),
6007 head: "abc123".to_owned(),
6008 branch: Some("feature/privacy".to_owned()),
6009 is_bare: false,
6010 is_main: false,
6011 locked: true,
6012 lock_reason: Some("created-lock-secret".to_owned()),
6013 prunable: true,
6014 prunable_reason: Some("created-prunable-secret".to_owned()),
6015 workspace_id: Some(WorkspaceId::new("feature").unwrap()),
6016 },
6017 workspace: WorkspaceSnapshot {
6018 workspace_id: WorkspaceId::new("feature").unwrap(),
6019 canonical_root: host_path(r"C:\work\feature"),
6020 sessions: Vec::new(),
6021 worktree_service_mode: None,
6022 managed_worktree_profiles: None,
6023 },
6024 });
6025
6026 let json = serde_json::to_string(&response).unwrap();
6027 assert!(!json.contains("created-lock-secret"));
6028 assert!(!json.contains("created-prunable-secret"));
6029 assert!(!json.contains("lock_reason"));
6030 assert!(!json.contains("prunable_reason"));
6031 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
6032 assert!(matches!(decoded.response, Ok(C2NodeResponse::WorktreeCreated {
6033 worktree: C2GitWorktreeSnapshot { locked: true, prunable: true, .. },
6034 ..
6035 })));
6036 }
6037
6038 #[test]
6039 fn routed_resume_authorized_control_event_omits_provider_session_identity() {
6040 let routed = routed_control_event(ControlEventKind::ResumeAuthorized {
6041 session: ResumeSessionSummary {
6042 key: ProviderSessionKey::SessionId,
6043 id: "private-resume-session-id".to_owned(),
6044 },
6045 });
6046
6047 let json = serde_json::to_string(&routed).unwrap();
6048 assert!(json.contains("resume-authorized"));
6049 assert!(!json.contains("private-resume-session-id"));
6050 assert!(!json.contains("provider_session"));
6051 assert!(!json.contains("transcript_path"));
6052 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
6053 assert!(matches!(decoded.event, C2NodeEvent::Control {
6054 event: C2ControlEvent { event: C2ControlEventKind::ResumeAuthorized, .. },
6055 ..
6056 }));
6057 }
6058
6059 #[test]
6060 fn routed_session_identity_observed_control_event_omits_provider_identity_and_path() {
6061 let routed = routed_control_event(ControlEventKind::ProviderEvent {
6062 sequence: 19,
6063 source: provider_source(),
6064 source_sequence: 7,
6065 event: ProviderEvent::SessionIdentityObserved {
6066 identity: ProviderSessionIdentity {
6067 key: ProviderSessionKey::ConversationId,
6068 id: "private-observed-provider-id".to_owned(),
6069 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
6070 },
6071 },
6072 });
6073
6074 let json = serde_json::to_string(&routed).unwrap();
6075 assert!(json.contains("session-identity-observed"));
6076 assert!(!json.contains("private-observed-provider-id"));
6077 assert!(!json.contains("provider-transcript.jsonl"));
6078 assert!(!json.contains("transcript_path"));
6079 assert!(!json.contains("\"identity\":"));
6080 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
6081 assert!(matches!(decoded.event, C2NodeEvent::Control {
6082 event: C2ControlEvent {
6083 event: C2ControlEventKind::ProviderEvent {
6084 event: C2ProviderEventKind::SessionIdentityObserved,
6085 },
6086 ..
6087 },
6088 ..
6089 }));
6090 }
6091
6092 #[test]
6093 fn routed_provider_interaction_resolution_is_categorical_only() {
6094 let routed = routed_control_event(ControlEventKind::ProviderEvent {
6095 sequence: 20,
6096 source: provider_source(),
6097 source_sequence: 8,
6098 event: ProviderEvent::InteractionResolved {
6099 request_id: "private-provider-request-id".to_owned(),
6100 outcome: ProviderInteractionOutcome::Denied,
6101 },
6102 });
6103
6104 let json = serde_json::to_string(&routed).unwrap();
6105 assert!(json.contains("interaction-resolved"));
6106 assert!(!json.contains("private-provider-request-id"));
6107 assert!(!json.contains("denied"));
6108 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
6109 assert!(matches!(decoded.event, C2NodeEvent::Control {
6110 event: C2ControlEvent {
6111 event: C2ControlEventKind::ProviderEvent {
6112 event: C2ProviderEventKind::InteractionResolved,
6113 },
6114 ..
6115 },
6116 ..
6117 }));
6118 }
6119
6120 #[test]
6121 fn routed_snapshot_recursively_omits_provider_identity_and_error_state() {
6122 let mut session = fixture_session();
6123 session.status = SessionStatus::Failed {
6124 message: "private-session-failure".to_owned(),
6125 };
6126 session.terminal_stale = Some("private-terminal-error".to_owned());
6127 session.history.last_error = Some("private-history-error".to_owned());
6128 session.resume.last_session = Some(ResumeSessionSummary {
6129 key: ProviderSessionKey::SessionId,
6130 id: "private-resume-summary-id".to_owned(),
6131 });
6132 session.resume.last_error = Some("private-resume-error".to_owned());
6133 session.foreground.stale_reason = Some("private-foreground-error".to_owned());
6134 session.provider.session = Some(ProviderSessionIdentity {
6135 key: ProviderSessionKey::SessionId,
6136 id: "private-snapshot-provider-id".to_owned(),
6137 transcript_path: Some(r"C:\private\snapshot-transcript.jsonl".to_owned()),
6138 });
6139 session.provider.current_prompt = Some("private-current-prompt".to_owned());
6140 session.provider.last_event = Some(ProviderEvent::Error {
6141 message: "private-provider-error".to_owned(),
6142 });
6143 let routed = routed_response(NodeResponse::Snapshot {
6144 event_sequence: 4,
6145 controller: None,
6146 snapshot: NodeSnapshot {
6147 node_id: NodeId::new("node-a").unwrap(),
6148 enabled_providers: vec![provider("codex")],
6149 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6150 workspaces: vec![WorkspaceSnapshot {
6151 workspace_id: WorkspaceId::new("primary").unwrap(),
6152 canonical_root: host_path(r"C:\workspace"),
6153 sessions: vec![session],
6154 worktree_service_mode: None,
6155 managed_worktree_profiles: None,
6156 }],
6157 session_records: Vec::new(),
6158 managed_worktrees: Vec::new(),
6159 launch_inventory: None,
6160 agent_progress: Vec::new(),
6161 },
6162 });
6163
6164 let json = serde_json::to_string(&routed).unwrap();
6165 for secret in [
6166 "private-session-failure",
6167 "private-terminal-error",
6168 "private-history-error",
6169 "private-resume-summary-id",
6170 "private-resume-error",
6171 "private-foreground-error",
6172 "private-snapshot-provider-id",
6173 "snapshot-transcript.jsonl",
6174 "private-current-prompt",
6175 "private-provider-error",
6176 ] {
6177 assert!(!json.contains(secret));
6178 }
6179 assert!(!json.contains("provider_session"));
6180 assert!(!json.contains("transcript_path"));
6181 assert!(!json.contains("last_error"));
6182 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
6183 assert!(matches!(decoded.response, Ok(C2NodeResponse::Snapshot { snapshot, .. })
6184 if matches!(snapshot.workspaces[0].sessions[0].status, C2SessionStatus::Failed)
6185 && snapshot.workspaces[0].sessions[0].provider_identity_present));
6186 }
6187
6188 #[test]
6189 fn slim_managed_sessions_are_sorted_bounded_and_privacy_minimized() {
6190 let long_name = "ж".repeat((MAX_C2_SESSION_DISPLAY_NAME_BYTES / 2) + 4);
6191 let make_record = |record_id: &str, display_name: String| ManagedSessionRecord {
6192 record_id: SessionRecordId::new(record_id).unwrap(),
6193 display_name,
6194 provider: provider("codex"),
6195 mode: SessionMode::Pty,
6196 state: ManagedSessionState::Dormant,
6197 workspace_id: WorkspaceId::new("primary").unwrap(),
6198 canonical_root: host_path(r"C:\private\workspace"),
6199 provider_session: Some(ProviderSessionIdentity {
6200 key: ProviderSessionKey::SessionId,
6201 id: "5af75a6b-3e64-41dd-96fa-private-provider-id".to_owned(),
6202 transcript_path: Some(r"C:\private\transcript.jsonl".to_owned()),
6203 }),
6204 active_session: None,
6205 environment_profile: None,
6206 bundle: None,
6207 context_id: None,
6208 context: None,
6209 exported_context: None,
6210 task_binding: None,
6211 created_at_unix_ms: 10,
6212 updated_at_unix_ms: 20,
6213 last_error: Some("private backend diagnostic".to_owned()),
6214 };
6215 let snapshot = NodeSnapshot {
6216 node_id: NodeId::new("node-a").unwrap(),
6217 enabled_providers: vec![provider("codex")],
6218 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6219 workspaces: Vec::new(),
6220 session_records: vec![
6221 make_record("session-z", "z".to_owned()),
6222 make_record("session-a", long_name),
6223 ],
6224 managed_worktrees: Vec::new(),
6225 launch_inventory: None,
6226 agent_progress: Vec::new(),
6227 };
6228
6229 let slim = SlimNodeInventory::from_snapshot(&snapshot);
6230 assert_eq!(slim.managed_session_count, 2);
6231 assert!(!slim.managed_sessions_truncated);
6232 assert_eq!(
6233 slim.managed_sessions.iter().map(|record| record.record_id.as_str()).collect::<Vec<_>>(),
6234 vec!["session-a", "session-z"],
6235 );
6236 assert_eq!(slim.managed_sessions[0].display_name.len(), MAX_C2_SESSION_DISPLAY_NAME_BYTES);
6237 assert!(slim.managed_sessions[0].display_name_truncated);
6238 assert!(slim.managed_sessions[0].provider_identity_present);
6239
6240 let json = serde_json::to_string(&slim).unwrap();
6241 assert!(!json.contains("5af75a6b-3e64-41dd-96fa-private-provider-id"));
6242 assert!(!json.contains("transcript.jsonl"));
6243 assert!(!json.contains("private backend diagnostic"));
6244 assert!(!json.contains("private\\\\workspace"));
6245 assert!(!json.contains("provider_session"));
6246 assert!(!json.contains("last_error"));
6247 }
6248
6249 #[test]
6250 fn slim_inventory_defaults_managed_sessions_for_v1_payloads() {
6251 let legacy = r#"{"node_id":"node-a","enabled_providers":[],"workspaces":{},"workspace_count":0,"workspaces_truncated":false,"session_count":0,"sessions_truncated":false}"#;
6252 let inventory = serde_json::from_str::<SlimNodeInventory>(legacy).unwrap();
6253 assert!(inventory.provider_contracts.is_empty());
6254 assert!(inventory.provider_adapter_contracts.is_empty());
6255 assert!(inventory.managed_sessions.is_empty());
6256 assert_eq!(inventory.managed_session_count, 0);
6257 assert!(!inventory.managed_sessions_truncated);
6258 let reencoded = serde_json::to_string(&inventory).unwrap();
6259 assert!(!reencoded.contains("provider_contracts"));
6260 assert!(!reencoded.contains("provider_adapter_contracts"));
6261 assert_eq!(inventory.retired_count, 0);
6262 assert!(!reencoded.contains("retired_count"));
6263 }
6264
6265 #[test]
6270 fn slim_inventory_retired_count_round_trips_and_defaults_to_zero() {
6271 let legacy = r#"{"node_id":"node-a","enabled_providers":[],"workspaces":{},"workspace_count":0,"workspaces_truncated":false,"session_count":0,"sessions_truncated":false}"#;
6272 let inventory = serde_json::from_str::<SlimNodeInventory>(legacy).unwrap();
6273 assert_eq!(inventory.retired_count, 0);
6274
6275 let mut inventory = inventory;
6276 inventory.retired_count = 7;
6277 let encoded = serde_json::to_string(&inventory).unwrap();
6278 assert!(encoded.contains("\"retired_count\":7"));
6279 let decoded: SlimNodeInventory = serde_json::from_str(&encoded).unwrap();
6280 assert_eq!(decoded.retired_count, 7);
6281 }
6282
6283 #[test]
6284 fn slim_inventory_reports_managed_session_truncation() {
6285 let session_records = (0..=MAX_C2_MANAGED_SESSIONS_PER_NODE)
6286 .map(|index| ManagedSessionRecord {
6287 record_id: SessionRecordId::new(format!("session-{index:03}")).unwrap(),
6288 display_name: format!("session {index}"),
6289 provider: provider("claude"),
6290 mode: SessionMode::Inline,
6291 state: ManagedSessionState::Unavailable,
6292 workspace_id: WorkspaceId::new("primary").unwrap(),
6293 canonical_root: host_path(r"C:\repo"),
6294 provider_session: None,
6295 active_session: None,
6296 environment_profile: None,
6297 bundle: None,
6298 context_id: None,
6299 context: None,
6300 exported_context: None,
6301 task_binding: None,
6302 created_at_unix_ms: index as u64,
6303 updated_at_unix_ms: index as u64,
6304 last_error: None,
6305 })
6306 .collect::<Vec<_>>();
6307 let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
6308 node_id: NodeId::new("node-a").unwrap(),
6309 enabled_providers: Vec::new(),
6310 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6311 workspaces: Vec::new(),
6312 session_records,
6313 managed_worktrees: Vec::new(),
6314 launch_inventory: None,
6315 agent_progress: Vec::new(),
6316 });
6317 assert_eq!(slim.managed_session_count, MAX_C2_MANAGED_SESSIONS_PER_NODE + 1);
6318 assert_eq!(slim.managed_sessions.len(), MAX_C2_MANAGED_SESSIONS_PER_NODE);
6319 assert!(slim.managed_sessions_truncated);
6320 }
6321
6322 #[test]
6323 fn slim_managed_sessions_page_keeps_the_live_record_even_when_it_sorts_last_by_id() {
6324 let stale_count = MAX_C2_MANAGED_SESSIONS_PER_NODE + 2;
6334 let mut session_records = (0..stale_count)
6335 .map(|index| ManagedSessionRecord {
6336 record_id: SessionRecordId::new(format!("session-{index:03}")).unwrap(),
6337 display_name: format!("session {index}"),
6338 provider: provider("claude"),
6339 mode: SessionMode::Inline,
6340 state: ManagedSessionState::Unavailable,
6341 workspace_id: WorkspaceId::new("primary").unwrap(),
6342 canonical_root: host_path(r"C:\repo"),
6343 provider_session: None,
6344 active_session: None,
6345 environment_profile: None,
6346 bundle: None,
6347 context_id: None,
6348 context: None,
6349 exported_context: None,
6350 task_binding: None,
6351 created_at_unix_ms: index as u64,
6352 updated_at_unix_ms: index as u64,
6353 last_error: None,
6354 })
6355 .collect::<Vec<_>>();
6356 session_records.push(ManagedSessionRecord {
6357 record_id: SessionRecordId::new(format!("session-{stale_count:03}")).unwrap(),
6358 display_name: "live-run".to_owned(),
6359 provider: provider("claude"),
6360 mode: SessionMode::Pty,
6361 state: ManagedSessionState::Live,
6362 workspace_id: WorkspaceId::new("primary").unwrap(),
6363 canonical_root: host_path(r"C:\repo"),
6364 provider_session: None,
6365 active_session: None,
6366 environment_profile: None,
6367 bundle: None,
6368 context_id: None,
6369 context: None,
6370 exported_context: None,
6371 task_binding: None,
6372 created_at_unix_ms: stale_count as u64,
6373 updated_at_unix_ms: stale_count as u64,
6374 last_error: None,
6375 });
6376 let pending_index = stale_count + 1;
6377 session_records.push(ManagedSessionRecord {
6378 record_id: SessionRecordId::new(format!("session-{pending_index:03}")).unwrap(),
6379 display_name: "pending-spawn".to_owned(),
6380 provider: provider("claude"),
6381 mode: SessionMode::Pty,
6382 state: ManagedSessionState::IdentityPending,
6383 workspace_id: WorkspaceId::new("primary").unwrap(),
6384 canonical_root: host_path(r"C:\repo"),
6385 provider_session: None,
6386 active_session: None,
6387 environment_profile: None,
6388 bundle: None,
6389 context_id: None,
6390 context: None,
6391 exported_context: None,
6392 task_binding: None,
6393 created_at_unix_ms: pending_index as u64,
6394 updated_at_unix_ms: pending_index as u64,
6395 last_error: None,
6396 });
6397 let total = session_records.len();
6398 assert!(total > MAX_C2_MANAGED_SESSIONS_PER_NODE);
6399
6400 let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
6401 node_id: NodeId::new("node-a").unwrap(),
6402 enabled_providers: Vec::new(),
6403 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6404 workspaces: Vec::new(),
6405 session_records,
6406 managed_worktrees: Vec::new(),
6407 launch_inventory: None,
6408 agent_progress: Vec::new(),
6409 });
6410
6411 assert_eq!(slim.managed_session_count, total);
6412 assert!(slim.managed_sessions_truncated);
6413 assert_eq!(slim.managed_sessions.len(), MAX_C2_MANAGED_SESSIONS_PER_NODE);
6414 assert!(
6415 slim.managed_sessions.iter().any(|record| record.state == ManagedSessionState::Live),
6416 "the live record must survive the page cut even though its id sorts last",
6417 );
6418 assert!(
6419 slim.managed_sessions.iter()
6420 .any(|record| record.state == ManagedSessionState::IdentityPending),
6421 "the identity-pending record must survive the page cut even though its id sorts last",
6422 );
6423 assert_eq!(slim.managed_sessions[0].state, ManagedSessionState::Live);
6424 assert_eq!(slim.managed_sessions[1].state, ManagedSessionState::IdentityPending);
6425 assert!(
6426 slim.managed_sessions[2..].iter().all(|record| record.state == ManagedSessionState::Unavailable),
6427 "every Unavailable record on the page must rank after Live and IdentityPending",
6428 );
6429 }
6430
6431 #[test]
6432 fn managed_worktree_projection_is_bounded_and_contains_no_git_or_path_details() {
6433 fn lease(index: usize) -> ManagedWorktreeLeaseSnapshot {
6434 ManagedWorktreeLeaseSnapshot {
6435 lease_id: ManagedWorktreeLeaseId::new(format!("lease-{index}")).unwrap(),
6436 source_workspace_id: WorkspaceId::new("primary").unwrap(),
6437 workspace_id: WorkspaceId::new(format!("managed-{index}")).unwrap(),
6438 profile_id: WorktreeProfileId::new("review").unwrap(),
6439 profile_revision: WorktreeProfileRevision::new("review.r1").unwrap(),
6440 retention: ManagedWorktreeRetention::RemoveWhenReleased,
6441 state: ManagedWorktreeLeaseState::Ready,
6442 active_session_count: 0,
6443 managed_record_count: 0,
6444 cleanup_failure: None,
6445 created_at_unix_ms: 1,
6446 updated_at_unix_ms: 2,
6447 }
6448 }
6449
6450 let projected = C2NodeSnapshot::from(&NodeSnapshot {
6451 node_id: NodeId::new("node-a").unwrap(),
6452 enabled_providers: Vec::new(),
6453 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6454 workspaces: Vec::new(),
6455 session_records: Vec::new(),
6456 managed_worktrees: vec![lease(0)],
6457 launch_inventory: None,
6458 agent_progress: Vec::new(),
6459 });
6460 let json = serde_json::to_string(&projected).unwrap();
6461 assert!(json.contains("managed_worktrees"));
6462 for forbidden in ["canonical", "path", "gitdir", "branch", "base_commit", "diagnostic"] {
6463 assert!(!json.contains(forbidden), "managed projection leaked {forbidden}");
6464 }
6465
6466 let overflow = C2NodeSnapshot {
6467 node_id: NodeId::new("node-a").unwrap(),
6468 enabled_providers: Vec::new(),
6469 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6470 workspaces: Vec::new(),
6471 session_records: Vec::new(),
6472 agent_progress: Vec::new(),
6473 managed_worktrees: (0..=MAX_C2_MANAGED_WORKTREES_PER_NODE)
6474 .map(lease)
6475 .collect(),
6476 launch_inventory: None,
6477 machine: None,
6478 };
6479 let encoded = serde_json::to_value(overflow).unwrap();
6480 assert!(serde_json::from_value::<C2NodeSnapshot>(encoded).is_err());
6481 }
6482}