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