Skip to main content

hatchery_core/
lib.rs

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