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