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 machine: None,
5228 };
5229
5230 let full = C2NodeSnapshot::from(&snapshot);
5231 let slim_from_node = SlimNodeInventory::from_snapshot(&snapshot);
5232 let slim_from_c2 = SlimNodeInventory::from_c2_snapshot(&full);
5233 assert_eq!(full.launch_inventory.as_ref(), Some(&launch_inventory));
5234 assert_eq!(slim_from_node.launch_inventory.as_ref(), Some(&launch_inventory));
5235 assert_eq!(slim_from_c2.launch_inventory.as_ref(), Some(&launch_inventory));
5236 assert_eq!(
5237 full.workspaces[0].worktree_service_mode,
5238 Some(WorktreeServiceMode::Managed),
5239 );
5240 assert_eq!(
5241 slim_from_node.workspaces[&WorkspaceId::new("repo").unwrap()]
5242 .worktree_service_mode,
5243 Some(WorktreeServiceMode::Managed),
5244 );
5245 assert_eq!(
5246 slim_from_c2.workspaces[&WorkspaceId::new("repo").unwrap()]
5247 .worktree_service_mode,
5248 Some(WorktreeServiceMode::Managed),
5249 );
5250 assert_eq!(
5251 full.workspaces[0].managed_worktree_profiles.as_ref(),
5252 Some(&worktree_profiles),
5253 );
5254 assert_eq!(
5255 slim_from_node.workspaces[&WorkspaceId::new("repo").unwrap()]
5256 .managed_worktree_profiles
5257 .as_ref(),
5258 Some(&worktree_profiles),
5259 );
5260
5261 let legacy = serde_json::from_value::<C2WorkspaceSnapshot>(serde_json::json!({
5262 "workspace_id": "legacy",
5263 "canonical_root": "legacy-root",
5264 "sessions": [],
5265 }))
5266 .unwrap();
5267 assert_eq!(legacy.worktree_service_mode, None);
5268 assert_eq!(legacy.managed_worktree_profiles, None);
5269 let encoded = serde_json::to_value(legacy).unwrap();
5270 assert!(encoded.get("worktree_service_mode").is_none());
5271 assert!(encoded.get("managed_worktree_profiles").is_none());
5272 }
5273
5274 #[test]
5275 fn slim_inventory_provider_contract_projection_is_exact_and_private() {
5276 let mut slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5277 node_id: NodeId::new("node-a").unwrap(),
5278 enabled_providers: vec![provider("codex")],
5279 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5280 workspaces: Vec::new(),
5281 session_records: Vec::new(),
5282 managed_worktrees: Vec::new(),
5283 launch_inventory: None,
5284 agent_progress: Vec::new(),
5285 });
5286 let (provider_contracts, provider_adapter_contracts) =
5287 provider_contract_manifest();
5288 slim.provider_contracts = provider_contracts;
5289 slim.provider_adapter_contracts = provider_adapter_contracts;
5290
5291 let value = serde_json::to_value(&slim).unwrap();
5292 assert_eq!(
5293 value["provider_contracts"],
5294 serde_json::json!([{
5295 "provider": "codex",
5296 "revision": "codex.2026-08",
5297 }]),
5298 );
5299 assert_eq!(
5300 value["provider_adapter_contracts"],
5301 serde_json::json!([{
5302 "provider": "codex",
5303 "family": "pty-semantic",
5304 "adapter_id": "codex",
5305 "revision": "pty-semantic.2026-08",
5306 }]),
5307 );
5308 let json = serde_json::to_string(&value).unwrap();
5309 for forbidden in [
5310 "installed_cli_version",
5311 "executable_path",
5312 "auth_state",
5313 "canary_verdict",
5314 "events",
5315 "routed_response",
5316 ] {
5317 assert!(!json.contains(forbidden), "leaked private field {forbidden}");
5318 }
5319 }
5320
5321 #[test]
5322 fn c2_projection_omits_path_stdout_stderr_env_and_fallback_reason() {
5323 let runtime_statuses = ProviderRuntimeStatuses::new([
5324 ProviderRuntimeStatus::raw_passthrough(
5325 provider("codex"),
5326 Some(ProviderRuntimeVersion::new("0.999.0").unwrap()),
5327 ),
5328 ])
5329 .unwrap();
5330 let snapshot = NodeSnapshot {
5331 node_id: NodeId::new("node-a").unwrap(),
5332 enabled_providers: vec![provider("codex")],
5333 provider_runtime_statuses: runtime_statuses.clone(),
5334 workspaces: Vec::new(),
5335 session_records: Vec::new(),
5336 managed_worktrees: Vec::new(),
5337 launch_inventory: None,
5338 agent_progress: Vec::new(),
5339 };
5340 let projected = C2NodeSnapshot::from(&snapshot);
5341 let inventory = SlimNodeInventory::from_snapshot(&snapshot);
5342 assert_eq!(projected.provider_runtime_statuses, runtime_statuses);
5343 assert_eq!(inventory.provider_runtime_statuses, runtime_statuses);
5344 for encoded in [
5345 serde_json::to_string(&projected).unwrap(),
5346 serde_json::to_string(&inventory).unwrap(),
5347 ] {
5348 assert!(encoded.contains("\"version\":\"0.999.0\""));
5349 for forbidden in [
5350 "launcher", "executable", "stdout", "stderr", "environment", "fallback",
5351 "reason", "arguments",
5352 ] {
5353 assert!(!encoded.contains(forbidden), "leaked field {forbidden}");
5354 }
5355 }
5356 }
5357
5358 #[test]
5359 fn slim_inventory_reports_sessions_hidden_by_workspace_truncation() {
5360 let mut workspaces = (0..MAX_C2_WORKSPACES_PER_NODE)
5361 .map(|index| WorkspaceSnapshot {
5362 workspace_id: WorkspaceId::new(format!("work-{index:02}")).unwrap(),
5363 canonical_root: host_path(format!("root-{index:02}")),
5364 sessions: Vec::new(),
5365 worktree_service_mode: None,
5366 managed_worktree_profiles: None,
5367 })
5368 .collect::<Vec<_>>();
5369 workspaces.push(WorkspaceSnapshot {
5370 workspace_id: WorkspaceId::new("work-zz").unwrap(),
5371 canonical_root: host_path("hidden-root"),
5372 sessions: vec![fixture_session()],
5373 worktree_service_mode: None,
5374 managed_worktree_profiles: None,
5375 });
5376 let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5377 node_id: NodeId::new("node-a").unwrap(),
5378 enabled_providers: Vec::new(),
5379 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5380 workspaces,
5381 session_records: Vec::new(),
5382 managed_worktrees: Vec::new(),
5383 launch_inventory: None,
5384 agent_progress: Vec::new(),
5385 });
5386 assert!(slim.workspaces_truncated);
5387 assert_eq!(slim.session_count, 1);
5388 assert!(slim.sessions_truncated);
5389 }
5390
5391 #[test]
5392 fn control_auth_transcript_is_direction_and_protocol_domain_separated() {
5393 assert_eq!(C2_API_VERSION, 2);
5394 let client_nonce = [3; C2_AUTH_NONCE_BYTES];
5395 let server_nonce = [7; C2_AUTH_NONCE_BYTES];
5396 let server = c2_auth_transcript(C2AuthDirection::Server, &client_nonce, &server_nonce);
5397 let client = c2_auth_transcript(C2AuthDirection::Client, &client_nonce, &server_nonce);
5398 assert_ne!(server, client);
5399 assert!(server.starts_with(b"gate4agent-c2-control-auth-v2\0"));
5400 assert!(!server.windows(b"gate4agent-node-auth-v3".len()).any(|window| window == b"gate4agent-node-auth-v3"));
5401 assert_eq!(&server[server.len() - (C2_AUTH_NONCE_BYTES * 2)..server.len() - C2_AUTH_NONCE_BYTES], &client_nonce);
5402 assert_eq!(&server[server.len() - C2_AUTH_NONCE_BYTES..], &server_nonce);
5403 }
5404
5405 #[test]
5406 fn topology_projection_is_sorted_bounded_and_minimal() {
5407 let nodes = (0..=MAX_C2_NODES).rev().map(|index| {
5408 let node_id = NodeId::new(format!("node-{index:03}")).unwrap();
5409 let incarnation_id = NodeIncarnationId::from_bytes([index as u8; 16]);
5410 let observed = ObservedNode {
5411 endpoint: format!(r"\\.\pipe\node-{index:03}"),
5412 transport_label: "windows-named-pipe".to_owned(),
5413 transport: NodeTransportState::Online,
5414 freshness: NodeFreshness::Fresh,
5415 cursor: Some(NodeCursor { incarnation_id, sequence: 99 }),
5416 inventory: None,
5417 machine: None,
5418 last_attempt_unix_ms: Some(10),
5419 last_success_unix_ms: Some(10),
5420 consecutive_failures: 0,
5421 last_error: None,
5422 gaps: Vec::new(),
5423 gaps_truncated: 0,
5424 };
5425 (node_id, observed)
5426 }).collect();
5427 let topology = C2Topology::from_status(&StatusResponse {
5428 api_version: C2_API_VERSION,
5429 ready: true,
5430 observed_at_unix_ms: 10,
5431 nodes,
5432 });
5433
5434 assert_eq!(topology.nodes.len(), MAX_C2_NODES);
5435 assert_eq!(topology.nodes.first().unwrap().node_id.as_str(), "node-000");
5436 assert_eq!(topology.nodes.last().unwrap().node_id.as_str(), "node-063");
5437 assert_eq!(
5438 topology.nodes[0].current_incarnation_id,
5439 Some(NodeIncarnationId::from_bytes([0; 16])),
5440 );
5441 let json = serde_json::to_string(&C2ServerFrame::Topology(topology)).unwrap();
5442 assert!(!json.contains("observed_at_unix_ms"));
5443 assert!(!json.contains("sequence"));
5444 assert!(!json.contains("inventory"));
5445 assert!(!json.contains("managed_worktrees"));
5446 }
5447
5448 #[test]
5449 fn topology_node_without_relay_route_decodes_as_unknown() {
5450 let legacy = r#"{
5451 "node_id":"node-a",
5452 "endpoint":"legacy-endpoint",
5453 "transport":"offline",
5454 "current_incarnation_id":null
5455 }"#;
5456
5457 let decoded: C2TopologyNode = serde_json::from_str(legacy).unwrap();
5458
5459 assert_eq!(decoded.relay_route, C2RelayRoute::Unknown);
5460 assert!(!serde_json::to_string(&decoded).unwrap().contains("relay_route"));
5461 }
5462
5463 #[test]
5464 fn topology_provider_contract_projection_is_capability_gated_and_change_sensitive() {
5465 let (provider_contracts, provider_adapter_contracts) =
5466 provider_contract_manifest();
5467 let mut inventory = SlimNodeInventory::from_snapshot(&NodeSnapshot {
5468 node_id: NodeId::new("node-a").unwrap(),
5469 enabled_providers: vec![provider("codex")],
5470 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5471 workspaces: Vec::new(),
5472 session_records: Vec::new(),
5473 managed_worktrees: Vec::new(),
5474 launch_inventory: None,
5475 agent_progress: Vec::new(),
5476 });
5477 inventory.provider_contracts = provider_contracts;
5478 inventory.provider_adapter_contracts = provider_adapter_contracts;
5479 let status = StatusResponse {
5480 api_version: C2_API_VERSION,
5481 ready: true,
5482 observed_at_unix_ms: 10,
5483 nodes: BTreeMap::from([(
5484 NodeId::new("node-a").unwrap(),
5485 ObservedNode {
5486 endpoint: r"\\.\pipe\node-a".to_owned(),
5487 transport_label: "windows-named-pipe".to_owned(),
5488 transport: NodeTransportState::Online,
5489 freshness: NodeFreshness::Fresh,
5490 cursor: Some(NodeCursor {
5491 incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5492 sequence: 9,
5493 }),
5494 inventory: Some(inventory),
5495 machine: None,
5496 last_attempt_unix_ms: Some(10),
5497 last_success_unix_ms: Some(10),
5498 consecutive_failures: 0,
5499 last_error: None,
5500 gaps: Vec::new(),
5501 gaps_truncated: 0,
5502 },
5503 )]),
5504 };
5505
5506 let projected = C2Topology::from_status_with_provider_contracts(&status, true);
5507 assert_eq!(projected.nodes[0].provider_contracts.len(), 1);
5508 assert_eq!(projected.nodes[0].provider_adapter_contracts.len(), 1);
5509 let legacy = C2Topology::from_status_with_provider_contracts(&status, false);
5510 assert!(legacy.nodes[0].provider_contracts.is_empty());
5511 assert!(legacy.nodes[0].provider_adapter_contracts.is_empty());
5512 #[derive(Serialize)]
5513 struct LegacyTopologyNode<'a> {
5514 node_id: &'a NodeId,
5515 endpoint: &'a str,
5516 relay_route: C2RelayRoute,
5517 transport: NodeTransportState,
5518 current_incarnation_id: Option<NodeIncarnationId>,
5519 }
5520 #[derive(Serialize)]
5521 struct LegacyTopology<'a> {
5522 nodes: Vec<LegacyTopologyNode<'a>>,
5523 }
5524 let legacy_shape = LegacyTopology {
5525 nodes: legacy.nodes.iter().map(|node| LegacyTopologyNode {
5526 node_id: &node.node_id,
5527 endpoint: &node.endpoint,
5528 relay_route: node.relay_route,
5529 transport: node.transport,
5530 current_incarnation_id: node.current_incarnation_id,
5531 }).collect(),
5532 };
5533 let legacy_json = serde_json::to_string(&legacy).unwrap();
5534 assert_eq!(
5535 serde_json::to_vec(&legacy).unwrap(),
5536 serde_json::to_vec(&legacy_shape).unwrap(),
5537 );
5538 assert!(!legacy_json.contains("provider_contracts"));
5539 assert!(!legacy_json.contains("provider_adapter_contracts"));
5540 assert_ne!(legacy, projected);
5541
5542 let projected_json = serde_json::to_string(&projected).unwrap();
5543 assert!(projected_json.contains("provider_contracts"));
5544 assert!(projected_json.contains("provider_adapter_contracts"));
5545 assert!(!projected_json.contains("inventory"));
5546 assert!(!projected_json.contains("events"));
5547 assert!(!projected_json.contains("routed_response"));
5548 }
5549
5550 #[test]
5551 fn routed_durable_session_responses_round_trip_without_private_record_fields() {
5552 let record = private_session_record();
5553 let session = record.active_session.clone().unwrap();
5554 let native_selection = NativeSessionSelection {
5555 route: NativeSessionCatalogRoute::workspace(
5556 record.workspace_id.clone(),
5557 record.provider.clone(),
5558 ),
5559 catalog_revision: 7,
5560 recent_cutoff_unix_ms: 70,
5561 selection_id: "selection-private".to_owned(),
5562 };
5563 let responses = vec![
5564 NodeResponse::Snapshot {
5565 event_sequence: 4,
5566 controller: None,
5567 snapshot: NodeSnapshot {
5568 node_id: NodeId::new("node-a").unwrap(),
5569 enabled_providers: vec![provider("codex")],
5570 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5571 workspaces: Vec::new(),
5572 session_records: vec![record.clone()],
5573 managed_worktrees: Vec::new(),
5574 launch_inventory: None,
5575 agent_progress: Vec::new(),
5576 },
5577 },
5578 NodeResponse::ProviderSessionIndexed { record: record.clone() },
5579 NodeResponse::NativeSessionIndexed {
5580 selection: native_selection.clone(),
5581 record: record.clone(),
5582 },
5583 NodeResponse::SessionRecordUpdated { record: record.clone() },
5584 NodeResponse::SessionRecordResumed {
5585 record: record.clone(),
5586 session: session.clone(),
5587 },
5588 NodeResponse::Resync {
5589 event_sequence: 5,
5590 oldest_available_sequence: 1,
5591 snapshot: NodeSnapshot {
5592 node_id: NodeId::new("node-a").unwrap(),
5593 enabled_providers: vec![provider("codex")],
5594 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5595 workspaces: Vec::new(),
5596 session_records: vec![record.clone()],
5597 managed_worktrees: Vec::new(),
5598 launch_inventory: None,
5599 agent_progress: Vec::new(),
5600 },
5601 events: vec![gate4agent_node_protocol::NodeEventEnvelope {
5602 sequence: 5,
5603 event: NodeEvent::SessionRecordUpserted { record: record.clone() },
5604 }],
5605 },
5606 ];
5607
5608 for response in responses {
5609 let json = serde_json::to_string(&routed_response(response)).unwrap();
5610 assert_private_record_fields_absent(&json);
5611 assert!(json.contains("provider_identity_present"));
5612 assert!(json.contains(r#""profile_id":"local-default""#));
5613 assert!(json.contains(
5614 r#""profile_revision":"local-default.2026-08""#,
5615 ));
5616 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5617 let decoded_record = match decoded.response.unwrap() {
5618 C2NodeResponse::Snapshot { snapshot, .. } => snapshot.session_records.into_iter().next().unwrap(),
5619 C2NodeResponse::ProviderSessionIndexed { record }
5620 | C2NodeResponse::NativeSessionIndexed { record, .. }
5621 | C2NodeResponse::SessionRecordUpdated { record }
5622 | C2NodeResponse::SessionRecordResumed { record, .. } => record,
5623 C2NodeResponse::Resync { snapshot, events, .. } => {
5624 assert!(matches!(events.into_iter().next().unwrap().event,
5625 C2NodeEvent::SessionRecordUpserted { ref record }
5626 if record.provider_identity_present));
5627 snapshot.session_records.into_iter().next().unwrap()
5628 }
5629 response => panic!("unexpected response after C2 round trip: {response:?}"),
5630 };
5631 assert_eq!(decoded_record.record_id.as_str(), "session-private");
5632 assert_eq!(decoded_record.display_name, "release shepherd");
5633 assert_eq!(decoded_record.active_session.as_ref(), Some(&session));
5634 assert!(decoded_record.provider_identity_present);
5635 }
5636 }
5637
5638 #[test]
5639 fn c2_resync_projection_preserves_authoritative_replay_floor() {
5640 let source = NodeResponse::Resync {
5641 event_sequence: 12,
5642 oldest_available_sequence: 9,
5643 snapshot: NodeSnapshot {
5644 node_id: NodeId::new("node-a").unwrap(),
5645 enabled_providers: Vec::new(),
5646 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5647 workspaces: Vec::new(),
5648 session_records: Vec::new(),
5649 managed_worktrees: Vec::new(),
5650 launch_inventory: None,
5651 agent_progress: Vec::new(),
5652 },
5653 events: Vec::new(),
5654 };
5655 let projected = C2NodeResponse::from(&source);
5656 assert!(matches!(
5657 projected,
5658 C2NodeResponse::Resync {
5659 event_sequence: 12,
5660 oldest_available_sequence: 9,
5661 ..
5662 }
5663 ));
5664 let encoded = serde_json::to_vec(&projected).unwrap();
5665 assert_eq!(
5666 serde_json::from_slice::<C2NodeResponse>(&encoded).unwrap(),
5667 projected,
5668 );
5669 }
5670
5671 #[test]
5672 fn routed_session_record_event_round_trips_without_private_record_fields() {
5673 let routed = RoutedNodeEvent {
5674 node_id: NodeId::new("node-a").unwrap(),
5675 cursor: NodeCursor {
5676 incarnation_id: NodeIncarnationId::from_bytes([8; 16]),
5677 sequence: 9,
5678 },
5679 event: C2NodeEvent::from(&NodeEvent::SessionRecordUpserted {
5680 record: private_session_record(),
5681 }),
5682 };
5683
5684 let json = serde_json::to_string(&routed).unwrap();
5685 assert_private_record_fields_absent(&json);
5686 assert!(json.contains("provider_identity_present"));
5687 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
5688 let C2NodeEvent::SessionRecordUpserted { record } = decoded.event else {
5689 panic!("unexpected routed event after C2 round trip");
5690 };
5691 assert_eq!(record.record_id.as_str(), "session-private");
5692 assert_eq!(record.display_name, "release shepherd");
5693 assert!(record.provider_identity_present);
5694 }
5695
5696 #[test]
5697 fn routed_node_failure_replaces_raw_message_with_fixed_category() {
5698 let raw = NodeFailure {
5699 code: NodeFailureCode::BackendOperationFailed,
5700 message: r"provider token-secret failed at C:\private\relay.log".to_owned(),
5701 };
5702 let routed = RoutedNodeResponse {
5703 node_id: NodeId::new("node-a").unwrap(),
5704 incarnation_id: NodeIncarnationId::from_bytes([7; 16]),
5705 response: Err(C2NodeFailure::from(&raw)),
5706 };
5707
5708 let json = serde_json::to_string(&routed).unwrap();
5709 assert!(!json.contains("token-secret"));
5710 assert!(!json.contains("private"));
5711 assert!(!json.contains("relay.log"));
5712 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5713 assert_eq!(decoded.response.unwrap_err(), C2NodeFailure {
5714 code: NodeFailureCode::BackendOperationFailed,
5715 message: "node backend operation failed".to_owned(),
5716 });
5717 }
5718
5719 #[test]
5720 fn routed_workspace_inspection_omits_raw_git_diagnostics_and_reasons() {
5721 let worktree = GitWorktreeSnapshot {
5722 path: host_path(r"C:\work\feature"),
5723 head: "abc123".to_owned(),
5724 branch: Some("feature/privacy".to_owned()),
5725 is_bare: false,
5726 is_main: false,
5727 locked: true,
5728 lock_reason: Some("lock-secret-provider-token".to_owned()),
5729 prunable: true,
5730 prunable_reason: Some("prunable-secret-private-path".to_owned()),
5731 workspace_id: Some(WorkspaceId::new("feature").unwrap()),
5732 };
5733 let response = routed_response(NodeResponse::WorkspaceInspected {
5734 inspection: WorkspaceInspection {
5735 workspace_id: WorkspaceId::new("primary").unwrap(),
5736 entries: Vec::new(),
5737 tree_truncated: false,
5738 git: GitSnapshot {
5739 is_repository: true,
5740 branch: Some("main".to_owned()),
5741 status: Vec::new(),
5742 recent_commits: Vec::new(),
5743 worktrees: vec![worktree],
5744 managed_worktree: None,
5745 truncated: false,
5746 diagnostic: Some("diagnostic-secret C:\\private\\git.stderr".to_owned()),
5747 },
5748 truncation: None,
5749 },
5750 });
5751
5752 let json = serde_json::to_string(&response).unwrap();
5753 for secret in [
5754 "lock-secret-provider-token",
5755 "prunable-secret-private-path",
5756 "diagnostic-secret",
5757 "git.stderr",
5758 ] {
5759 assert!(!json.contains(secret));
5760 }
5761 assert!(!json.contains("lock_reason"));
5762 assert!(!json.contains("prunable_reason"));
5763 assert!(!json.contains("\"diagnostic\":"));
5764 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5765 let Ok(C2NodeResponse::WorkspaceInspected { inspection }) = decoded.response else {
5766 panic!("unexpected routed workspace response");
5767 };
5768 assert!(inspection.git.diagnostic_present);
5769 assert!(inspection.git.worktrees[0].locked);
5770 assert!(inspection.git.worktrees[0].prunable);
5771 assert_eq!(inspection.git.worktrees[0].path.display_text(), r"C:\work\feature");
5772 }
5773
5774 #[test]
5775 fn routed_workspace_file_read_projects_only_correlated_operator_content() {
5776 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
5777 let response = routed_response(NodeResponse::WorkspaceFileRead {
5778 file: WorkspaceFileRead {
5779 workspace_id: WorkspaceId::new("primary").unwrap(),
5780 path: path.clone(),
5781 content: WorkspaceFileContent::Utf8 {
5782 text: "pub fn fixture() {}\n".to_owned(),
5783 byte_len: 20,
5784 },
5785 revision: None,
5786 },
5787 });
5788
5789 let json = serde_json::to_string(&response).unwrap();
5790 assert!(!json.contains("canonical_root"));
5791 assert!(!json.contains("diagnostic"));
5792 assert!(!json.contains("controller"));
5793 assert!(!json.contains("inventory"));
5794 assert!(!json.contains("event_sequence"));
5795 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5796 assert_eq!(decoded.response, Ok(C2NodeResponse::WorkspaceFileRead {
5797 file: WorkspaceFileRead {
5798 workspace_id: WorkspaceId::new("primary").unwrap(),
5799 path,
5800 content: WorkspaceFileContent::Utf8 {
5801 text: "pub fn fixture() {}\n".to_owned(),
5802 byte_len: 20,
5803 },
5804 revision: None,
5805 },
5806 }));
5807 }
5808
5809 #[test]
5810 fn workspace_entry_create_projects_exact_bounded_c2_contracts() {
5811 assert_eq!(
5812 C2_WORKSPACE_ENTRY_CREATE_CAPABILITY,
5813 "workspace-entry-create-v1",
5814 );
5815 let workspace_id = WorkspaceId::new("primary").unwrap();
5816 let file = WorkspaceFileRead {
5817 workspace_id: workspace_id.clone(),
5818 path: repository_path("src/new.rs"),
5819 content: WorkspaceFileContent::Utf8 {
5820 text: String::new(),
5821 byte_len: 0,
5822 },
5823 revision: Some(
5824 gate4agent_node_protocol::WorkspaceFileRevision::new(
5825 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
5826 .to_owned(),
5827 )
5828 .unwrap(),
5829 ),
5830 };
5831 let file_response = C2NodeResponse::from(
5832 &NodeResponse::WorkspaceFileCreated { file: file.clone() },
5833 );
5834 assert_eq!(
5835 file_response,
5836 C2NodeResponse::WorkspaceFileCreated { file },
5837 );
5838 assert!(file_response.requires_workspace_entry_create_capability());
5839
5840 let entry = WorkspaceEntry {
5841 relative_path: repository_path("src/new"),
5842 kind: WorkspaceEntryKind::Directory,
5843 };
5844 let directory_response = C2NodeResponse::from(
5845 &NodeResponse::WorkspaceDirectoryCreated {
5846 workspace_id: workspace_id.clone(),
5847 entry: entry.clone(),
5848 },
5849 );
5850 assert_eq!(
5851 directory_response,
5852 C2NodeResponse::WorkspaceDirectoryCreated {
5853 workspace_id,
5854 entry,
5855 },
5856 );
5857 assert!(directory_response.requires_workspace_entry_create_capability());
5858 let encoded = serde_json::to_string(&directory_response).unwrap();
5859 assert_eq!(
5860 serde_json::from_str::<C2NodeResponse>(&encoded).unwrap(),
5861 directory_response,
5862 );
5863
5864 for (code, message) in [
5865 (NodeFailureCode::RepositoryEntryAlreadyExists, "repository entry already exists"),
5866 (NodeFailureCode::RepositoryParentNotFound, "repository parent directory unavailable"),
5867 (NodeFailureCode::RepositoryParentNotDirectory, "repository parent path is not a directory"),
5868 (NodeFailureCode::RepositoryEntryCreateTimedOut, "repository entry creation timed out"),
5869 (NodeFailureCode::RepositoryEntryCreateFailed, "repository entry creation failed"),
5870 ] {
5871 assert_eq!(
5872 C2NodeFailure::from(&NodeFailure {
5873 code,
5874 message: "private node detail".to_owned(),
5875 })
5876 .message,
5877 message,
5878 );
5879 }
5880 }
5881
5882 #[test]
5883 fn c2_host_directory_browse_projection_is_bounded_and_sanitizes_failures() {
5884 assert_eq!(C2_HOST_DIRECTORY_BROWSE_CAPABILITY, "host-directory-browse-v1");
5885 let directory = host_path(r"C:\Users");
5886 let entry = HostDirectoryEntry {
5887 path: host_path(r"C:\Users\Public"),
5888 display_name: "Public".to_owned(),
5889 is_link: false,
5890 };
5891 let response = routed_response(NodeResponse::HostDirectoriesBrowsed {
5892 listing: HostDirectoryListing {
5893 directory: Some(directory.clone()),
5894 parent: Some(host_path(r"C:\")),
5895 entries: vec![entry.clone()],
5896 next_after: Some(entry.path.clone()),
5897 incomplete: true,
5898 },
5899 });
5900 let json = serde_json::to_string(&response).unwrap();
5901 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
5902 assert_eq!(decoded, response);
5903 assert!(matches!(
5904 decoded.response,
5905 Ok(C2NodeResponse::HostDirectoriesBrowsed {
5906 listing: HostDirectoryListing {
5907 directory: Some(ref actual),
5908 incomplete: true,
5909 ..
5910 }
5911 }) if actual == &directory
5912 ));
5913
5914 let mut oversized = serde_json::to_value(C2NodeResponse::HostDirectoriesBrowsed {
5915 listing: HostDirectoryListing {
5916 directory: None,
5917 parent: None,
5918 entries: vec![entry; gate4agent_node_protocol::MAX_HOST_DIRECTORY_ENTRIES + 1],
5919 next_after: None,
5920 incomplete: false,
5921 },
5922 }).unwrap();
5923 assert!(serde_json::from_value::<C2NodeResponse>(oversized.take()).is_err());
5924
5925 for (code, expected) in [
5926 (NodeFailureCode::HostDirectoryInvalid, "host directory invalid"),
5927 (NodeFailureCode::HostDirectoryReadFailed, "host directory read failed"),
5928 (NodeFailureCode::HostDirectoryReadTimedOut, "host directory read timed out"),
5929 ] {
5930 let failure = C2NodeFailure::from(&NodeFailure {
5931 code,
5932 message: r"private C:\Users\owner\secret".to_owned(),
5933 });
5934 assert_eq!(failure.message, expected);
5935 assert!(failure.requires_host_directory_browse_capability());
5936 assert!(!failure.message.contains("private"));
5937 }
5938 }
5939
5940 #[test]
5941 fn c2_projection_roundtrips_non_utf8_host_paths_without_interpretation() {
5942 let opaque = OpaqueHostPath::unix_bytes(vec![b'/', b's', b'r', b'v', b'/', 0xff, b'\n', 0x1b]).unwrap();
5943 let projected = C2NodeSnapshot::from(&NodeSnapshot {
5944 node_id: NodeId::new("remote-linux").unwrap(),
5945 enabled_providers: Vec::new(),
5946 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
5947 workspaces: vec![WorkspaceSnapshot {
5948 workspace_id: WorkspaceId::new("repo").unwrap(),
5949 canonical_root: opaque.clone(),
5950 sessions: Vec::new(),
5951 worktree_service_mode: None,
5952 managed_worktree_profiles: None,
5953 }],
5954 session_records: Vec::new(),
5955 managed_worktrees: Vec::new(),
5956 launch_inventory: None,
5957 agent_progress: Vec::new(),
5958 });
5959
5960 let encoded = serde_json::to_string(&projected).unwrap();
5961 let decoded = serde_json::from_str::<C2NodeSnapshot>(&encoded).unwrap();
5962
5963 assert_eq!(decoded.workspaces[0].canonical_root, opaque);
5964 assert_eq!(decoded.workspaces[0].workspace_id.as_str(), "repo");
5965 let slim = SlimNodeInventory::from_c2_snapshot(&decoded);
5966 let display = &slim.workspaces[&WorkspaceId::new("repo").unwrap()].canonical_root;
5967 assert!(!display.chars().any(char::is_control));
5968 assert!(display.contains("\\n"));
5969 assert!(display.contains("\\u{1b}"));
5970 }
5971
5972 #[test]
5973 fn standalone_workspace_response_roundtrips_authoritative_snapshot() {
5974 assert_eq!(
5975 C2_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
5976 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
5977 );
5978 let workspace = WorkspaceSnapshot {
5979 workspace_id: WorkspaceId::new("standalone").unwrap(),
5980 canonical_root: host_path(r"C:\standalone"),
5981 sessions: Vec::new(),
5982 worktree_service_mode: Some(WorktreeServiceMode::Manual),
5983 managed_worktree_profiles: Some(WorktreeProfileInventory {
5984 profiles: Vec::new(),
5985 }),
5986 };
5987 let projected = C2NodeResponse::from(
5988 &NodeResponse::StandaloneWorkspaceCreated {
5989 workspace: workspace.clone(),
5990 },
5991 );
5992 let encoded = serde_json::to_string(&projected).unwrap();
5993 let decoded = serde_json::from_str::<C2NodeResponse>(&encoded).unwrap();
5994
5995 assert_eq!(
5996 decoded,
5997 C2NodeResponse::StandaloneWorkspaceCreated {
5998 workspace: C2WorkspaceSnapshot::from(&workspace),
5999 },
6000 );
6001 }
6002
6003 #[test]
6004 fn routed_worktree_created_omits_raw_git_reasons() {
6005 let response = routed_response(NodeResponse::WorktreeCreated {
6006 worktree: GitWorktreeSnapshot {
6007 path: host_path(r"C:\work\feature"),
6008 head: "abc123".to_owned(),
6009 branch: Some("feature/privacy".to_owned()),
6010 is_bare: false,
6011 is_main: false,
6012 locked: true,
6013 lock_reason: Some("created-lock-secret".to_owned()),
6014 prunable: true,
6015 prunable_reason: Some("created-prunable-secret".to_owned()),
6016 workspace_id: Some(WorkspaceId::new("feature").unwrap()),
6017 },
6018 workspace: WorkspaceSnapshot {
6019 workspace_id: WorkspaceId::new("feature").unwrap(),
6020 canonical_root: host_path(r"C:\work\feature"),
6021 sessions: Vec::new(),
6022 worktree_service_mode: None,
6023 managed_worktree_profiles: None,
6024 },
6025 });
6026
6027 let json = serde_json::to_string(&response).unwrap();
6028 assert!(!json.contains("created-lock-secret"));
6029 assert!(!json.contains("created-prunable-secret"));
6030 assert!(!json.contains("lock_reason"));
6031 assert!(!json.contains("prunable_reason"));
6032 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
6033 assert!(matches!(decoded.response, Ok(C2NodeResponse::WorktreeCreated {
6034 worktree: C2GitWorktreeSnapshot { locked: true, prunable: true, .. },
6035 ..
6036 })));
6037 }
6038
6039 #[test]
6040 fn routed_resume_authorized_control_event_omits_provider_session_identity() {
6041 let routed = routed_control_event(ControlEventKind::ResumeAuthorized {
6042 session: ResumeSessionSummary {
6043 key: ProviderSessionKey::SessionId,
6044 id: "private-resume-session-id".to_owned(),
6045 },
6046 });
6047
6048 let json = serde_json::to_string(&routed).unwrap();
6049 assert!(json.contains("resume-authorized"));
6050 assert!(!json.contains("private-resume-session-id"));
6051 assert!(!json.contains("provider_session"));
6052 assert!(!json.contains("transcript_path"));
6053 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
6054 assert!(matches!(decoded.event, C2NodeEvent::Control {
6055 event: C2ControlEvent { event: C2ControlEventKind::ResumeAuthorized, .. },
6056 ..
6057 }));
6058 }
6059
6060 #[test]
6061 fn routed_session_identity_observed_control_event_omits_provider_identity_and_path() {
6062 let routed = routed_control_event(ControlEventKind::ProviderEvent {
6063 sequence: 19,
6064 source: provider_source(),
6065 source_sequence: 7,
6066 event: ProviderEvent::SessionIdentityObserved {
6067 identity: ProviderSessionIdentity {
6068 key: ProviderSessionKey::ConversationId,
6069 id: "private-observed-provider-id".to_owned(),
6070 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
6071 },
6072 },
6073 });
6074
6075 let json = serde_json::to_string(&routed).unwrap();
6076 assert!(json.contains("session-identity-observed"));
6077 assert!(!json.contains("private-observed-provider-id"));
6078 assert!(!json.contains("provider-transcript.jsonl"));
6079 assert!(!json.contains("transcript_path"));
6080 assert!(!json.contains("\"identity\":"));
6081 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
6082 assert!(matches!(decoded.event, C2NodeEvent::Control {
6083 event: C2ControlEvent {
6084 event: C2ControlEventKind::ProviderEvent {
6085 event: C2ProviderEventKind::SessionIdentityObserved,
6086 },
6087 ..
6088 },
6089 ..
6090 }));
6091 }
6092
6093 #[test]
6094 fn routed_provider_interaction_resolution_is_categorical_only() {
6095 let routed = routed_control_event(ControlEventKind::ProviderEvent {
6096 sequence: 20,
6097 source: provider_source(),
6098 source_sequence: 8,
6099 event: ProviderEvent::InteractionResolved {
6100 request_id: "private-provider-request-id".to_owned(),
6101 outcome: ProviderInteractionOutcome::Denied,
6102 },
6103 });
6104
6105 let json = serde_json::to_string(&routed).unwrap();
6106 assert!(json.contains("interaction-resolved"));
6107 assert!(!json.contains("private-provider-request-id"));
6108 assert!(!json.contains("denied"));
6109 let decoded = serde_json::from_str::<RoutedNodeEvent>(&json).unwrap();
6110 assert!(matches!(decoded.event, C2NodeEvent::Control {
6111 event: C2ControlEvent {
6112 event: C2ControlEventKind::ProviderEvent {
6113 event: C2ProviderEventKind::InteractionResolved,
6114 },
6115 ..
6116 },
6117 ..
6118 }));
6119 }
6120
6121 #[test]
6122 fn routed_snapshot_recursively_omits_provider_identity_and_error_state() {
6123 let mut session = fixture_session();
6124 session.status = SessionStatus::Failed {
6125 message: "private-session-failure".to_owned(),
6126 };
6127 session.terminal_stale = Some("private-terminal-error".to_owned());
6128 session.history.last_error = Some("private-history-error".to_owned());
6129 session.resume.last_session = Some(ResumeSessionSummary {
6130 key: ProviderSessionKey::SessionId,
6131 id: "private-resume-summary-id".to_owned(),
6132 });
6133 session.resume.last_error = Some("private-resume-error".to_owned());
6134 session.foreground.stale_reason = Some("private-foreground-error".to_owned());
6135 session.provider.session = Some(ProviderSessionIdentity {
6136 key: ProviderSessionKey::SessionId,
6137 id: "private-snapshot-provider-id".to_owned(),
6138 transcript_path: Some(r"C:\private\snapshot-transcript.jsonl".to_owned()),
6139 });
6140 session.provider.current_prompt = Some("private-current-prompt".to_owned());
6141 session.provider.last_event = Some(ProviderEvent::Error {
6142 message: "private-provider-error".to_owned(),
6143 });
6144 let routed = routed_response(NodeResponse::Snapshot {
6145 event_sequence: 4,
6146 controller: None,
6147 snapshot: NodeSnapshot {
6148 node_id: NodeId::new("node-a").unwrap(),
6149 enabled_providers: vec![provider("codex")],
6150 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6151 workspaces: vec![WorkspaceSnapshot {
6152 workspace_id: WorkspaceId::new("primary").unwrap(),
6153 canonical_root: host_path(r"C:\workspace"),
6154 sessions: vec![session],
6155 worktree_service_mode: None,
6156 managed_worktree_profiles: None,
6157 }],
6158 session_records: Vec::new(),
6159 managed_worktrees: Vec::new(),
6160 launch_inventory: None,
6161 agent_progress: Vec::new(),
6162 },
6163 });
6164
6165 let json = serde_json::to_string(&routed).unwrap();
6166 for secret in [
6167 "private-session-failure",
6168 "private-terminal-error",
6169 "private-history-error",
6170 "private-resume-summary-id",
6171 "private-resume-error",
6172 "private-foreground-error",
6173 "private-snapshot-provider-id",
6174 "snapshot-transcript.jsonl",
6175 "private-current-prompt",
6176 "private-provider-error",
6177 ] {
6178 assert!(!json.contains(secret));
6179 }
6180 assert!(!json.contains("provider_session"));
6181 assert!(!json.contains("transcript_path"));
6182 assert!(!json.contains("last_error"));
6183 let decoded = serde_json::from_str::<RoutedNodeResponse>(&json).unwrap();
6184 assert!(matches!(decoded.response, Ok(C2NodeResponse::Snapshot { snapshot, .. })
6185 if matches!(snapshot.workspaces[0].sessions[0].status, C2SessionStatus::Failed)
6186 && snapshot.workspaces[0].sessions[0].provider_identity_present));
6187 }
6188
6189 #[test]
6190 fn slim_managed_sessions_are_sorted_bounded_and_privacy_minimized() {
6191 let long_name = "ж".repeat((MAX_C2_SESSION_DISPLAY_NAME_BYTES / 2) + 4);
6192 let make_record = |record_id: &str, display_name: String| ManagedSessionRecord {
6193 record_id: SessionRecordId::new(record_id).unwrap(),
6194 display_name,
6195 provider: provider("codex"),
6196 mode: SessionMode::Pty,
6197 state: ManagedSessionState::Dormant,
6198 workspace_id: WorkspaceId::new("primary").unwrap(),
6199 canonical_root: host_path(r"C:\private\workspace"),
6200 provider_session: Some(ProviderSessionIdentity {
6201 key: ProviderSessionKey::SessionId,
6202 id: "5af75a6b-3e64-41dd-96fa-private-provider-id".to_owned(),
6203 transcript_path: Some(r"C:\private\transcript.jsonl".to_owned()),
6204 }),
6205 active_session: None,
6206 environment_profile: None,
6207 bundle: None,
6208 context_id: None,
6209 context: None,
6210 exported_context: None,
6211 task_binding: None,
6212 created_at_unix_ms: 10,
6213 updated_at_unix_ms: 20,
6214 last_error: Some("private backend diagnostic".to_owned()),
6215 };
6216 let snapshot = NodeSnapshot {
6217 node_id: NodeId::new("node-a").unwrap(),
6218 enabled_providers: vec![provider("codex")],
6219 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6220 workspaces: Vec::new(),
6221 session_records: vec![
6222 make_record("session-z", "z".to_owned()),
6223 make_record("session-a", long_name),
6224 ],
6225 managed_worktrees: Vec::new(),
6226 launch_inventory: None,
6227 agent_progress: Vec::new(),
6228 };
6229
6230 let slim = SlimNodeInventory::from_snapshot(&snapshot);
6231 assert_eq!(slim.managed_session_count, 2);
6232 assert!(!slim.managed_sessions_truncated);
6233 assert_eq!(
6234 slim.managed_sessions.iter().map(|record| record.record_id.as_str()).collect::<Vec<_>>(),
6235 vec!["session-a", "session-z"],
6236 );
6237 assert_eq!(slim.managed_sessions[0].display_name.len(), MAX_C2_SESSION_DISPLAY_NAME_BYTES);
6238 assert!(slim.managed_sessions[0].display_name_truncated);
6239 assert!(slim.managed_sessions[0].provider_identity_present);
6240
6241 let json = serde_json::to_string(&slim).unwrap();
6242 assert!(!json.contains("5af75a6b-3e64-41dd-96fa-private-provider-id"));
6243 assert!(!json.contains("transcript.jsonl"));
6244 assert!(!json.contains("private backend diagnostic"));
6245 assert!(!json.contains("private\\\\workspace"));
6246 assert!(!json.contains("provider_session"));
6247 assert!(!json.contains("last_error"));
6248 }
6249
6250 #[test]
6251 fn slim_inventory_defaults_managed_sessions_for_v1_payloads() {
6252 let legacy = r#"{"node_id":"node-a","enabled_providers":[],"workspaces":{},"workspace_count":0,"workspaces_truncated":false,"session_count":0,"sessions_truncated":false}"#;
6253 let inventory = serde_json::from_str::<SlimNodeInventory>(legacy).unwrap();
6254 assert!(inventory.provider_contracts.is_empty());
6255 assert!(inventory.provider_adapter_contracts.is_empty());
6256 assert!(inventory.managed_sessions.is_empty());
6257 assert_eq!(inventory.managed_session_count, 0);
6258 assert!(!inventory.managed_sessions_truncated);
6259 let reencoded = serde_json::to_string(&inventory).unwrap();
6260 assert!(!reencoded.contains("provider_contracts"));
6261 assert!(!reencoded.contains("provider_adapter_contracts"));
6262 assert_eq!(inventory.retired_count, 0);
6263 assert!(!reencoded.contains("retired_count"));
6264 }
6265
6266 #[test]
6271 fn slim_inventory_retired_count_round_trips_and_defaults_to_zero() {
6272 let legacy = r#"{"node_id":"node-a","enabled_providers":[],"workspaces":{},"workspace_count":0,"workspaces_truncated":false,"session_count":0,"sessions_truncated":false}"#;
6273 let inventory = serde_json::from_str::<SlimNodeInventory>(legacy).unwrap();
6274 assert_eq!(inventory.retired_count, 0);
6275
6276 let mut inventory = inventory;
6277 inventory.retired_count = 7;
6278 let encoded = serde_json::to_string(&inventory).unwrap();
6279 assert!(encoded.contains("\"retired_count\":7"));
6280 let decoded: SlimNodeInventory = serde_json::from_str(&encoded).unwrap();
6281 assert_eq!(decoded.retired_count, 7);
6282 }
6283
6284 #[test]
6285 fn slim_inventory_reports_managed_session_truncation() {
6286 let session_records = (0..=MAX_C2_MANAGED_SESSIONS_PER_NODE)
6287 .map(|index| ManagedSessionRecord {
6288 record_id: SessionRecordId::new(format!("session-{index:03}")).unwrap(),
6289 display_name: format!("session {index}"),
6290 provider: provider("claude"),
6291 mode: SessionMode::Inline,
6292 state: ManagedSessionState::Unavailable,
6293 workspace_id: WorkspaceId::new("primary").unwrap(),
6294 canonical_root: host_path(r"C:\repo"),
6295 provider_session: None,
6296 active_session: None,
6297 environment_profile: None,
6298 bundle: None,
6299 context_id: None,
6300 context: None,
6301 exported_context: None,
6302 task_binding: None,
6303 created_at_unix_ms: index as u64,
6304 updated_at_unix_ms: index as u64,
6305 last_error: None,
6306 })
6307 .collect::<Vec<_>>();
6308 let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
6309 node_id: NodeId::new("node-a").unwrap(),
6310 enabled_providers: Vec::new(),
6311 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6312 workspaces: Vec::new(),
6313 session_records,
6314 managed_worktrees: Vec::new(),
6315 launch_inventory: None,
6316 agent_progress: Vec::new(),
6317 });
6318 assert_eq!(slim.managed_session_count, MAX_C2_MANAGED_SESSIONS_PER_NODE + 1);
6319 assert_eq!(slim.managed_sessions.len(), MAX_C2_MANAGED_SESSIONS_PER_NODE);
6320 assert!(slim.managed_sessions_truncated);
6321 }
6322
6323 #[test]
6324 fn slim_managed_sessions_page_keeps_the_live_record_even_when_it_sorts_last_by_id() {
6325 let stale_count = MAX_C2_MANAGED_SESSIONS_PER_NODE + 2;
6335 let mut session_records = (0..stale_count)
6336 .map(|index| ManagedSessionRecord {
6337 record_id: SessionRecordId::new(format!("session-{index:03}")).unwrap(),
6338 display_name: format!("session {index}"),
6339 provider: provider("claude"),
6340 mode: SessionMode::Inline,
6341 state: ManagedSessionState::Unavailable,
6342 workspace_id: WorkspaceId::new("primary").unwrap(),
6343 canonical_root: host_path(r"C:\repo"),
6344 provider_session: None,
6345 active_session: None,
6346 environment_profile: None,
6347 bundle: None,
6348 context_id: None,
6349 context: None,
6350 exported_context: None,
6351 task_binding: None,
6352 created_at_unix_ms: index as u64,
6353 updated_at_unix_ms: index as u64,
6354 last_error: None,
6355 })
6356 .collect::<Vec<_>>();
6357 session_records.push(ManagedSessionRecord {
6358 record_id: SessionRecordId::new(format!("session-{stale_count:03}")).unwrap(),
6359 display_name: "live-run".to_owned(),
6360 provider: provider("claude"),
6361 mode: SessionMode::Pty,
6362 state: ManagedSessionState::Live,
6363 workspace_id: WorkspaceId::new("primary").unwrap(),
6364 canonical_root: host_path(r"C:\repo"),
6365 provider_session: None,
6366 active_session: None,
6367 environment_profile: None,
6368 bundle: None,
6369 context_id: None,
6370 context: None,
6371 exported_context: None,
6372 task_binding: None,
6373 created_at_unix_ms: stale_count as u64,
6374 updated_at_unix_ms: stale_count as u64,
6375 last_error: None,
6376 });
6377 let pending_index = stale_count + 1;
6378 session_records.push(ManagedSessionRecord {
6379 record_id: SessionRecordId::new(format!("session-{pending_index:03}")).unwrap(),
6380 display_name: "pending-spawn".to_owned(),
6381 provider: provider("claude"),
6382 mode: SessionMode::Pty,
6383 state: ManagedSessionState::IdentityPending,
6384 workspace_id: WorkspaceId::new("primary").unwrap(),
6385 canonical_root: host_path(r"C:\repo"),
6386 provider_session: None,
6387 active_session: None,
6388 environment_profile: None,
6389 bundle: None,
6390 context_id: None,
6391 context: None,
6392 exported_context: None,
6393 task_binding: None,
6394 created_at_unix_ms: pending_index as u64,
6395 updated_at_unix_ms: pending_index as u64,
6396 last_error: None,
6397 });
6398 let total = session_records.len();
6399 assert!(total > MAX_C2_MANAGED_SESSIONS_PER_NODE);
6400
6401 let slim = SlimNodeInventory::from_snapshot(&NodeSnapshot {
6402 node_id: NodeId::new("node-a").unwrap(),
6403 enabled_providers: Vec::new(),
6404 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6405 workspaces: Vec::new(),
6406 session_records,
6407 managed_worktrees: Vec::new(),
6408 launch_inventory: None,
6409 agent_progress: Vec::new(),
6410 });
6411
6412 assert_eq!(slim.managed_session_count, total);
6413 assert!(slim.managed_sessions_truncated);
6414 assert_eq!(slim.managed_sessions.len(), MAX_C2_MANAGED_SESSIONS_PER_NODE);
6415 assert!(
6416 slim.managed_sessions.iter().any(|record| record.state == ManagedSessionState::Live),
6417 "the live record must survive the page cut even though its id sorts last",
6418 );
6419 assert!(
6420 slim.managed_sessions.iter()
6421 .any(|record| record.state == ManagedSessionState::IdentityPending),
6422 "the identity-pending record must survive the page cut even though its id sorts last",
6423 );
6424 assert_eq!(slim.managed_sessions[0].state, ManagedSessionState::Live);
6425 assert_eq!(slim.managed_sessions[1].state, ManagedSessionState::IdentityPending);
6426 assert!(
6427 slim.managed_sessions[2..].iter().all(|record| record.state == ManagedSessionState::Unavailable),
6428 "every Unavailable record on the page must rank after Live and IdentityPending",
6429 );
6430 }
6431
6432 #[test]
6433 fn managed_worktree_projection_is_bounded_and_contains_no_git_or_path_details() {
6434 fn lease(index: usize) -> ManagedWorktreeLeaseSnapshot {
6435 ManagedWorktreeLeaseSnapshot {
6436 lease_id: ManagedWorktreeLeaseId::new(format!("lease-{index}")).unwrap(),
6437 source_workspace_id: WorkspaceId::new("primary").unwrap(),
6438 workspace_id: WorkspaceId::new(format!("managed-{index}")).unwrap(),
6439 profile_id: WorktreeProfileId::new("review").unwrap(),
6440 profile_revision: WorktreeProfileRevision::new("review.r1").unwrap(),
6441 retention: ManagedWorktreeRetention::RemoveWhenReleased,
6442 state: ManagedWorktreeLeaseState::Ready,
6443 active_session_count: 0,
6444 managed_record_count: 0,
6445 cleanup_failure: None,
6446 created_at_unix_ms: 1,
6447 updated_at_unix_ms: 2,
6448 }
6449 }
6450
6451 let projected = C2NodeSnapshot::from(&NodeSnapshot {
6452 node_id: NodeId::new("node-a").unwrap(),
6453 enabled_providers: Vec::new(),
6454 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6455 workspaces: Vec::new(),
6456 session_records: Vec::new(),
6457 managed_worktrees: vec![lease(0)],
6458 launch_inventory: None,
6459 agent_progress: Vec::new(),
6460 });
6461 let json = serde_json::to_string(&projected).unwrap();
6462 assert!(json.contains("managed_worktrees"));
6463 for forbidden in ["canonical", "path", "gitdir", "branch", "base_commit", "diagnostic"] {
6464 assert!(!json.contains(forbidden), "managed projection leaked {forbidden}");
6465 }
6466
6467 let overflow = C2NodeSnapshot {
6468 node_id: NodeId::new("node-a").unwrap(),
6469 enabled_providers: Vec::new(),
6470 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
6471 workspaces: Vec::new(),
6472 session_records: Vec::new(),
6473 agent_progress: Vec::new(),
6474 managed_worktrees: (0..=MAX_C2_MANAGED_WORKTREES_PER_NODE)
6475 .map(lease)
6476 .collect(),
6477 launch_inventory: None,
6478 machine: None,
6479 };
6480 let encoded = serde_json::to_value(overflow).unwrap();
6481 assert!(serde_json::from_value::<C2NodeSnapshot>(encoded).is_err());
6482 }
6483}