Skip to main content

gate4agent_node_protocol/
lib.rs

1//! Bounded wire contract for the local Gate4Agent node.
2
3pub use gate4agent_types::{
4    AdapterFamily, AdapterId, AgentId, HistoryCandidateSummary, NativeSessionCatalogScope,
5    NativeSessionCatalogSummary, NativeSessionCatalogWindow, NativeSessionExternalGroup,
6    NativeSessionExternalGroupKind, ProviderConfigChoice, ProviderConfigOption,
7    ProviderConfigOptionKind, ProviderInteractionKind, ProviderInteractionResponse,
8    SessionRecordPreview, HISTORY_DISCOVERY_LIMIT_MAX,
9    NATIVE_SESSION_CATALOG_LIMIT_MAX,
10    NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX,
11};
12pub mod correlation;
13pub use gate4agent_build_stamp::BUILD_STAMP;
14use gate4agent_types::{
15    AgentInstanceId, ApprovalLevel, ControlEvent, ProviderActivity, ProviderSessionIdentity,
16    SessionGeneration, SessionSnapshot, TerminalControl, TerminalFrame, TerminalSize,
17};
18use ring::digest::{Context, SHA256};
19use serde::de::{DeserializeOwned, MapAccess, SeqAccess, Visitor};
20use serde::ser::SerializeStruct;
21use serde::{Deserialize, Deserializer, Serialize, Serializer};
22use std::borrow::{Borrow, Cow};
23use std::cmp::Ordering;
24use std::fmt;
25use std::hash::{Hash, Hasher};
26use std::io;
27use std::str::FromStr;
28use thiserror::Error;
29use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
30use tokio::time::{timeout, Duration};
31
32pub const NODE_STATE_SCHEMA_V1: u16 = 1;
33pub const NODE_STATE_SCHEMA_V2: u16 = 2;
34pub const NODE_STATE_SCHEMA_V3: u16 = 3;
35pub const NODE_STATE_SCHEMA_V4: u16 = 4;
36pub const NODE_STATE_SCHEMA_V5: u16 = 5;
37pub const NODE_STATE_SCHEMA_V6: u16 = 6;
38pub const NODE_STATE_SCHEMA_V7: u16 = 7;
39pub const NODE_STATE_SCHEMA_V8: u16 = 8;
40pub const NODE_STATE_SCHEMA_V9: u16 = 9;
41pub const NODE_STATE_SCHEMA_V10: u16 = 10;
42pub const NODE_COMPATIBILITY_METADATA_CAPABILITY: &str = "compatibility.metadata";
43pub const NODE_OPAQUE_UNIX_PATH_CAPABILITY: &str = "path.opaque-unix-bytes-v1";
44pub const NODE_REPOSITORY_PATH_CAPABILITY: &str = "repository-path-v1";
45pub const NODE_WORKSPACE_FILE_READ_CAPABILITY: &str = "workspace-file-read-v1";
46pub const NODE_WORKSPACE_FILE_WRITE_CAPABILITY: &str = "workspace-file-write-v1";
47pub const NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY: &str = "workspace-entry-create-v1";
48pub const NODE_GIT_READ_CAPABILITY: &str = "git-read-v1";
49pub const NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY: &str = "provider-contract-manifest-v1";
50pub const NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY: &str = "provider-runtime-status-v1";
51pub const NODE_PROVIDER_ID_OPEN_CAPABILITY: &str = "provider-id.open-v1";
52pub const NODE_TERMINAL_FRAME_EVENTS_CAPABILITY: &str = "terminal-frame-events-v1";
53/// The outbound content stream -- `NodeEvent::AgentStream` -- mirroring
54/// `NODE_TERMINAL_FRAME_EVENTS_CAPABILITY`'s own subscription/type split
55/// rather than folding content into a telemetry vocabulary
56/// (`docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
57/// §3-4).
58pub const NODE_AGENT_STREAM_EVENTS_CAPABILITY: &str = "agent-stream-events-v1";
59/// The inbound ACP control verbs -- `ResolveInteraction`, `SetSessionMode`,
60/// `SetSessionConfigOption`, `SetSessionModel` -- that answer what
61/// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` reports (same plan, §5).
62pub const NODE_ACP_CONTROL_CAPABILITY: &str = "acp-control-v1";
63pub const NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY: &str =
64    "spawn-spec.defaults-overrides-v1";
65pub const NODE_SPAWN_PROFILE_REVISION_CAPABILITY: &str =
66    "spawn-spec.profile-revision-v1";
67pub const NODE_WORKTREE_SELECTION_CAPABILITY: &str = "worktree-selection-v1";
68pub const NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY: &str =
69    "managed-worktree-lifecycle-v1";
70pub const NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY: &str =
71    "managed-worktree-spawn-v2";
72pub const NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY: &str =
73    "child-environment-profile-v1";
74pub const NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY: &str =
75    "session-bundle-materialization-v1";
76pub const NODE_HISTORY_CONTEXT_PACK_CAPABILITY: &str = "history-context-pack-v1";
77pub const NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY: &str =
78    "session-record-context-export-v1";
79pub const NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY: &str =
80    "standalone-workspace-lifecycle-v1";
81pub const NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY: &str =
82    "provider-session-reference-index-v1";
83pub const NODE_NATIVE_SESSION_CATALOG_CAPABILITY: &str = "native-session-catalog-v2";
84pub const NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY: &str =
85    "native-session-catalog-paging-v2";
86pub const NODE_NATIVE_SESSION_PREVIEW_CAPABILITY: &str = "native-session-preview-v2";
87pub const NODE_NATIVE_SESSION_INDEX_CAPABILITY: &str = "native-session-index-v2";
88pub const NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY: &str = "agent-progress-snapshot-v1";
89pub const NODE_SESSION_TASK_CORRELATION_CAPABILITY: &str = "session-task-correlation-v1";
90pub const NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY: &str =
91    "delivery-bundle-v2-stage-commit";
92pub const NODE_HARNESS_MCP_READ_PROXY_CAPABILITY: &str = "harness-mcp-read-proxy-v1";
93/// Read-only, paged directory browsing using UTF-8 absolute host paths only.
94/// `OpaqueHostPath::UnixBytes` is outside this capability revision.
95pub const CAPABILITY_HOST_DIRECTORY_BROWSE_V1: &str = "host-directory-browse-v1";
96pub const SPAWN_RUNTIME_RAW_PTY_LIFECYCLE: &str = "raw-pty-lifecycle";
97pub const SPAWN_RUNTIME_SEMANTIC_READINESS: &str = "semantic-readiness";
98pub const SPAWN_RUNTIME_STRUCTURED_PROMPT: &str = "structured-prompt";
99pub const SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY: &str = "provider-session-identity";
100pub const SPAWN_RUNTIME_SEMANTIC_RESUME: &str = "semantic-resume";
101pub const NODE_LEGACY_PROVIDER_IDS: [&str; 3] = ["claude", "codex", "kimi"];
102pub const MAX_NODE_IDENTIFIER_BYTES: usize = 64;
103pub const MAX_COMPATIBILITY_IDENTIFIER_BYTES: usize = 64;
104pub const MAX_PROVIDER_RUNTIME_VERSION_BYTES: usize = MAX_COMPATIBILITY_IDENTIFIER_BYTES;
105pub const MAX_PROVIDER_RUNTIME_CONTRACT_ID_BYTES: usize = MAX_COMPATIBILITY_IDENTIFIER_BYTES;
106pub const MAX_PROVIDER_CONTRACT_REVISION_BYTES: usize = 128;
107pub const MAX_ADAPTER_CONTRACT_REVISION_BYTES: usize = 128;
108pub const MAX_PROVIDER_IDENTITIES: usize = 16;
109pub const MAX_PROVIDER_CONTRACTS: usize = MAX_PROVIDER_IDENTITIES;
110pub const MAX_PROVIDER_RUNTIME_STATUSES: usize = MAX_PROVIDER_IDENTITIES;
111pub const MAX_PROVIDER_ADAPTER_CONTRACTS: usize = 32;
112pub const MAX_AGENT_PROGRESS_ENTRIES: usize = 128;
113pub const MAX_AGENT_PROGRESS_ENTRY_BYTES: usize = 4_096;
114pub const MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS: usize = 8;
115pub const MAX_AGENT_PROGRESS_TOOL_LABEL_BYTES: usize = 64;
116pub const NODE_INCARNATION_ID_BYTES: usize = 16;
117pub const TASK_ID_NONCE_BYTES: usize = 12;
118pub const MAX_NODE_FRAME_BYTES: usize = 8 * 1024 * 1024;
119pub const MAX_NODE_CLIENT_FRAME_BYTES: usize = 256 * 1024;
120pub const MAX_NODE_TEXT_BYTES: usize = 32 * 1024;
121pub const MAX_NODE_TERMINAL_BYTES: usize = 64;
122pub const MAX_SESSION_DISPLAY_NAME_BYTES: usize = 256;
123pub const MAX_SPAWN_PROFILE_ID_BYTES: usize = 64;
124pub const MAX_SPAWN_PROFILE_REVISION_BYTES: usize = 128;
125pub const MAX_SPAWN_PROFILES: usize = 64;
126
127/// Soft bound on station network allowlist catalog ids exposed on launch inventory.
128pub const MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES: usize = 128;
129pub const MAX_SPAWN_ENVIRONMENT_PROFILE_REVISION_BYTES: usize = 128;
130pub const MAX_SPAWN_BUNDLE_REVISION_BYTES: usize = 128;
131pub const MAX_SPAWN_RESOURCE_ID_BYTES: usize = 128;
132pub const MAX_SPAWN_IDEMPOTENCY_KEY_BYTES: usize = 128;
133pub const MAX_SPAWN_REQUIRED_CAPABILITIES: usize = 16;
134pub const MAX_CONTEXT_PACK_BYTES: u32 = 256 * 1024;
135pub const MAX_CONTEXT_PACK_RETAINED_MESSAGES: u64 =
136    gate4agent_types::HISTORY_MESSAGES_MAX as u64;
137pub const MAX_WORKTREE_PROFILE_ID_BYTES: usize = 64;
138pub const MAX_WORKTREE_PROFILE_REVISION_BYTES: usize = 128;
139pub const MAX_MANAGED_WORKTREE_LEASE_ID_BYTES: usize = 128;
140pub const MAX_MANAGED_WORKTREE_LEASES: usize = 128;
141pub const MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE: usize = 64;
142pub const MAX_LAUNCH_BUNDLES: usize = 128;
143pub const MAX_SPAWN_DEADLINE_MS: u64 = 120_000;
144pub const MAX_WORKSPACE_ROOT_BYTES: usize = gate4agent_types::WORKING_DIRECTORY_MAX_BYTES;
145pub const MAX_REPOSITORY_PATH_BYTES: usize = 1_024;
146pub const MAX_WORKSPACE_FILE_BYTES: usize = 256 * 1024;
147pub const MAX_GIT_HISTORY_COMMITS: u16 = 50;
148pub const MAX_GIT_DIFF_BYTES: usize = 512 * 1024;
149pub const MAX_HOST_DIRECTORY_ENTRIES: usize = 256;
150pub const MAX_HOST_DIRECTORY_DISPLAY_NAME_BYTES: usize = 1_024;
151pub const MAX_DELIVERY_FILES: usize = 128;
152pub const MAX_DELIVERY_FILE_BYTES: usize = 1024 * 1024;
153pub const MAX_DELIVERY_TOTAL_BYTES: usize = 32 * 1024 * 1024;
154pub const MAX_DELIVERY_RELATIVE_PATH_BYTES: usize = 512;
155pub const MAX_DELIVERY_CHUNK_RAW_BYTES: usize = 48 * 1024;
156pub const MAX_HARNESS_MCP_REPLY_CHUNK_RAW_BYTES: usize = MAX_DELIVERY_CHUNK_RAW_BYTES;
157// Owned independently of `hatchery-harness-api::HARNESS_READ_REQUEST_MAX_
158// BYTES`/`HARNESS_READ_RESPONSE_MAX_BYTES` -- this crate must not depend on
159// the harness's crate at all (Nested Control Plane doctrine, Law 3; see this
160// crate's own CLAUDE.md `Forbidden:` line). The values are chosen to match
161// today; a future change to either side's budget is a deliberate edit on
162// both, not a shared constant.
163pub const MAX_HARNESS_MCP_LOCAL_REQUEST_BYTES: usize = 64 * 1024;
164pub const MAX_HARNESS_MCP_AGGREGATE_REPLY_BYTES: usize = 1024 * 1024;
165pub const MAX_HARNESS_MCP_PENDING_CALLS_PER_SESSION: usize = 32;
166pub const MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE: usize = 128;
167pub const MAX_HARNESS_MCP_RESERVATION_TTL_MS: u64 = 120_000;
168pub const MAX_HARNESS_MCP_CALL_DEADLINE_MS: u64 = 3_000;
169pub const MAX_HARNESS_MCP_SPAWN_RELAY_DEADLINE_MS: u64 = 125_000;
170pub const DELIVERY_STAGE_NONCE_BYTES: usize = 16;
171pub const MAX_NODE_HELLO_FRAME_BYTES: usize = 8 * 1024;
172pub const NODE_AUTH_NONCE_BYTES: usize = 32;
173pub const NODE_AUTH_PROOF_BYTES: usize = 32;
174pub const MAX_CONTROLLER_LEASE_MS: u64 = 60_000;
175pub const MIN_CONTROLLER_LEASE_MS: u64 = 1_000;
176pub const DEFAULT_CONTROLLER_LEASE_MS: u64 = 15_000;
177/// Mirrors `gate4agent_types::PROVIDER_EVENT_ID_MAX_BYTES` -- the ACP
178/// control verbs' `mode_id`/`option_id`/`model_id` and the agent stream's
179/// `tool_name`/catalog id fields round-trip provider-minted ids through
180/// the same bound the provider event stream already validates them
181/// against.
182pub const MAX_ACP_CONTROL_ID_BYTES: usize = gate4agent_types::PROVIDER_EVENT_ID_MAX_BYTES;
183/// Mirrors `gate4agent_types::PROVIDER_EVENT_TEXT_MAX_BYTES` -- free text
184/// carried on the agent content stream (`Text`, `Thinking`, interaction
185/// `prompt`/`title`, config `value_json`) and `SetSessionConfigOption`'s
186/// `value_json`.
187pub const MAX_ACP_CONTROL_TEXT_BYTES: usize = gate4agent_types::PROVIDER_EVENT_TEXT_MAX_BYTES;
188/// Bound on `ResolveInteraction.correlation_id` and on the short labels
189/// (`tool_class`, `reason_kind`) of an agent-stream `Blocked` chunk.
190pub const MAX_ACP_CORRELATION_ID_BYTES: usize = 64;
191/// Bound on the `reason` of an agent-stream `Blocked` chunk.
192pub const MAX_ACP_BLOCKED_REASON_BYTES: usize = 1_024;
193/// Bound on the `help` of an agent-stream `Blocked` chunk -- the guidance tail
194/// a CLI attaches after naming the block, which runs longer than a one-line
195/// refusal.
196pub const MAX_ACP_BLOCKED_HELP_BYTES: usize = 2_048;
197/// Mirrors `gate4agent_types::PROVIDER_CONFIG_OPTION_CHOICES_MAX` -- the
198/// option list on one `InteractionPrompt` chunk.
199pub const MAX_ACP_INTERACTION_OPTIONS: usize =
200    gate4agent_types::PROVIDER_CONFIG_OPTION_CHOICES_MAX;
201/// Mirrors `gate4agent_types::PROVIDER_CONFIG_OPTIONS_MAX` -- the catalog
202/// list on `ModeCatalog`/`ModelCatalog`/`ConfigOptions` chunks.
203pub const MAX_ACP_CATALOG_ENTRIES: usize = gate4agent_types::PROVIDER_CONFIG_OPTIONS_MAX;
204
205pub fn provider_id_is_legacy(provider: &AgentId) -> bool {
206    NODE_LEGACY_PROVIDER_IDS.contains(&provider.as_str())
207}
208
209#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
210#[serde(rename_all = "kebab-case")]
211pub enum ClientRole {
212    Operator,
213    Observer,
214}
215
216#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
217#[serde(rename_all = "kebab-case")]
218pub enum ProviderRuntimeMode {
219    Unavailable,
220    RawPassthrough,
221    VerifiedSemantic,
222}
223
224#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
225pub struct ProviderRuntimeVersion(String);
226
227#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
228pub struct ProviderRuntimeContractId(String);
229
230#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
231pub struct ProviderRuntimeStatus {
232    provider: AgentId,
233    mode: ProviderRuntimeMode,
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    version: Option<ProviderRuntimeVersion>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    contract_id: Option<ProviderRuntimeContractId>,
238}
239
240impl ProviderRuntimeStatus {
241    pub fn unavailable(provider: AgentId) -> Self {
242        Self {
243            provider,
244            mode: ProviderRuntimeMode::Unavailable,
245            version: None,
246            contract_id: None,
247        }
248    }
249
250    pub fn raw_passthrough(
251        provider: AgentId,
252        version: Option<ProviderRuntimeVersion>,
253    ) -> Self {
254        Self {
255            provider,
256            mode: ProviderRuntimeMode::RawPassthrough,
257            version,
258            contract_id: None,
259        }
260    }
261
262    pub fn verified_semantic(
263        provider: AgentId,
264        version: ProviderRuntimeVersion,
265        contract_id: ProviderRuntimeContractId,
266    ) -> Self {
267        Self {
268            provider,
269            mode: ProviderRuntimeMode::VerifiedSemantic,
270            version: Some(version),
271            contract_id: Some(contract_id),
272        }
273    }
274
275    pub fn provider(&self) -> &AgentId {
276        &self.provider
277    }
278
279    pub const fn mode(&self) -> ProviderRuntimeMode {
280        self.mode
281    }
282
283    pub fn version(&self) -> Option<&ProviderRuntimeVersion> {
284        self.version.as_ref()
285    }
286
287    pub fn contract_id(&self) -> Option<&ProviderRuntimeContractId> {
288        self.contract_id.as_ref()
289    }
290
291    fn from_wire(
292        provider: AgentId,
293        mode: ProviderRuntimeMode,
294        version: Option<ProviderRuntimeVersion>,
295        contract_id: Option<ProviderRuntimeContractId>,
296    ) -> Result<Self, ProviderRuntimeStatusError> {
297        match (mode, version, contract_id) {
298            (ProviderRuntimeMode::Unavailable, None, None) => Ok(Self::unavailable(provider)),
299            (ProviderRuntimeMode::RawPassthrough, version, None) => {
300                Ok(Self::raw_passthrough(provider, version))
301            }
302            (ProviderRuntimeMode::VerifiedSemantic, Some(version), Some(contract_id)) => {
303                Ok(Self::verified_semantic(provider, version, contract_id))
304            }
305            (ProviderRuntimeMode::Unavailable, _, _) => {
306                Err(ProviderRuntimeStatusError::UnavailableHasMetadata { provider })
307            }
308            (ProviderRuntimeMode::RawPassthrough, _, Some(_)) => {
309                Err(ProviderRuntimeStatusError::RawPassthroughHasContract { provider })
310            }
311            (ProviderRuntimeMode::VerifiedSemantic, _, _) => {
312                Err(ProviderRuntimeStatusError::VerifiedSemanticMissingMetadata { provider })
313            }
314        }
315    }
316}
317
318impl<'de> Deserialize<'de> for ProviderRuntimeStatus {
319    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
320    where
321        D: Deserializer<'de>,
322    {
323        #[derive(Deserialize)]
324        struct WireStatus {
325            provider: AgentId,
326            mode: ProviderRuntimeMode,
327            #[serde(default)]
328            version: Option<ProviderRuntimeVersion>,
329            #[serde(default)]
330            contract_id: Option<ProviderRuntimeContractId>,
331        }
332
333        let wire = WireStatus::deserialize(deserializer)?;
334        Self::from_wire(wire.provider, wire.mode, wire.version, wire.contract_id)
335            .map_err(serde::de::Error::custom)
336    }
337}
338
339#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
340#[serde(transparent)]
341pub struct ProviderRuntimeStatuses(Vec<ProviderRuntimeStatus>);
342
343impl ProviderRuntimeStatuses {
344    pub fn new(
345        statuses: impl IntoIterator<Item = ProviderRuntimeStatus>,
346    ) -> Result<Self, ProviderRuntimeStatusError> {
347        let mut bounded = Vec::with_capacity(MAX_PROVIDER_RUNTIME_STATUSES);
348        for status in statuses {
349            if bounded.len() == MAX_PROVIDER_RUNTIME_STATUSES {
350                return Err(ProviderRuntimeStatusError::TooMany {
351                    max: MAX_PROVIDER_RUNTIME_STATUSES,
352                });
353            }
354            if bounded
355                .iter()
356                .any(|existing: &ProviderRuntimeStatus| existing.provider == status.provider)
357            {
358                return Err(ProviderRuntimeStatusError::DuplicateProvider {
359                    provider: status.provider.clone(),
360                });
361            }
362            bounded.push(status);
363        }
364        bounded.sort_by(|left, right| left.provider.cmp(&right.provider));
365        Ok(Self(bounded))
366    }
367
368    pub fn as_slice(&self) -> &[ProviderRuntimeStatus] {
369        &self.0
370    }
371
372    pub fn iter(&self) -> impl ExactSizeIterator<Item = &ProviderRuntimeStatus> {
373        self.0.iter()
374    }
375
376    pub fn is_empty(&self) -> bool {
377        self.0.is_empty()
378    }
379
380    pub fn clear(&mut self) {
381        self.0.clear();
382    }
383
384    pub fn retain(
385        &mut self,
386        mut predicate: impl FnMut(&ProviderRuntimeStatus) -> bool,
387    ) {
388        self.0.retain(|status| predicate(status));
389    }
390}
391
392impl<'de> Deserialize<'de> for ProviderRuntimeStatuses {
393    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
394    where
395        D: Deserializer<'de>,
396    {
397        struct StatusesVisitor;
398
399        impl<'de> Visitor<'de> for StatusesVisitor {
400            type Value = ProviderRuntimeStatuses;
401
402            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
403                write!(
404                    formatter,
405                    "at most {MAX_PROVIDER_RUNTIME_STATUSES} unique provider runtime statuses",
406                )
407            }
408
409            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
410            where
411                A: SeqAccess<'de>,
412            {
413                let mut statuses = Vec::with_capacity(MAX_PROVIDER_RUNTIME_STATUSES);
414                while let Some(status) = sequence.next_element::<ProviderRuntimeStatus>()? {
415                    if statuses.len() == MAX_PROVIDER_RUNTIME_STATUSES {
416                        return Err(serde::de::Error::custom(
417                            ProviderRuntimeStatusError::TooMany {
418                                max: MAX_PROVIDER_RUNTIME_STATUSES,
419                            },
420                        ));
421                    }
422                    if statuses.iter().any(|existing: &ProviderRuntimeStatus| {
423                        existing.provider == status.provider
424                    }) {
425                        return Err(serde::de::Error::custom(
426                            ProviderRuntimeStatusError::DuplicateProvider {
427                                provider: status.provider.clone(),
428                            },
429                        ));
430                    }
431                    statuses.push(status);
432                }
433                statuses.sort_by(|left, right| left.provider.cmp(&right.provider));
434                Ok(ProviderRuntimeStatuses(statuses))
435            }
436        }
437
438        deserializer.deserialize_seq(StatusesVisitor)
439    }
440}
441
442#[derive(Clone, Debug, Eq, Error, PartialEq)]
443pub enum ProviderRuntimeStatusError {
444    #[error("provider runtime status exceeds the {max}-provider limit")]
445    TooMany { max: usize },
446    #[error("provider runtime status contains duplicate provider {provider:?}")]
447    DuplicateProvider { provider: AgentId },
448    #[error("unavailable provider {provider:?} cannot include version or contract metadata")]
449    UnavailableHasMetadata { provider: AgentId },
450    #[error("raw passthrough provider {provider:?} cannot include a semantic contract")]
451    RawPassthroughHasContract { provider: AgentId },
452    #[error("verified semantic provider {provider:?} requires both version and contract metadata")]
453    VerifiedSemanticMissingMetadata { provider: AgentId },
454}
455
456#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
457#[serde(rename_all = "kebab-case")]
458pub enum SessionMode {
459    Pty,
460    Inline,
461    Acp,
462}
463
464#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
465#[serde(transparent)]
466pub struct SpawnProfileId(String);
467
468#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
469#[serde(transparent)]
470pub struct SpawnProfileRevision(String);
471
472#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
473#[serde(transparent)]
474pub struct SpawnBundleId(String);
475
476#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
477#[serde(transparent)]
478pub struct SpawnBundleRevision(String);
479
480#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
481#[serde(transparent)]
482pub struct SpawnBundleDigest(String);
483
484#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
485#[serde(transparent)]
486pub struct SpawnContextId(String);
487
488#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
489#[serde(transparent)]
490pub struct SpawnContextDigest(String);
491
492#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
493#[serde(transparent)]
494pub struct SpawnEnvironmentProfileId(String);
495
496#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
497#[serde(transparent)]
498pub struct SpawnEnvironmentProfileRevision(String);
499
500/// Opaque node-local **network allowlist** policy id (station axis).
501///
502/// Ids / digests only on C2 — never cookies, OAuth material, or proxy
503/// credentials. See hatchery-websession-docs plans
504/// `station-network-and-browser-profile-knobs-2026-10-02.md` and
505/// `dig2browser-station-probe-and-network-permit-set-2026-10-02.md` Track B.
506/// Node catalog enforce refuses unknown ids (`UnknownNetworkAllowlist`);
507/// optional node-local permit-set / provider-native mapping stays off the wire.
508/// Unsupported provider-native mappings refuse
509/// (`UnsupportedNetworkAllowlistMapping`). Dig2browser station exclusive lease
510/// stubbed (probe feature-gated).
511#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
512#[serde(transparent)]
513pub struct SpawnNetworkAllowlistId(String);
514
515/// Opaque dig2browser station **browserProfile** id under node-local
516/// `profiles_root`.
517///
518/// Profile id string only — never cookie bytes or route credentials on C2 /
519/// IPC. See hatchery-websession-docs plan
520/// `station-network-and-browser-profile-knobs-2026-10-02.md`. Local station
521/// pipe reachability refuse + exclusive node-local lease behind feature `dig2-station-probe` (never cookies on C2).
522#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
523#[serde(transparent)]
524pub struct SpawnBrowserProfileId(String);
525
526#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
527#[serde(transparent)]
528pub struct SpawnIdempotencyKey(String);
529
530macro_rules! spawn_identifier_impl {
531    ($type:ident, $label:literal, $max:ident) => {
532        impl $type {
533            pub fn new(value: impl Into<String>) -> Result<Self, SpawnIdentifierError> {
534                let value = value.into();
535                validate_spawn_identifier($label, &value, $max)?;
536                Ok(Self(value))
537            }
538
539            pub fn as_str(&self) -> &str {
540                &self.0
541            }
542        }
543
544        impl fmt::Display for $type {
545            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
546                formatter.write_str(self.as_str())
547            }
548        }
549
550        impl FromStr for $type {
551            type Err = SpawnIdentifierError;
552
553            fn from_str(value: &str) -> Result<Self, Self::Err> {
554                Self::new(value)
555            }
556        }
557
558        impl<'de> Deserialize<'de> for $type {
559            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
560            where
561                D: Deserializer<'de>,
562            {
563                let value = String::deserialize(deserializer)?;
564                Self::new(value).map_err(serde::de::Error::custom)
565            }
566        }
567    };
568}
569
570spawn_identifier_impl!(SpawnProfileId, "spawn profile", MAX_SPAWN_PROFILE_ID_BYTES);
571spawn_identifier_impl!(
572    SpawnProfileRevision,
573    "spawn profile revision",
574    MAX_SPAWN_PROFILE_REVISION_BYTES
575);
576spawn_identifier_impl!(SpawnBundleId, "spawn bundle", MAX_SPAWN_RESOURCE_ID_BYTES);
577spawn_identifier_impl!(
578    SpawnBundleRevision,
579    "spawn bundle revision",
580    MAX_SPAWN_BUNDLE_REVISION_BYTES
581);
582spawn_identifier_impl!(SpawnContextId, "spawn context", MAX_SPAWN_RESOURCE_ID_BYTES);
583spawn_identifier_impl!(
584    SpawnEnvironmentProfileId,
585    "spawn environment profile",
586    MAX_SPAWN_RESOURCE_ID_BYTES
587);
588spawn_identifier_impl!(
589    SpawnNetworkAllowlistId,
590    "spawn network allowlist",
591    MAX_SPAWN_RESOURCE_ID_BYTES
592);
593spawn_identifier_impl!(
594    SpawnBrowserProfileId,
595    "spawn browser profile",
596    MAX_SPAWN_RESOURCE_ID_BYTES
597);
598
599impl SpawnBundleDigest {
600    pub fn new(value: impl Into<String>) -> Result<Self, SpawnBundleDigestError> {
601        let value = value.into();
602        let digest = value
603            .strip_prefix("sha256:")
604            .ok_or(SpawnBundleDigestError)?;
605        if digest.len() != 64
606            || !digest
607                .bytes()
608                .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
609        {
610            return Err(SpawnBundleDigestError);
611        }
612        Ok(Self(value))
613    }
614
615    pub fn as_str(&self) -> &str {
616        &self.0
617    }
618}
619
620impl fmt::Display for SpawnBundleDigest {
621    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
622        formatter.write_str(self.as_str())
623    }
624}
625
626impl FromStr for SpawnBundleDigest {
627    type Err = SpawnBundleDigestError;
628
629    fn from_str(value: &str) -> Result<Self, Self::Err> {
630        Self::new(value)
631    }
632}
633
634impl<'de> Deserialize<'de> for SpawnBundleDigest {
635    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
636    where
637        D: Deserializer<'de>,
638    {
639        let value = String::deserialize(deserializer)?;
640        Self::new(value).map_err(serde::de::Error::custom)
641    }
642}
643
644#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
645#[error("spawn bundle digest must be sha256: followed by exactly 64 lowercase hexadecimal characters")]
646pub struct SpawnBundleDigestError;
647
648impl SpawnContextDigest {
649    pub fn new(value: impl Into<String>) -> Result<Self, SpawnContextDigestError> {
650        let value = value.into();
651        let digest = value
652            .strip_prefix("sha256:")
653            .ok_or(SpawnContextDigestError)?;
654        if digest.len() != 64
655            || !digest
656                .bytes()
657                .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
658        {
659            return Err(SpawnContextDigestError);
660        }
661        Ok(Self(value))
662    }
663
664    pub fn as_str(&self) -> &str {
665        &self.0
666    }
667}
668
669impl fmt::Display for SpawnContextDigest {
670    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
671        formatter.write_str(self.as_str())
672    }
673}
674
675impl FromStr for SpawnContextDigest {
676    type Err = SpawnContextDigestError;
677
678    fn from_str(value: &str) -> Result<Self, Self::Err> {
679        Self::new(value)
680    }
681}
682
683impl<'de> Deserialize<'de> for SpawnContextDigest {
684    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
685    where
686        D: Deserializer<'de>,
687    {
688        let value = String::deserialize(deserializer)?;
689        Self::new(value).map_err(serde::de::Error::custom)
690    }
691}
692
693#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
694#[error("spawn context digest must be sha256: followed by exactly 64 lowercase hexadecimal characters")]
695pub struct SpawnContextDigestError;
696spawn_identifier_impl!(
697    SpawnEnvironmentProfileRevision,
698    "spawn environment profile revision",
699    MAX_SPAWN_ENVIRONMENT_PROFILE_REVISION_BYTES
700);
701spawn_identifier_impl!(
702    SpawnIdempotencyKey,
703    "spawn idempotency key",
704    MAX_SPAWN_IDEMPOTENCY_KEY_BYTES
705);
706
707#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
708#[serde(transparent)]
709pub struct WorktreeProfileId(String);
710
711#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
712#[serde(transparent)]
713pub struct WorktreeProfileRevision(String);
714
715#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
716#[serde(transparent)]
717pub struct ManagedWorktreeLeaseId(String);
718
719macro_rules! managed_worktree_identifier_impl {
720    ($type:ident, $label:literal, $max:ident) => {
721        impl $type {
722            pub fn new(
723                value: impl Into<String>,
724            ) -> Result<Self, ManagedWorktreeIdentifierError> {
725                let value = value.into();
726                validate_spawn_identifier($label, &value, $max).map_err(
727                    |error| ManagedWorktreeIdentifierError { source: error },
728                )?;
729                Ok(Self(value))
730            }
731
732            pub fn as_str(&self) -> &str {
733                &self.0
734            }
735        }
736
737        impl fmt::Display for $type {
738            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
739                formatter.write_str(self.as_str())
740            }
741        }
742
743        impl FromStr for $type {
744            type Err = ManagedWorktreeIdentifierError;
745
746            fn from_str(value: &str) -> Result<Self, Self::Err> {
747                Self::new(value)
748            }
749        }
750
751        impl<'de> Deserialize<'de> for $type {
752            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
753            where
754                D: Deserializer<'de>,
755            {
756                let value = String::deserialize(deserializer)?;
757                Self::new(value).map_err(serde::de::Error::custom)
758            }
759        }
760    };
761}
762
763managed_worktree_identifier_impl!(
764    WorktreeProfileId,
765    "worktree profile",
766    MAX_WORKTREE_PROFILE_ID_BYTES
767);
768managed_worktree_identifier_impl!(
769    WorktreeProfileRevision,
770    "worktree profile revision",
771    MAX_WORKTREE_PROFILE_REVISION_BYTES
772);
773managed_worktree_identifier_impl!(
774    ManagedWorktreeLeaseId,
775    "managed worktree lease",
776    MAX_MANAGED_WORKTREE_LEASE_ID_BYTES
777);
778
779#[derive(Clone, Debug, Eq, Error, PartialEq)]
780#[error("invalid managed worktree identifier: {source}")]
781pub struct ManagedWorktreeIdentifierError {
782    #[source]
783    source: SpawnIdentifierError,
784}
785
786fn validate_spawn_identifier(
787    label: &'static str,
788    value: &str,
789    max: usize,
790) -> Result<(), SpawnIdentifierError> {
791    if value.is_empty() {
792        return Err(SpawnIdentifierError::Empty { label });
793    }
794    if value.len() > max {
795        return Err(SpawnIdentifierError::TooLong {
796            label,
797            len: value.len(),
798            max,
799        });
800    }
801    if !value
802        .bytes()
803        .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'/' | b'\\'))
804    {
805        return Err(SpawnIdentifierError::InvalidCharacters {
806            label,
807            value: value.to_owned(),
808        });
809    }
810    Ok(())
811}
812
813#[derive(Clone, Debug, Eq, Error, PartialEq)]
814pub enum SpawnIdentifierError {
815    #[error("{label} cannot be empty")]
816    Empty { label: &'static str },
817    #[error("{label} length {len} exceeds the {max}-byte limit")]
818    TooLong {
819        label: &'static str,
820        len: usize,
821        max: usize,
822    },
823    #[error("{label} must contain printable non-whitespace ASCII without path separators: {value}")]
824    InvalidCharacters { label: &'static str, value: String },
825}
826
827#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
828#[serde(transparent)]
829pub struct SpawnPrompt(String);
830
831impl SpawnPrompt {
832    pub fn new(value: impl Into<String>) -> Result<Self, SpawnPromptError> {
833        let value = value.into();
834        if value.len() > MAX_NODE_TEXT_BYTES {
835            return Err(SpawnPromptError::TooLong {
836                len: value.len(),
837                max: MAX_NODE_TEXT_BYTES,
838            });
839        }
840        Ok(Self(value))
841    }
842
843    pub fn as_str(&self) -> &str {
844        &self.0
845    }
846
847    pub fn byte_len(&self) -> usize {
848        self.0.len()
849    }
850}
851
852impl<'de> Deserialize<'de> for SpawnPrompt {
853    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
854    where
855        D: Deserializer<'de>,
856    {
857        let value = String::deserialize(deserializer)?;
858        Self::new(value).map_err(serde::de::Error::custom)
859    }
860}
861
862#[derive(Clone, Debug, Eq, Error, PartialEq)]
863pub enum SpawnPromptError {
864    #[error("spawn prompt length {len} exceeds the {max}-byte limit")]
865    TooLong { len: usize, max: usize },
866}
867
868#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
869#[serde(transparent)]
870pub struct SpawnDeadlineMs(u64);
871
872impl SpawnDeadlineMs {
873    pub fn new(value: u64) -> Result<Self, SpawnDeadlineError> {
874        if value == 0 || value > MAX_SPAWN_DEADLINE_MS {
875            return Err(SpawnDeadlineError::OutOfRange {
876                value,
877                max: MAX_SPAWN_DEADLINE_MS,
878            });
879        }
880        Ok(Self(value))
881    }
882
883    pub const fn get(self) -> u64 {
884        self.0
885    }
886}
887
888impl<'de> Deserialize<'de> for SpawnDeadlineMs {
889    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
890    where
891        D: Deserializer<'de>,
892    {
893        Self::new(u64::deserialize(deserializer)?).map_err(serde::de::Error::custom)
894    }
895}
896
897#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
898pub enum SpawnDeadlineError {
899    #[error("spawn deadline {value}ms is outside 1..={max}ms")]
900    OutOfRange { value: u64, max: u64 },
901}
902
903#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
904#[serde(transparent)]
905pub struct SpawnRequiredCapabilities(Vec<CapabilityId>);
906
907impl SpawnRequiredCapabilities {
908    pub fn new(
909        capabilities: impl IntoIterator<Item = CapabilityId>,
910    ) -> Result<Self, SpawnRequiredCapabilitiesError> {
911        let mut bounded = Vec::with_capacity(MAX_SPAWN_REQUIRED_CAPABILITIES);
912        for capability in capabilities {
913            if bounded.len() == MAX_SPAWN_REQUIRED_CAPABILITIES {
914                return Err(SpawnRequiredCapabilitiesError::TooMany {
915                    max: MAX_SPAWN_REQUIRED_CAPABILITIES,
916                });
917            }
918            if bounded.contains(&capability) {
919                return Err(SpawnRequiredCapabilitiesError::Duplicate { capability });
920            }
921            bounded.push(capability);
922        }
923        bounded.sort();
924        Ok(Self(bounded))
925    }
926
927    pub fn as_slice(&self) -> &[CapabilityId] {
928        &self.0
929    }
930
931    pub fn iter(&self) -> impl ExactSizeIterator<Item = &CapabilityId> {
932        self.0.iter()
933    }
934
935    pub fn is_empty(&self) -> bool {
936        self.0.is_empty()
937    }
938}
939
940impl<'de> Deserialize<'de> for SpawnRequiredCapabilities {
941    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
942    where
943        D: Deserializer<'de>,
944    {
945        struct CapabilitiesVisitor;
946
947        impl<'de> Visitor<'de> for CapabilitiesVisitor {
948            type Value = SpawnRequiredCapabilities;
949
950            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
951                write!(
952                    formatter,
953                    "at most {MAX_SPAWN_REQUIRED_CAPABILITIES} unique spawn capabilities",
954                )
955            }
956
957            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
958            where
959                A: SeqAccess<'de>,
960            {
961                let mut capabilities = Vec::with_capacity(MAX_SPAWN_REQUIRED_CAPABILITIES);
962                while let Some(capability) = sequence.next_element::<CapabilityId>()? {
963                    if capabilities.len() == MAX_SPAWN_REQUIRED_CAPABILITIES {
964                        return Err(serde::de::Error::custom(
965                            SpawnRequiredCapabilitiesError::TooMany {
966                                max: MAX_SPAWN_REQUIRED_CAPABILITIES,
967                            },
968                        ));
969                    }
970                    if capabilities.contains(&capability) {
971                        return Err(serde::de::Error::custom(
972                            SpawnRequiredCapabilitiesError::Duplicate { capability },
973                        ));
974                    }
975                    capabilities.push(capability);
976                }
977                capabilities.sort();
978                Ok(SpawnRequiredCapabilities(capabilities))
979            }
980        }
981
982        deserializer.deserialize_seq(CapabilitiesVisitor)
983    }
984}
985
986#[derive(Clone, Debug, Eq, Error, PartialEq)]
987pub enum SpawnRequiredCapabilitiesError {
988    #[error("spawn required capabilities exceed the {max}-entry limit")]
989    TooMany { max: usize },
990    #[error("spawn required capabilities contain duplicate {capability}")]
991    Duplicate { capability: CapabilityId },
992}
993
994#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
995#[serde(deny_unknown_fields)]
996pub struct SpawnTarget {
997    pub node_id: NodeId,
998    pub workspace_id: WorkspaceId,
999    #[serde(default, skip_serializing_if = "Option::is_none")]
1000    pub worktree_id: Option<WorkspaceId>,
1001}
1002
1003#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1004#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
1005pub enum SpawnOverride<T> {
1006    Inherit,
1007    Set { value: T },
1008    Clear,
1009}
1010
1011impl<T> Default for SpawnOverride<T> {
1012    fn default() -> Self {
1013        Self::Inherit
1014    }
1015}
1016
1017/// Spawn-time overrides for an accepted `SpawnSpec`.
1018///
1019/// Station-profile axes (design ledger): `environment_profile_id` covers
1020/// **providerHome** bindings; workspace target covers **cwd**;
1021/// `approval_level` is the partial **sandbox** axis; optional
1022/// `network_allowlist` + `browser_profile_id` are the **network** /
1023/// dig2browser **browserProfile** axes (ids only). See hatchery-websession-docs
1024/// plan `station-network-and-browser-profile-knobs-2026-10-02.md` and research
1025/// `station-profile-and-os-sandbox-matrix-2026-10-02.md`. Secrets stay on
1026/// the node; C2 carries ids/receipts only (`C2 → node → drivers`). Resolve
1027/// echoes registered network allowlist ids onto env-profile receipts (empty-
1028/// default catalog refuse); dig2browser station IPC bind / reachability
1029/// refuse waits on a cheap g4a-local probe (stubbed on node).
1030#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1031#[serde(default, deny_unknown_fields)]
1032pub struct SpawnOverrides {
1033    pub provider: SpawnOverride<AgentId>,
1034    pub mode: SpawnOverride<SessionMode>,
1035    pub terminal_size: SpawnOverride<TerminalSize>,
1036    pub prompt: SpawnOverride<SpawnPrompt>,
1037    pub bundle_id: SpawnOverride<SpawnBundleId>,
1038    pub context_id: SpawnOverride<SpawnContextId>,
1039    pub environment_profile_id: SpawnOverride<SpawnEnvironmentProfileId>,
1040    /// A plain `Option`, not a `SpawnOverride<ApprovalLevel>` like every
1041    /// field above it: those all have a corresponding field on
1042    /// `SpawnProfileDefaults` to inherit from, so `Inherit` names a real
1043    /// third state distinct from "cleared" or "set". A spawn profile
1044    /// declares no approval level of its own, so there is nothing to
1045    /// inherit -- `None` already means exactly what `Inherit` would, "use
1046    /// the axis default" (`ApprovalLevel::FullAuto`), so this stays a
1047    /// two-state `Option` rather than adding a `SpawnOverride` variant that
1048    /// can never resolve against a profile field that does not exist.
1049    #[serde(skip_serializing_if = "Option::is_none")]
1050    pub approval_level: Option<ApprovalLevel>,
1051    /// Optional station **network** allowlist policy id (node-local catalog).
1052    /// Opaque id only — never credentials. Plan:
1053    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
1054    /// Resolve echoes this onto `ResolvedEnvironmentProfileReceipt` when the
1055    /// id is registered in the node-local catalog (empty by default). Empty /
1056    /// whitespace ids refuse at type construction; unknown ids refuse with
1057    /// `UnknownNetworkAllowlist`. Dig2browser bind is separate (`browser_profile_id`).
1058    #[serde(default, skip_serializing_if = "Option::is_none")]
1059    pub network_allowlist: Option<SpawnNetworkAllowlistId>,
1060    /// Optional dig2browser station **browserProfile** id (`profiles_root`).
1061    /// Id only — never cookie/OAuth/proxy material on C2. Plan:
1062    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.2 / §4.
1063    /// Resolve echoes this onto `ResolvedEnvironmentProfileReceipt`. Station
1064    /// IPC reachability refuse waits on a cheap g4a-local probe (stubbed).
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub browser_profile_id: Option<SpawnBrowserProfileId>,
1067}
1068
1069impl Default for SpawnOverrides {
1070    fn default() -> Self {
1071        Self {
1072            provider: SpawnOverride::Inherit,
1073            mode: SpawnOverride::Inherit,
1074            terminal_size: SpawnOverride::Inherit,
1075            prompt: SpawnOverride::Inherit,
1076            bundle_id: SpawnOverride::Inherit,
1077            context_id: SpawnOverride::Inherit,
1078            environment_profile_id: SpawnOverride::Inherit,
1079            approval_level: None,
1080            network_allowlist: None,
1081            browser_profile_id: None,
1082        }
1083    }
1084}
1085
1086#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1087#[serde(deny_unknown_fields)]
1088pub struct SpawnProfileDefaults {
1089    pub profile_id: SpawnProfileId,
1090    pub revision: SpawnProfileRevision,
1091    pub provider: AgentId,
1092    pub mode: SessionMode,
1093    pub terminal_size: TerminalSize,
1094    pub prompt: Option<SpawnPrompt>,
1095    pub bundle_id: Option<SpawnBundleId>,
1096    pub context_id: Option<SpawnContextId>,
1097    pub environment_profile_id: Option<SpawnEnvironmentProfileId>,
1098}
1099
1100#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1101#[serde(deny_unknown_fields)]
1102pub struct SpawnSpec {
1103    pub target: SpawnTarget,
1104    pub profile_id: SpawnProfileId,
1105    pub expected_profile_revision: SpawnProfileRevision,
1106    #[serde(default)]
1107    pub overrides: SpawnOverrides,
1108    /// Node-local processing budget. It starts when the authenticated Node accepts the
1109    /// first request for this idempotency key; relay queueing is outside this budget.
1110    pub deadline_ms: SpawnDeadlineMs,
1111    pub idempotency_key: SpawnIdempotencyKey,
1112    #[serde(default)]
1113    pub required_capabilities: SpawnRequiredCapabilities,
1114}
1115
1116#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1117#[serde(rename_all = "kebab-case")]
1118pub enum ManagedWorktreeRetention {
1119    RemoveWhenReleased,
1120    Retain,
1121}
1122
1123#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1124#[serde(rename_all = "kebab-case")]
1125pub enum ManagedWorktreeLeaseState {
1126    Allocating,
1127    Ready,
1128    InUse,
1129    Retained,
1130    CleanupBlocked,
1131    RecoveryRequired,
1132    Removed,
1133}
1134
1135#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1136#[serde(rename_all = "kebab-case")]
1137pub enum ManagedWorktreeCleanupFailure {
1138    Busy,
1139    Dirty,
1140    Locked,
1141    Prunable,
1142    OwnershipConflict,
1143    Backend,
1144}
1145
1146#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1147#[serde(deny_unknown_fields)]
1148pub struct ManagedWorktreeSpawnRequest {
1149    pub spawn_spec: SpawnSpec,
1150    pub worktree_profile_id: WorktreeProfileId,
1151}
1152
1153#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1154#[serde(deny_unknown_fields)]
1155pub struct ManagedWorktreeSpawnRequestV2 {
1156    pub spawn_spec: SpawnSpec,
1157    pub worktree_profile_id: WorktreeProfileId,
1158    pub expected_profile_revision: WorktreeProfileRevision,
1159}
1160
1161#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1162#[serde(deny_unknown_fields)]
1163pub struct ManagedWorktreeLeaseSnapshot {
1164    pub lease_id: ManagedWorktreeLeaseId,
1165    pub source_workspace_id: WorkspaceId,
1166    pub workspace_id: WorkspaceId,
1167    pub profile_id: WorktreeProfileId,
1168    pub profile_revision: WorktreeProfileRevision,
1169    pub retention: ManagedWorktreeRetention,
1170    pub state: ManagedWorktreeLeaseState,
1171    pub active_session_count: u16,
1172    pub managed_record_count: u16,
1173    pub cleanup_failure: Option<ManagedWorktreeCleanupFailure>,
1174    pub created_at_unix_ms: u64,
1175    pub updated_at_unix_ms: u64,
1176}
1177
1178impl<'de> Deserialize<'de> for ManagedWorktreeLeaseSnapshot {
1179    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1180    where
1181        D: Deserializer<'de>,
1182    {
1183        #[derive(Deserialize)]
1184        #[serde(deny_unknown_fields)]
1185        struct WireLease {
1186            lease_id: ManagedWorktreeLeaseId,
1187            source_workspace_id: WorkspaceId,
1188            workspace_id: WorkspaceId,
1189            profile_id: WorktreeProfileId,
1190            profile_revision: WorktreeProfileRevision,
1191            retention: ManagedWorktreeRetention,
1192            state: ManagedWorktreeLeaseState,
1193            active_session_count: u16,
1194            managed_record_count: u16,
1195            cleanup_failure: Option<ManagedWorktreeCleanupFailure>,
1196            created_at_unix_ms: u64,
1197            updated_at_unix_ms: u64,
1198        }
1199
1200        let wire = WireLease::deserialize(deserializer)?;
1201        if wire.source_workspace_id == wire.workspace_id {
1202            return Err(serde::de::Error::custom(
1203                "managed worktree workspace cannot alias its source workspace",
1204            ));
1205        }
1206        if wire.updated_at_unix_ms < wire.created_at_unix_ms {
1207            return Err(serde::de::Error::custom(
1208                "managed worktree update timestamp precedes creation timestamp",
1209            ));
1210        }
1211        let holder_count = u32::from(wire.active_session_count)
1212            .saturating_add(u32::from(wire.managed_record_count));
1213        let valid_state = match wire.state {
1214            ManagedWorktreeLeaseState::Allocating
1215            | ManagedWorktreeLeaseState::Ready
1216            | ManagedWorktreeLeaseState::Retained
1217            | ManagedWorktreeLeaseState::Removed => {
1218                holder_count == 0 && wire.cleanup_failure.is_none()
1219            }
1220            ManagedWorktreeLeaseState::InUse => {
1221                holder_count > 0 && wire.cleanup_failure.is_none()
1222            }
1223            ManagedWorktreeLeaseState::CleanupBlocked => {
1224                holder_count == 0 && wire.cleanup_failure.is_some()
1225            }
1226            ManagedWorktreeLeaseState::RecoveryRequired => wire.cleanup_failure.is_some(),
1227        };
1228        if !valid_state {
1229            return Err(serde::de::Error::custom(
1230                "managed worktree state, holder counts, and cleanup failure are inconsistent",
1231            ));
1232        }
1233        Ok(Self {
1234            lease_id: wire.lease_id,
1235            source_workspace_id: wire.source_workspace_id,
1236            workspace_id: wire.workspace_id,
1237            profile_id: wire.profile_id,
1238            profile_revision: wire.profile_revision,
1239            retention: wire.retention,
1240            state: wire.state,
1241            active_session_count: wire.active_session_count,
1242            managed_record_count: wire.managed_record_count,
1243            cleanup_failure: wire.cleanup_failure,
1244            created_at_unix_ms: wire.created_at_unix_ms,
1245            updated_at_unix_ms: wire.updated_at_unix_ms,
1246        })
1247    }
1248}
1249
1250#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1251#[serde(deny_unknown_fields)]
1252pub struct ManagedWorktreeSpawnReceipt {
1253    pub spawn: ResolvedSpawnReceipt,
1254    pub lease: ManagedWorktreeLeaseSnapshot,
1255}
1256
1257impl<'de> Deserialize<'de> for ManagedWorktreeSpawnReceipt {
1258    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1259    where
1260        D: Deserializer<'de>,
1261    {
1262        #[derive(Deserialize)]
1263        #[serde(deny_unknown_fields)]
1264        struct WireReceipt {
1265            spawn: ResolvedSpawnReceipt,
1266            lease: ManagedWorktreeLeaseSnapshot,
1267        }
1268
1269        let wire = WireReceipt::deserialize(deserializer)?;
1270        if wire.lease.source_workspace_id != wire.spawn.target.workspace_id
1271            || wire.spawn.target.worktree_id.as_ref() != Some(&wire.lease.workspace_id)
1272            || wire.spawn.session.workspace_id != wire.lease.workspace_id
1273            || wire.lease.state != ManagedWorktreeLeaseState::InUse
1274            || wire.lease.cleanup_failure.is_some()
1275            || wire.lease.active_session_count != 1
1276        {
1277            return Err(serde::de::Error::custom(
1278                "managed worktree spawn receipt contains inconsistent lease correlation",
1279            ));
1280        }
1281        Ok(Self {
1282            spawn: wire.spawn,
1283            lease: wire.lease,
1284        })
1285    }
1286}
1287
1288#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1289#[serde(rename_all = "kebab-case")]
1290pub enum SpawnFieldProvenance {
1291    Profile,
1292    Override,
1293    Cleared,
1294}
1295
1296#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1297#[serde(deny_unknown_fields)]
1298pub struct SpawnResolutionProvenance {
1299    pub provider: SpawnFieldProvenance,
1300    pub mode: SpawnFieldProvenance,
1301    pub terminal_size: SpawnFieldProvenance,
1302    pub prompt: SpawnFieldProvenance,
1303    pub bundle_id: SpawnFieldProvenance,
1304    pub context_id: SpawnFieldProvenance,
1305    pub environment_profile_id: SpawnFieldProvenance,
1306}
1307
1308#[derive(Clone, Debug, Eq, PartialEq)]
1309pub struct ResolvedSpawnSpec {
1310    pub target: SpawnTarget,
1311    pub profile_id: SpawnProfileId,
1312    pub profile_revision: SpawnProfileRevision,
1313    pub provider: AgentId,
1314    pub mode: SessionMode,
1315    pub terminal_size: TerminalSize,
1316    pub prompt: Option<SpawnPrompt>,
1317    pub bundle_id: Option<SpawnBundleId>,
1318    pub context_id: Option<SpawnContextId>,
1319    pub environment_profile_id: Option<SpawnEnvironmentProfileId>,
1320    pub deadline_ms: SpawnDeadlineMs,
1321    pub idempotency_key: SpawnIdempotencyKey,
1322    pub required_capabilities: SpawnRequiredCapabilities,
1323    pub provenance: SpawnResolutionProvenance,
1324    /// Resolved from `overrides.approval_level` -- `None` becomes
1325    /// `ApprovalLevel::default()` (`FullAuto`) here, once, so every
1326    /// consumer of a `ResolvedSpawnSpec` reads a concrete launch-time value
1327    /// rather than re-deriving the same default independently.
1328    pub approval_level: ApprovalLevel,
1329    /// Opaque station **network** allowlist policy id from
1330    /// `SpawnOverrides::network_allowlist` (pass-through). Empty/whitespace
1331    /// ids are refused at `SpawnNetworkAllowlistId` construction. Unknown
1332    /// catalog ids refuse at node resolve (`UnknownNetworkAllowlist`); this
1333    /// field carries the id for receipt echo when registered.
1334    pub network_allowlist: Option<SpawnNetworkAllowlistId>,
1335    /// Opaque dig2browser station **browserProfile** id from
1336    /// `SpawnOverrides::browser_profile_id` (pass-through). Empty/whitespace
1337    /// ids are refused at `SpawnBrowserProfileId` construction. When feature
1338    /// `dig2-station-probe` is on, node resolve probes local station IPC and
1339    /// spawn takes an exclusive node-local lease (never ImportSession/cookies
1340    /// on C2). Feature off keeps stub id echo.
1341    pub browser_profile_id: Option<SpawnBrowserProfileId>,
1342}
1343
1344#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
1345pub struct SpawnPromptMetadata {
1346    pub present: bool,
1347    pub byte_len: u32,
1348}
1349
1350impl<'de> Deserialize<'de> for SpawnPromptMetadata {
1351    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1352    where
1353        D: Deserializer<'de>,
1354    {
1355        #[derive(Deserialize)]
1356        #[serde(deny_unknown_fields)]
1357        struct WireMetadata {
1358            present: bool,
1359            byte_len: u32,
1360        }
1361
1362        let wire = WireMetadata::deserialize(deserializer)?;
1363        if usize::try_from(wire.byte_len).unwrap_or(usize::MAX) > MAX_NODE_TEXT_BYTES {
1364            return Err(serde::de::Error::custom(
1365                "spawn prompt metadata exceeds the prompt byte limit",
1366            ));
1367        }
1368        if !wire.present && wire.byte_len != 0 {
1369            return Err(serde::de::Error::custom(
1370                "absent spawn prompt metadata must report zero bytes",
1371            ));
1372        }
1373        Ok(Self {
1374            present: wire.present,
1375            byte_len: wire.byte_len,
1376        })
1377    }
1378}
1379
1380impl SpawnPromptMetadata {
1381    pub fn from_prompt(prompt: Option<&SpawnPrompt>) -> Self {
1382        Self {
1383            present: prompt.is_some(),
1384            byte_len: prompt
1385                .map(SpawnPrompt::byte_len)
1386                .and_then(|len| u32::try_from(len).ok())
1387                .unwrap_or(0),
1388        }
1389    }
1390}
1391
1392/// Resolved environment-profile identity echoed on spawn receipts.
1393///
1394/// Always carries profile id + revision. Optional `network_allowlist` and
1395/// `browser_profile_id` echo **opaque station ids only** (never cookies /
1396/// OAuth / proxy credentials on C2). See hatchery-websession-docs plan
1397/// `station-network-and-browser-profile-knobs-2026-10-02.md` §4. Dig2browser
1398/// station reachability refuse + exclusive lease (feature `dig2-station-probe`)
1399/// run on the node — this receipt still echoes **opaque ids only**.
1400#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1401#[serde(deny_unknown_fields)]
1402pub struct ResolvedEnvironmentProfileReceipt {
1403    pub profile_id: SpawnEnvironmentProfileId,
1404    pub profile_revision: SpawnEnvironmentProfileRevision,
1405    /// Echo of spawn/station **network** allowlist policy id when set.
1406    /// Plan: `station-network-and-browser-profile-knobs-2026-10-02.md`.
1407    #[serde(default, skip_serializing_if = "Option::is_none")]
1408    pub network_allowlist: Option<SpawnNetworkAllowlistId>,
1409    /// Echo of optional dig2browser station **browserProfile** id when set.
1410    /// Plan: `station-network-and-browser-profile-knobs-2026-10-02.md`.
1411    #[serde(default, skip_serializing_if = "Option::is_none")]
1412    pub browser_profile_id: Option<SpawnBrowserProfileId>,
1413}
1414
1415#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1416#[serde(deny_unknown_fields)]
1417pub struct ResolvedBundleReceipt {
1418    pub id: SpawnBundleId,
1419    pub revision: SpawnBundleRevision,
1420    pub digest: SpawnBundleDigest,
1421}
1422
1423#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1424#[serde(deny_unknown_fields)]
1425pub struct SpawnProfileSummary {
1426    pub id: SpawnProfileId,
1427    pub revision: SpawnProfileRevision,
1428    #[serde(default, skip_serializing_if = "Option::is_none")]
1429    pub environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
1430}
1431
1432#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1433#[serde(deny_unknown_fields)]
1434pub struct ManagedWorktreeProfileSummary {
1435    pub id: WorktreeProfileId,
1436    pub revision: WorktreeProfileRevision,
1437    pub retention: ManagedWorktreeRetention,
1438}
1439
1440#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1441#[serde(transparent)]
1442pub struct WorktreeProfileInventory {
1443    pub profiles: Vec<ManagedWorktreeProfileSummary>,
1444}
1445
1446impl<'de> Deserialize<'de> for WorktreeProfileInventory {
1447    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1448    where
1449        D: Deserializer<'de>,
1450    {
1451        deserialize_bounded_worktree_profiles(deserializer).map(|profiles| Self { profiles })
1452    }
1453}
1454
1455#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1456#[serde(deny_unknown_fields)]
1457pub struct LaunchInventory {
1458    #[serde(default, skip_serializing_if = "Option::is_none")]
1459    pub spawn_profiles: Option<Vec<SpawnProfileSummary>>,
1460    #[serde(default, skip_serializing_if = "Option::is_none")]
1461    pub bundles: Option<Vec<ResolvedBundleReceipt>>,
1462    /// Station network allowlist catalog ids registered on this node (opaque).
1463    /// Empty/None when unset. Never cookies / OAuth / proxy credentials.
1464    /// Dig2browser station probe is optional (`dig2-station-probe`); inventory
1465    /// still lists opaque allowlist ids only.
1466    #[serde(default, skip_serializing_if = "Option::is_none")]
1467    pub network_allowlists: Option<Vec<SpawnNetworkAllowlistId>>,
1468}
1469
1470impl<'de> Deserialize<'de> for LaunchInventory {
1471    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1472    where
1473        D: Deserializer<'de>,
1474    {
1475        #[derive(Deserialize)]
1476        #[serde(deny_unknown_fields)]
1477        struct WireInventory {
1478            #[serde(default)]
1479            spawn_profiles: Option<BoundedSpawnProfiles>,
1480            #[serde(default)]
1481            bundles: Option<BoundedLaunchBundles>,
1482            #[serde(default)]
1483            network_allowlists: Option<Vec<SpawnNetworkAllowlistId>>,
1484        }
1485
1486        let wire = WireInventory::deserialize(deserializer)?;
1487        if wire.spawn_profiles.is_none()
1488            && wire.bundles.is_none()
1489            && wire.network_allowlists.is_none()
1490        {
1491            return Err(serde::de::Error::custom(
1492                "launch inventory must expose at least one negotiated component",
1493            ));
1494        }
1495        let network_allowlists = match wire.network_allowlists {
1496            None => None,
1497            Some(ids) => {
1498                if ids.len() > MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1499                    return Err(serde::de::Error::custom(
1500                        "launch inventory network allowlist catalog exceeds bound",
1501                    ));
1502                }
1503                let mut seen = std::collections::BTreeSet::new();
1504                for id in &ids {
1505                    if !seen.insert(id.clone()) {
1506                        return Err(serde::de::Error::custom(
1507                            "launch inventory contains duplicate network allowlist id",
1508                        ));
1509                    }
1510                }
1511                Some(ids)
1512            }
1513        };
1514        Ok(Self {
1515            spawn_profiles: wire.spawn_profiles.map(|profiles| profiles.0),
1516            bundles: wire.bundles.map(|bundles| bundles.0),
1517            network_allowlists,
1518        })
1519    }
1520}
1521
1522struct BoundedSpawnProfiles(Vec<SpawnProfileSummary>);
1523
1524impl<'de> Deserialize<'de> for BoundedSpawnProfiles {
1525    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1526    where
1527        D: Deserializer<'de>,
1528    {
1529        struct SpawnProfilesVisitor;
1530
1531        impl<'de> Visitor<'de> for SpawnProfilesVisitor {
1532            type Value = BoundedSpawnProfiles;
1533
1534            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1535                write!(formatter, "at most {MAX_SPAWN_PROFILES} spawn profiles")
1536            }
1537
1538            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1539            where
1540                A: SeqAccess<'de>,
1541            {
1542                let mut profiles = Vec::with_capacity(
1543                    sequence.size_hint().unwrap_or(0).min(MAX_SPAWN_PROFILES),
1544                );
1545                while let Some(profile) = sequence.next_element::<SpawnProfileSummary>()? {
1546                    if profiles.len() == MAX_SPAWN_PROFILES {
1547                        return Err(serde::de::Error::invalid_length(profiles.len() + 1, &self));
1548                    }
1549                    if profiles.iter().any(|existing: &SpawnProfileSummary| existing.id == profile.id) {
1550                        return Err(serde::de::Error::custom(
1551                            "launch inventory contains duplicate spawn profile identity",
1552                        ));
1553                    }
1554                    profiles.push(profile);
1555                }
1556                Ok(BoundedSpawnProfiles(profiles))
1557            }
1558        }
1559
1560        deserializer.deserialize_seq(SpawnProfilesVisitor)
1561    }
1562}
1563
1564struct BoundedLaunchBundles(Vec<ResolvedBundleReceipt>);
1565
1566impl<'de> Deserialize<'de> for BoundedLaunchBundles {
1567    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1568    where
1569        D: Deserializer<'de>,
1570    {
1571        struct LaunchBundlesVisitor;
1572
1573        impl<'de> Visitor<'de> for LaunchBundlesVisitor {
1574            type Value = BoundedLaunchBundles;
1575
1576            fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1577                write!(formatter, "at most {MAX_LAUNCH_BUNDLES} launch bundles")
1578            }
1579
1580            fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1581            where
1582                A: SeqAccess<'de>,
1583            {
1584                let mut bundles = Vec::with_capacity(
1585                    sequence.size_hint().unwrap_or(0).min(MAX_LAUNCH_BUNDLES),
1586                );
1587                while let Some(bundle) = sequence.next_element::<ResolvedBundleReceipt>()? {
1588                    if bundles.len() == MAX_LAUNCH_BUNDLES {
1589                        return Err(serde::de::Error::invalid_length(bundles.len() + 1, &self));
1590                    }
1591                    if bundles.iter().any(|existing: &ResolvedBundleReceipt| existing.id == bundle.id) {
1592                        return Err(serde::de::Error::custom(
1593                            "launch inventory contains duplicate bundle identity",
1594                        ));
1595                    }
1596                    bundles.push(bundle);
1597                }
1598                Ok(BoundedLaunchBundles(bundles))
1599            }
1600        }
1601
1602        deserializer.deserialize_seq(LaunchBundlesVisitor)
1603    }
1604}
1605
1606fn deserialize_bounded_worktree_profiles<'de, D>(
1607    deserializer: D,
1608) -> Result<Vec<ManagedWorktreeProfileSummary>, D::Error>
1609where
1610    D: Deserializer<'de>,
1611{
1612    struct WorktreeProfilesVisitor;
1613
1614    impl<'de> Visitor<'de> for WorktreeProfilesVisitor {
1615        type Value = Vec<ManagedWorktreeProfileSummary>;
1616
1617        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1618            write!(
1619                formatter,
1620                "at most {MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE} managed worktree profiles",
1621            )
1622        }
1623
1624        fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1625        where
1626            A: SeqAccess<'de>,
1627        {
1628            let mut profiles = Vec::with_capacity(
1629                sequence
1630                    .size_hint()
1631                    .unwrap_or(0)
1632                    .min(MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE),
1633            );
1634            while let Some(profile) = sequence.next_element::<ManagedWorktreeProfileSummary>()? {
1635                if profiles.len() == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
1636                    return Err(serde::de::Error::invalid_length(profiles.len() + 1, &self));
1637                }
1638                if profiles.iter().any(|existing: &ManagedWorktreeProfileSummary| existing.id == profile.id) {
1639                    return Err(serde::de::Error::custom(
1640                        "workspace inventory contains duplicate managed worktree profile identity",
1641                    ));
1642                }
1643                profiles.push(profile);
1644            }
1645            Ok(profiles)
1646        }
1647    }
1648
1649    deserializer.deserialize_seq(WorktreeProfilesVisitor)
1650}
1651
1652#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1653#[serde(deny_unknown_fields)]
1654pub struct ContextPackLineageReceipt {
1655    pub source_node_id: NodeId,
1656    pub source_session: SessionAddress,
1657    pub source_provider: AgentId,
1658}
1659
1660#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1661#[serde(deny_unknown_fields)]
1662pub struct ResolvedContextPackReceipt {
1663    pub id: SpawnContextId,
1664    pub digest: SpawnContextDigest,
1665    pub lineage: ContextPackLineageReceipt,
1666    pub source_message_count: u64,
1667    pub retained_message_count: u64,
1668    pub byte_len: u32,
1669    pub truncated: bool,
1670}
1671
1672impl<'de> Deserialize<'de> for ResolvedContextPackReceipt {
1673    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1674    where
1675        D: Deserializer<'de>,
1676    {
1677        #[derive(Deserialize)]
1678        #[serde(deny_unknown_fields)]
1679        struct WireReceipt {
1680            id: SpawnContextId,
1681            digest: SpawnContextDigest,
1682            lineage: ContextPackLineageReceipt,
1683            source_message_count: u64,
1684            retained_message_count: u64,
1685            byte_len: u32,
1686            truncated: bool,
1687        }
1688
1689        let wire = WireReceipt::deserialize(deserializer)?;
1690        let receipt = Self {
1691            id: wire.id,
1692            digest: wire.digest,
1693            lineage: wire.lineage,
1694            source_message_count: wire.source_message_count,
1695            retained_message_count: wire.retained_message_count,
1696            byte_len: wire.byte_len,
1697            truncated: wire.truncated,
1698        };
1699        if !receipt.is_valid() {
1700            return Err(serde::de::Error::custom(
1701                "context pack receipt is empty, inconsistent, or exceeds protocol limits",
1702            ));
1703        }
1704        Ok(receipt)
1705    }
1706}
1707
1708impl ResolvedContextPackReceipt {
1709    pub fn is_valid(&self) -> bool {
1710        self.source_message_count > 0
1711            && self.retained_message_count > 0
1712            && self.retained_message_count <= self.source_message_count
1713            && self.retained_message_count <= MAX_CONTEXT_PACK_RETAINED_MESSAGES
1714            && self.byte_len > 0
1715            && self.byte_len <= MAX_CONTEXT_PACK_BYTES
1716            && self.truncated
1717                == (self.source_message_count > self.retained_message_count)
1718    }
1719}
1720
1721/// Domain-separates the context pack digest from every other SHA256 use in
1722/// the wire so a collision elsewhere can never be replayed as a valid
1723/// context pack digest.
1724const CONTEXT_PACK_DIGEST_DOMAIN: &[u8] = b"g4a-context-pack-v1\0";
1725
1726/// The one definition of the context pack digest formula: `SHA256(domain ||
1727/// JSON(lineage) || 0x00 || bytes)`, rendered `sha256:<hex>`. Both the node
1728/// (computing a pack's digest when it exports one) and the harness
1729/// (recomputing a fetched pack's digest to check it against a mailed
1730/// receipt) call this instead of keeping their own copy, so the formula can
1731/// only drift in one place.
1732pub fn context_pack_digest(lineage: &ContextPackLineageReceipt, bytes: &[u8]) -> SpawnContextDigest {
1733    let lineage_bytes = serde_json::to_vec(lineage)
1734        .expect("ContextPackLineageReceipt has no map keys and always serializes to JSON");
1735    let mut context = Context::new(&SHA256);
1736    context.update(CONTEXT_PACK_DIGEST_DOMAIN);
1737    context.update(&lineage_bytes);
1738    context.update(&[0]);
1739    context.update(bytes);
1740    let hex = context
1741        .finish()
1742        .as_ref()
1743        .iter()
1744        .map(|byte| format!("{byte:02x}"))
1745        .collect::<String>();
1746    SpawnContextDigest::new(format!("sha256:{hex}"))
1747        .expect("a freshly rendered sha256:<64 lowercase hex> string always satisfies SpawnContextDigest::new")
1748}
1749
1750/// A bare (no `sha256:` prefix) lowercase hex SHA-256 digest of `bytes`.
1751/// Used where the wire already names the hash algorithm by convention rather
1752/// than in the value itself -- `HarnessMailRefV1::WorkspacePath.sha256` and
1753/// the node's own `WorkspaceFileRevision` are both this shape (unlike the
1754/// context pack digest above, which reuses the prefixed `sha256:<hex>` form
1755/// since it must distinguish itself from any other hash the wire might one
1756/// day carry for the same object). Plain SHA-256 with no domain separation:
1757/// a workspace file's bytes are not reused as an input to any other digest
1758/// this wire computes, so there is nothing for a domain tag to separate it
1759/// from.
1760pub fn sha256_hex(bytes: &[u8]) -> String {
1761    let mut context = Context::new(&SHA256);
1762    context.update(bytes);
1763    context.finish().as_ref().iter().map(|byte| format!("{byte:02x}")).collect()
1764}
1765
1766#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
1767#[serde(deny_unknown_fields)]
1768pub struct ContextPackBytesRead {
1769    pub digest: SpawnContextDigest,
1770    pub id: SpawnContextId,
1771    pub byte_len: u32,
1772    pub bytes: Vec<u8>,
1773}
1774
1775impl<'de> Deserialize<'de> for ContextPackBytesRead {
1776    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1777    where
1778        D: Deserializer<'de>,
1779    {
1780        #[derive(Deserialize)]
1781        #[serde(deny_unknown_fields)]
1782        struct WireContextPackBytesRead {
1783            digest: SpawnContextDigest,
1784            id: SpawnContextId,
1785            byte_len: u32,
1786            bytes: Vec<u8>,
1787        }
1788
1789        let wire = WireContextPackBytesRead::deserialize(deserializer)?;
1790        let actual_bytes = wire.bytes.len();
1791        if u64::from(wire.byte_len) != actual_bytes as u64 {
1792            return Err(serde::de::Error::custom(format!(
1793                "context pack bytes declare {} bytes but contain {actual_bytes}",
1794                wire.byte_len,
1795            )));
1796        }
1797        if wire.byte_len > MAX_CONTEXT_PACK_BYTES {
1798            return Err(serde::de::Error::custom(format!(
1799                "context pack bytes length {} exceeds the {MAX_CONTEXT_PACK_BYTES}-byte limit",
1800                wire.byte_len,
1801            )));
1802        }
1803        Ok(Self {
1804            digest: wire.digest,
1805            id: wire.id,
1806            byte_len: wire.byte_len,
1807            bytes: wire.bytes,
1808        })
1809    }
1810}
1811
1812fn context_receipt_binding_is_valid(
1813    context_id: Option<&SpawnContextId>,
1814    context: Option<&ResolvedContextPackReceipt>,
1815) -> bool {
1816    match (context_id, context) {
1817        (None, None) => true,
1818        (Some(context_id), Some(context)) => &context.id == context_id && context.is_valid(),
1819        (None, Some(_)) | (Some(_), None) => false,
1820    }
1821}
1822
1823#[derive(Debug, Error)]
1824pub enum HarnessMcpContractError {
1825    #[error("invalid harness MCP reservation ID")]
1826    InvalidReservationId,
1827    #[error("invalid harness MCP call ID")]
1828    InvalidCallId,
1829    #[error("invalid harness MCP activation digest")]
1830    InvalidActivationDigest,
1831    #[error("invalid harness MCP local token")]
1832    InvalidLocalToken,
1833    #[error("invalid harness MCP reply chunk")]
1834    InvalidReplyChunk,
1835    #[error("invalid harness MCP local request")]
1836    InvalidLocalRequest,
1837    #[error("invalid harness MCP local reply")]
1838    InvalidLocalReply,
1839    #[error("invalid harness MCP launch description")]
1840    InvalidLaunch,
1841}
1842
1843fn is_exact_lower_hex(value: &str, prefix: &str, digits: usize) -> bool {
1844    value.len() == prefix.len() + digits
1845        && value.starts_with(prefix)
1846        && value[prefix.len()..]
1847            .bytes()
1848            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
1849}
1850
1851macro_rules! harness_mcp_wire_string {
1852    ($name:ident, $prefix:literal, $digits:literal, $error:ident) => {
1853        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1854        #[serde(transparent)]
1855        pub struct $name(String);
1856
1857        impl $name {
1858            pub fn new(value: impl Into<String>) -> Result<Self, HarnessMcpContractError> {
1859                let value = value.into();
1860                if !is_exact_lower_hex(&value, $prefix, $digits) {
1861                    return Err(HarnessMcpContractError::$error);
1862                }
1863                Ok(Self(value))
1864            }
1865
1866            pub fn as_str(&self) -> &str { &self.0 }
1867        }
1868
1869        impl<'de> Deserialize<'de> for $name {
1870            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1871            where D: Deserializer<'de> {
1872                Self::new(String::deserialize(deserializer)?)
1873                    .map_err(serde::de::Error::custom)
1874            }
1875        }
1876    };
1877}
1878
1879harness_mcp_wire_string!(HarnessMcpReservationId, "hmcpres_", 24, InvalidReservationId);
1880harness_mcp_wire_string!(HarnessMcpCallId, "hmcpcall_", 24, InvalidCallId);
1881harness_mcp_wire_string!(HarnessMcpActivationDigest, "sha256:", 64, InvalidActivationDigest);
1882
1883#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
1884#[serde(transparent)]
1885pub struct HarnessMcpLocalToken(String);
1886
1887impl HarnessMcpLocalToken {
1888    pub fn new(value: impl Into<String>) -> Result<Self, HarnessMcpContractError> {
1889        let value = value.into();
1890        if !is_exact_lower_hex(&value, "g4ah3_", 64) {
1891            return Err(HarnessMcpContractError::InvalidLocalToken);
1892        }
1893        Ok(Self(value))
1894    }
1895
1896    pub fn expose(&self) -> &str { &self.0 }
1897}
1898
1899impl fmt::Debug for HarnessMcpLocalToken {
1900    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1901        formatter.write_str("HarnessMcpLocalToken([REDACTED])")
1902    }
1903}
1904
1905impl<'de> Deserialize<'de> for HarnessMcpLocalToken {
1906    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1907    where D: Deserializer<'de> {
1908        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
1909    }
1910}
1911
1912/// Longest server name or environment-variable name a launch description may carry.
1913pub const MAX_HARNESS_MCP_LAUNCH_NAME_BYTES: usize = 64;
1914/// Most arguments a launch description may pass to the MCP server program.
1915pub const MAX_HARNESS_MCP_LAUNCH_ARGS: usize = 8;
1916/// Longest single argument of a launch description.
1917pub const MAX_HARNESS_MCP_LAUNCH_ARG_BYTES: usize = 128;
1918/// Most environment variables a launch description may ask the node to scrub.
1919pub const MAX_HARNESS_MCP_LAUNCH_SCRUB_ENV: usize = 8;
1920
1921/// Optional stdio trace opt-in of a harness-MCP launch: when the node process
1922/// has a non-empty environment variable named `dir_env`, the node adds an
1923/// environment entry named `env` holding a trace-file path under that directory.
1924#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1925#[serde(deny_unknown_fields)]
1926pub struct HarnessMcpLaunchTraceV1 {
1927    pub env: String,
1928    pub dir_env: String,
1929}
1930
1931/// How the node exposes a reserved harness-MCP door to the provider session it
1932/// spawns -- every name in it is the CALLER's, never the node's.
1933///
1934/// The node owns the mechanism (a per-reservation local endpoint, a token, a
1935/// reviewed helper program) and fills the three values into the environment
1936/// variables the caller names; the caller decides what the MCP server is called
1937/// in the provider's configuration, which argv the helper program gets, and
1938/// which stale variables to erase from the provider's environment.
1939#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1940#[serde(deny_unknown_fields)]
1941pub struct HarnessMcpLaunchV1 {
1942    /// Name of the MCP server entry in the provider's configuration.
1943    pub server_name: String,
1944    /// Arguments the helper program is started with.
1945    pub args: Vec<String>,
1946    /// Environment variable that receives the local endpoint path.
1947    pub endpoint_env: String,
1948    /// Environment variable that receives the local token.
1949    pub token_env: String,
1950    /// Environment variable that receives the helper program path.
1951    pub program_env: String,
1952    /// Optional trace opt-in.
1953    pub trace: Option<HarnessMcpLaunchTraceV1>,
1954    /// Environment variables erased from the provider's environment.
1955    pub scrub_env: Vec<String>,
1956}
1957
1958fn launch_env_name_is_valid(name: &str) -> bool {
1959    let mut bytes = name.bytes();
1960    name.len() <= MAX_HARNESS_MCP_LAUNCH_NAME_BYTES
1961        && bytes
1962            .next()
1963            .is_some_and(|first| first.is_ascii_uppercase() || first == b'_')
1964        && bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
1965}
1966
1967impl HarnessMcpLaunchV1 {
1968    /// Checks every bound and that the three value-receiving names (plus the
1969    /// trace name) are distinct and none of them is also scrubbed.
1970    pub fn validate(&self) -> Result<(), HarnessMcpContractError> {
1971        let server_name_ok = !self.server_name.is_empty()
1972            && self.server_name.len() <= MAX_HARNESS_MCP_LAUNCH_NAME_BYTES
1973            && self
1974                .server_name
1975                .bytes()
1976                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-');
1977        let args_ok = self.args.len() <= MAX_HARNESS_MCP_LAUNCH_ARGS
1978            && self.args.iter().all(|arg| {
1979                !arg.is_empty()
1980                    && arg.len() <= MAX_HARNESS_MCP_LAUNCH_ARG_BYTES
1981                    && !arg.chars().any(char::is_control)
1982            });
1983        let mut value_envs = vec![
1984            self.endpoint_env.as_str(),
1985            self.token_env.as_str(),
1986            self.program_env.as_str(),
1987        ];
1988        if let Some(trace) = &self.trace {
1989            if !launch_env_name_is_valid(&trace.dir_env) {
1990                return Err(HarnessMcpContractError::InvalidLaunch);
1991            }
1992            value_envs.push(trace.env.as_str());
1993        }
1994        let envs_ok = value_envs.iter().all(|name| launch_env_name_is_valid(name))
1995            && self.scrub_env.len() <= MAX_HARNESS_MCP_LAUNCH_SCRUB_ENV
1996            && self.scrub_env.iter().all(|name| launch_env_name_is_valid(name));
1997        let mut seen = std::collections::BTreeSet::new();
1998        let distinct = value_envs
1999            .iter()
2000            .copied()
2001            .chain(self.scrub_env.iter().map(String::as_str))
2002            .all(|name| seen.insert(name));
2003        if server_name_ok && args_ok && envs_ok && distinct {
2004            Ok(())
2005        } else {
2006            Err(HarnessMcpContractError::InvalidLaunch)
2007        }
2008    }
2009}
2010
2011/// Names what shape an opaque harness-MCP payload's `body` holds.
2012///
2013/// Neither `node` nor `c2` ever inspect `body` -- see `HarnessMcpOpaquePayloadV1`
2014/// -- so this tag exists only so the two real endpoints (the reviewed local
2015/// helper program, which originates a request and decodes a reply; the
2016/// harness, which decodes a request and originates a reply) can tell a
2017/// version-skewed or misdirected payload from a well-formed one, without the
2018/// carrier needing to know either shape.
2019#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2020#[serde(rename_all = "kebab-case")]
2021pub enum HarnessMcpContentTypeV1 {
2022    HarnessReadRequestJsonV1,
2023    HarnessReadResponseJsonV1,
2024}
2025
2026/// An opaque harness-MCP payload.
2027///
2028/// This is the carriage this crate's wire types actually move end to end
2029/// (`HarnessMcpLocalRequestV1::request`, `HarnessMcpLocalReplyV1::Ok::
2030/// response`, `NodeEvent::HarnessMcpReadCall::request`): `body` is whatever
2031/// bytes the originating endpoint encoded, and this crate never decodes it.
2032/// Only the reviewed local helper (`hatchery-harness-mcp` bin from
2033/// `hatchery-harness-mcp`, which holds `hatchery-harness-api` types via
2034/// `hatchery-harness-client`) and `hatchery-harness-service` (the harness
2035/// itself) know what `body` actually contains.
2036///
2037/// This is the Nested Control Plane doctrine's Law 3 made mechanical
2038/// (`docs/architecture/nested-control-plane.md`): a lower tier (`node`,
2039/// wrapped by `c2`) must never import a higher tier's (the harness's) crate.
2040/// Before this type existed, `HarnessMcpLocalRequestV1`/`HarnessMcpLocalReplyV1`
2041/// carried the harness's own `HarnessReadRequestV1`/`HarnessReadResponseV1`
2042/// typed, which pulled `hatchery-harness-api` into this crate's dependency
2043/// graph and, through it, into every crate that re-exports this one --
2044/// `node`, `node-wire`, `c2`, `c2-client`. A relay that
2045/// cannot read what it relays is a relay done right.
2046#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2047#[serde(deny_unknown_fields)]
2048pub struct HarnessMcpOpaquePayloadV1 {
2049    pub content_type: HarnessMcpContentTypeV1,
2050    pub body: Vec<u8>,
2051}
2052
2053#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2054#[serde(deny_unknown_fields)]
2055pub struct HarnessMcpLocalRequestV1 {
2056    pub version: u16,
2057    pub token: HarnessMcpLocalToken,
2058    pub request: HarnessMcpOpaquePayloadV1,
2059}
2060
2061impl HarnessMcpLocalRequestV1 {
2062    pub fn validate(&self) -> Result<(), HarnessMcpContractError> {
2063        if self.version != 1
2064            || serde_json::to_vec(self)
2065                .map_or(true, |wire| wire.len() > MAX_HARNESS_MCP_LOCAL_REQUEST_BYTES)
2066        {
2067            return Err(HarnessMcpContractError::InvalidLocalRequest);
2068        }
2069        Ok(())
2070    }
2071}
2072
2073/// The proxy's terminal reply to a local session's harness-MCP call.
2074///
2075/// `Ok`'s `response` is opaque (see `HarnessMcpOpaquePayloadV1`) -- only the
2076/// harness that computed it and the local helper program that decodes it
2077/// know its shape. `Rejected` carries this crate's own coarse reason, used
2078/// uniformly whether the node decided it locally (an unmatched local token,
2079/// a caller that is not a descendant of the provider process, a deadline
2080/// that expired waiting on the harness) or the harness decided it and
2081/// relayed the reason back over `NodeRequest::RejectHarnessMcpCall` -- in
2082/// neither case does the harness's own richer `HarnessReadHostErrorV1` ever
2083/// need to cross into this crate.
2084#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2085#[serde(tag = "status", rename_all = "kebab-case", deny_unknown_fields)]
2086pub enum HarnessMcpLocalReplyV1 {
2087    Ok { response: HarnessMcpOpaquePayloadV1 },
2088    Rejected { reason: HarnessMcpRejectReasonV1 },
2089}
2090
2091impl HarnessMcpLocalReplyV1 {
2092    pub fn validate(&self) -> Result<(), HarnessMcpContractError> {
2093        if serde_json::to_vec(self)
2094            .map_or(true, |wire| wire.len() > MAX_HARNESS_MCP_AGGREGATE_REPLY_BYTES)
2095        {
2096            return Err(HarnessMcpContractError::InvalidLocalReply);
2097        }
2098        Ok(())
2099    }
2100}
2101
2102#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
2103#[serde(transparent)]
2104pub struct HarnessMcpReplyChunkHexV1(String);
2105
2106impl HarnessMcpReplyChunkHexV1 {
2107    pub fn new(value: impl Into<String>) -> Result<Self, HarnessMcpContractError> {
2108        let value = value.into();
2109        if value.len() > MAX_HARNESS_MCP_REPLY_CHUNK_RAW_BYTES * 2
2110            || value.len() % 2 != 0
2111            || !value.bytes().all(|byte| {
2112                byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
2113            })
2114        {
2115            return Err(HarnessMcpContractError::InvalidReplyChunk);
2116        }
2117        Ok(Self(value))
2118    }
2119
2120    pub fn as_str(&self) -> &str { &self.0 }
2121    pub fn raw_len(&self) -> usize { self.0.len() / 2 }
2122}
2123
2124impl<'de> Deserialize<'de> for HarnessMcpReplyChunkHexV1 {
2125    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2126    where D: Deserializer<'de> {
2127        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
2128    }
2129}
2130
2131#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2132#[serde(rename_all = "kebab-case")]
2133pub enum HarnessMcpRejectReasonV1 {
2134    Unauthorized,
2135    Unavailable,
2136    InvalidRequest,
2137    NotFoundOrDenied,
2138    ResponseTooLarge,
2139    Deadline,
2140    Internal,
2141}
2142
2143#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2144#[serde(deny_unknown_fields)]
2145pub struct ResolvedHarnessMcpProxyReceiptV1 {
2146    pub reservation_id: HarnessMcpReservationId,
2147    pub activation_digest: HarnessMcpActivationDigest,
2148}
2149
2150#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
2151#[serde(deny_unknown_fields)]
2152pub struct ResolvedSpawnReceipt {
2153    pub incarnation_id: NodeIncarnationId,
2154    pub session: SessionAddress,
2155    pub target: SpawnTarget,
2156    pub profile_id: SpawnProfileId,
2157    pub profile_revision: SpawnProfileRevision,
2158    pub provider: AgentId,
2159    pub mode: SessionMode,
2160    pub terminal_size: TerminalSize,
2161    pub prompt: SpawnPromptMetadata,
2162    pub bundle_id: Option<SpawnBundleId>,
2163    #[serde(default, skip_serializing_if = "Option::is_none")]
2164    pub bundle: Option<ResolvedBundleReceipt>,
2165    pub context_id: Option<SpawnContextId>,
2166    #[serde(default, skip_serializing_if = "Option::is_none")]
2167    pub context: Option<ResolvedContextPackReceipt>,
2168    #[serde(rename = "environment_profile_id")]
2169    pub environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
2170    pub deadline_ms: SpawnDeadlineMs,
2171    pub idempotency_key: SpawnIdempotencyKey,
2172    pub required_capabilities: SpawnRequiredCapabilities,
2173    pub provenance: SpawnResolutionProvenance,
2174    #[serde(default, skip_serializing_if = "Option::is_none")]
2175    pub harness_mcp_proxy: Option<ResolvedHarnessMcpProxyReceiptV1>,
2176}
2177
2178impl ResolvedSpawnReceipt {
2179    pub fn context_binding_is_valid(&self) -> bool {
2180        context_receipt_binding_is_valid(self.context_id.as_ref(), self.context.as_ref())
2181    }
2182}
2183
2184impl<'de> Deserialize<'de> for ResolvedSpawnReceipt {
2185    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2186    where
2187        D: Deserializer<'de>,
2188    {
2189        #[derive(Deserialize)]
2190        #[serde(deny_unknown_fields)]
2191        struct WireReceipt {
2192            incarnation_id: NodeIncarnationId,
2193            session: SessionAddress,
2194            target: SpawnTarget,
2195            profile_id: SpawnProfileId,
2196            profile_revision: SpawnProfileRevision,
2197            provider: AgentId,
2198            mode: SessionMode,
2199            terminal_size: TerminalSize,
2200            prompt: SpawnPromptMetadata,
2201            bundle_id: Option<SpawnBundleId>,
2202            #[serde(default)]
2203            bundle: Option<ResolvedBundleReceipt>,
2204            context_id: Option<SpawnContextId>,
2205            #[serde(default)]
2206            context: Option<ResolvedContextPackReceipt>,
2207            #[serde(rename = "environment_profile_id")]
2208            environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
2209            deadline_ms: SpawnDeadlineMs,
2210            idempotency_key: SpawnIdempotencyKey,
2211            required_capabilities: SpawnRequiredCapabilities,
2212            provenance: SpawnResolutionProvenance,
2213            #[serde(default)]
2214            harness_mcp_proxy: Option<ResolvedHarnessMcpProxyReceiptV1>,
2215        }
2216
2217        let wire = WireReceipt::deserialize(deserializer)?;
2218        let receipt = Self {
2219            incarnation_id: wire.incarnation_id,
2220            session: wire.session,
2221            target: wire.target,
2222            profile_id: wire.profile_id,
2223            profile_revision: wire.profile_revision,
2224            provider: wire.provider,
2225            mode: wire.mode,
2226            terminal_size: wire.terminal_size,
2227            prompt: wire.prompt,
2228            bundle_id: wire.bundle_id,
2229            bundle: wire.bundle,
2230            context_id: wire.context_id,
2231            context: wire.context,
2232            environment_profile: wire.environment_profile,
2233            deadline_ms: wire.deadline_ms,
2234            idempotency_key: wire.idempotency_key,
2235            required_capabilities: wire.required_capabilities,
2236            provenance: wire.provenance,
2237            harness_mcp_proxy: wire.harness_mcp_proxy,
2238        };
2239        if !receipt.context_binding_is_valid() {
2240            return Err(serde::de::Error::custom(
2241                "spawn receipt context id and materialization receipt are not correlated",
2242            ));
2243        }
2244        Ok(receipt)
2245    }
2246}
2247
2248impl SpawnSpec {
2249    pub fn resolve(
2250        &self,
2251        defaults: &SpawnProfileDefaults,
2252    ) -> Result<ResolvedSpawnSpec, SpawnSpecResolveError> {
2253        if self.profile_id != defaults.profile_id {
2254            return Err(SpawnSpecResolveError::ProfileMismatch {
2255                requested: self.profile_id.clone(),
2256                loaded: defaults.profile_id.clone(),
2257            });
2258        }
2259        if self.expected_profile_revision != defaults.revision {
2260            return Err(SpawnSpecResolveError::ProfileRevisionMismatch {
2261                expected: self.expected_profile_revision.clone(),
2262                loaded: defaults.revision.clone(),
2263            });
2264        }
2265        let (provider, provider_source) = resolve_required_spawn_field(
2266            "provider",
2267            &defaults.provider,
2268            &self.overrides.provider,
2269        )?;
2270        let (mode, mode_source) = resolve_required_spawn_field(
2271            "mode",
2272            &defaults.mode,
2273            &self.overrides.mode,
2274        )?;
2275        let (terminal_size, terminal_size_source) = resolve_required_spawn_field(
2276            "terminal_size",
2277            &defaults.terminal_size,
2278            &self.overrides.terminal_size,
2279        )?;
2280        if !terminal_size.is_valid() {
2281            return Err(SpawnSpecResolveError::InvalidTerminalSize);
2282        }
2283        let (prompt, prompt_source) = resolve_optional_spawn_field(
2284            &defaults.prompt,
2285            &self.overrides.prompt,
2286        );
2287        let (bundle_id, bundle_source) = resolve_optional_spawn_field(
2288            &defaults.bundle_id,
2289            &self.overrides.bundle_id,
2290        );
2291        let (context_id, context_source) = resolve_optional_spawn_field(
2292            &defaults.context_id,
2293            &self.overrides.context_id,
2294        );
2295        let (environment_profile_id, environment_profile_source) =
2296            resolve_optional_spawn_field(
2297                &defaults.environment_profile_id,
2298                &self.overrides.environment_profile_id,
2299            );
2300        Ok(ResolvedSpawnSpec {
2301            target: self.target.clone(),
2302            profile_id: self.profile_id.clone(),
2303            profile_revision: self.expected_profile_revision.clone(),
2304            provider,
2305            mode,
2306            terminal_size,
2307            prompt,
2308            bundle_id,
2309            context_id,
2310            environment_profile_id,
2311            deadline_ms: self.deadline_ms,
2312            idempotency_key: self.idempotency_key.clone(),
2313            required_capabilities: self.required_capabilities.clone(),
2314            provenance: SpawnResolutionProvenance {
2315                provider: provider_source,
2316                mode: mode_source,
2317                terminal_size: terminal_size_source,
2318                prompt: prompt_source,
2319                bundle_id: bundle_source,
2320                context_id: context_source,
2321                environment_profile_id: environment_profile_source,
2322            },
2323            approval_level: self.overrides.approval_level.unwrap_or_default(),
2324            network_allowlist: self.overrides.network_allowlist.clone(),
2325            browser_profile_id: self.overrides.browser_profile_id.clone(),
2326        })
2327    }
2328}
2329
2330impl ResolvedSpawnSpec {
2331    pub fn receipt(
2332        &self,
2333        incarnation_id: NodeIncarnationId,
2334        session: SessionAddress,
2335    ) -> ResolvedSpawnReceipt {
2336        self.receipt_with_materialization(incarnation_id, session, None, None, None)
2337    }
2338
2339    pub fn receipt_with_environment(
2340        &self,
2341        incarnation_id: NodeIncarnationId,
2342        session: SessionAddress,
2343        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
2344    ) -> ResolvedSpawnReceipt {
2345        self.receipt_with_materialization(
2346            incarnation_id,
2347            session,
2348            environment_profile,
2349            None,
2350            None,
2351        )
2352    }
2353
2354    pub fn receipt_with_materialization(
2355        &self,
2356        incarnation_id: NodeIncarnationId,
2357        session: SessionAddress,
2358        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
2359        bundle: Option<ResolvedBundleReceipt>,
2360        context: Option<ResolvedContextPackReceipt>,
2361    ) -> ResolvedSpawnReceipt {
2362        ResolvedSpawnReceipt {
2363            incarnation_id,
2364            session,
2365            target: self.target.clone(),
2366            profile_id: self.profile_id.clone(),
2367            profile_revision: self.profile_revision.clone(),
2368            provider: self.provider.clone(),
2369            mode: self.mode,
2370            terminal_size: self.terminal_size,
2371            prompt: SpawnPromptMetadata::from_prompt(self.prompt.as_ref()),
2372            bundle_id: self.bundle_id.clone(),
2373            bundle,
2374            context_id: self.context_id.clone(),
2375            context,
2376            environment_profile,
2377            deadline_ms: self.deadline_ms,
2378            idempotency_key: self.idempotency_key.clone(),
2379            required_capabilities: self.required_capabilities.clone(),
2380            provenance: self.provenance.clone(),
2381            harness_mcp_proxy: None,
2382        }
2383    }
2384}
2385
2386fn resolve_required_spawn_field<T: Clone>(
2387    field: &'static str,
2388    default: &T,
2389    override_value: &SpawnOverride<T>,
2390) -> Result<(T, SpawnFieldProvenance), SpawnSpecResolveError> {
2391    match override_value {
2392        SpawnOverride::Inherit => Ok((default.clone(), SpawnFieldProvenance::Profile)),
2393        SpawnOverride::Set { value } => Ok((value.clone(), SpawnFieldProvenance::Override)),
2394        SpawnOverride::Clear => Err(SpawnSpecResolveError::RequiredFieldCleared { field }),
2395    }
2396}
2397
2398fn resolve_optional_spawn_field<T: Clone>(
2399    default: &Option<T>,
2400    override_value: &SpawnOverride<T>,
2401) -> (Option<T>, SpawnFieldProvenance) {
2402    match override_value {
2403        SpawnOverride::Inherit => (default.clone(), SpawnFieldProvenance::Profile),
2404        SpawnOverride::Set { value } => {
2405            (Some(value.clone()), SpawnFieldProvenance::Override)
2406        }
2407        SpawnOverride::Clear => (None, SpawnFieldProvenance::Cleared),
2408    }
2409}
2410
2411#[derive(Clone, Debug, Eq, Error, PartialEq)]
2412pub enum SpawnSpecResolveError {
2413    #[error("spawn profile {requested} does not match loaded profile {loaded}")]
2414    ProfileMismatch {
2415        requested: SpawnProfileId,
2416        loaded: SpawnProfileId,
2417    },
2418    #[error("expected spawn profile revision {expected} does not match loaded revision {loaded}")]
2419    ProfileRevisionMismatch {
2420        expected: SpawnProfileRevision,
2421        loaded: SpawnProfileRevision,
2422    },
2423    #[error("required spawn field {field} cannot be cleared")]
2424    RequiredFieldCleared { field: &'static str },
2425    #[error("resolved spawn terminal size is invalid")]
2426    InvalidTerminalSize,
2427}
2428
2429#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2430pub struct NodeId(String);
2431
2432#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2433pub struct WorkspaceId(String);
2434
2435#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2436pub struct SessionRecordId(String);
2437
2438#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
2439#[serde(transparent)]
2440pub struct TaskId(String);
2441
2442impl TaskId {
2443    pub fn new(value: impl Into<String>) -> Result<Self, TaskIdError> {
2444        let value = value.into();
2445        let hex = value.strip_prefix("task-").ok_or(TaskIdError)?;
2446        if hex.len() != TASK_ID_NONCE_BYTES * 2
2447            || !hex.bytes().all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
2448        {
2449            return Err(TaskIdError);
2450        }
2451        Ok(Self(value))
2452    }
2453
2454    pub fn from_nonce(nonce: [u8; TASK_ID_NONCE_BYTES]) -> Self {
2455        let mut value = String::with_capacity(5 + TASK_ID_NONCE_BYTES * 2);
2456        value.push_str("task-");
2457        for byte in nonce {
2458            use std::fmt::Write as _;
2459            write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
2460        }
2461        Self(value)
2462    }
2463
2464    pub fn as_str(&self) -> &str {
2465        &self.0
2466    }
2467}
2468
2469impl AsRef<str> for TaskId {
2470    fn as_ref(&self) -> &str {
2471        self.as_str()
2472    }
2473}
2474
2475impl Borrow<str> for TaskId {
2476    fn borrow(&self) -> &str {
2477        self.as_str()
2478    }
2479}
2480
2481impl fmt::Display for TaskId {
2482    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2483        formatter.write_str(self.as_str())
2484    }
2485}
2486
2487impl FromStr for TaskId {
2488    type Err = TaskIdError;
2489
2490    fn from_str(value: &str) -> Result<Self, Self::Err> {
2491        Self::new(value)
2492    }
2493}
2494
2495impl<'de> Deserialize<'de> for TaskId {
2496    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2497    where
2498        D: Deserializer<'de>,
2499    {
2500        String::deserialize(deserializer)?
2501            .parse()
2502            .map_err(serde::de::Error::custom)
2503    }
2504}
2505
2506#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
2507#[error("task ID must be exactly `task-` followed by 24 lowercase hexadecimal characters")]
2508pub struct TaskIdError;
2509
2510#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2511pub struct NodeIncarnationId([u8; NODE_INCARNATION_ID_BYTES]);
2512
2513impl NodeIncarnationId {
2514    pub fn from_bytes(bytes: [u8; NODE_INCARNATION_ID_BYTES]) -> Self {
2515        Self(bytes)
2516    }
2517
2518    pub fn as_bytes(&self) -> &[u8; NODE_INCARNATION_ID_BYTES] {
2519        &self.0
2520    }
2521}
2522
2523impl fmt::Display for NodeIncarnationId {
2524    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2525        for byte in self.0 {
2526            write!(formatter, "{byte:02x}")?;
2527        }
2528        Ok(())
2529    }
2530}
2531
2532impl FromStr for NodeIncarnationId {
2533    type Err = NodeIncarnationIdError;
2534
2535    fn from_str(value: &str) -> Result<Self, Self::Err> {
2536        if value.len() != NODE_INCARNATION_ID_BYTES * 2 {
2537            return Err(NodeIncarnationIdError::InvalidLength {
2538                len: value.len(),
2539                expected: NODE_INCARNATION_ID_BYTES * 2,
2540            });
2541        }
2542        if !value.bytes().all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) {
2543            return Err(NodeIncarnationIdError::InvalidHex(value.to_owned()));
2544        }
2545        let mut bytes = [0; NODE_INCARNATION_ID_BYTES];
2546        for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
2547            bytes[index] = (decode_lower_hex(pair[0]) << 4) | decode_lower_hex(pair[1]);
2548        }
2549        Ok(Self(bytes))
2550    }
2551}
2552
2553impl Serialize for NodeIncarnationId {
2554    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2555    where
2556        S: Serializer,
2557    {
2558        serializer.collect_str(self)
2559    }
2560}
2561
2562impl<'de> Deserialize<'de> for NodeIncarnationId {
2563    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2564    where
2565        D: Deserializer<'de>,
2566    {
2567        let value = String::deserialize(deserializer)?;
2568        value.parse().map_err(serde::de::Error::custom)
2569    }
2570}
2571
2572fn decode_lower_hex(byte: u8) -> u8 {
2573    match byte {
2574        b'0'..=b'9' => byte - b'0',
2575        b'a'..=b'f' => byte - b'a' + 10,
2576        _ => unreachable!("lowercase hexadecimal input was validated"),
2577    }
2578}
2579
2580#[derive(Clone, Debug, Eq, Error, PartialEq)]
2581pub enum NodeIncarnationIdError {
2582    #[error("node incarnation ID length {len} does not match the required {expected} lowercase hexadecimal characters")]
2583    InvalidLength { len: usize, expected: usize },
2584    #[error("node incarnation ID must contain exactly 32 lowercase hexadecimal characters: {0}")]
2585    InvalidHex(String),
2586}
2587
2588macro_rules! identifier_impl {
2589    ($type:ident, $label:literal) => {
2590        impl $type {
2591            pub fn new(value: impl Into<String>) -> Result<Self, NodeIdentifierError> {
2592                let value = value.into();
2593                validate_identifier($label, &value)?;
2594                Ok(Self(value))
2595            }
2596
2597            pub fn as_str(&self) -> &str {
2598                &self.0
2599            }
2600        }
2601
2602        impl AsRef<str> for $type {
2603            fn as_ref(&self) -> &str {
2604                self.as_str()
2605            }
2606        }
2607
2608        impl Borrow<str> for $type {
2609            fn borrow(&self) -> &str {
2610                self.as_str()
2611            }
2612        }
2613
2614        impl fmt::Display for $type {
2615            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2616                formatter.write_str(self.as_str())
2617            }
2618        }
2619
2620        impl FromStr for $type {
2621            type Err = NodeIdentifierError;
2622
2623            fn from_str(value: &str) -> Result<Self, Self::Err> {
2624                Self::new(value)
2625            }
2626        }
2627
2628        impl Serialize for $type {
2629            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2630            where
2631                S: Serializer,
2632            {
2633                serializer.serialize_str(self.as_str())
2634            }
2635        }
2636
2637        impl<'de> Deserialize<'de> for $type {
2638            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2639            where
2640                D: Deserializer<'de>,
2641            {
2642                let value = String::deserialize(deserializer)?;
2643                Self::new(value).map_err(serde::de::Error::custom)
2644            }
2645        }
2646    };
2647}
2648
2649identifier_impl!(NodeId, "node");
2650identifier_impl!(WorkspaceId, "workspace");
2651identifier_impl!(SessionRecordId, "session record");
2652
2653#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
2654#[serde(deny_unknown_fields)]
2655pub struct NativeSessionCatalogRoute {
2656    pub scope: NativeSessionCatalogScope,
2657    #[serde(default, skip_serializing_if = "Option::is_none")]
2658    pub workspace_id: Option<WorkspaceId>,
2659    pub provider: AgentId,
2660}
2661
2662impl NativeSessionCatalogRoute {
2663    pub fn workspace(workspace_id: WorkspaceId, provider: AgentId) -> Self {
2664        Self {
2665            scope: NativeSessionCatalogScope::Workspace,
2666            workspace_id: Some(workspace_id),
2667            provider,
2668        }
2669    }
2670
2671    pub fn unregistered(provider: AgentId) -> Self {
2672        Self {
2673            scope: NativeSessionCatalogScope::Unregistered,
2674            workspace_id: None,
2675            provider,
2676        }
2677    }
2678
2679    pub fn validate(&self) -> Result<(), &'static str> {
2680        match (self.scope, self.workspace_id.as_ref()) {
2681            (NativeSessionCatalogScope::Workspace, Some(_))
2682            | (NativeSessionCatalogScope::Unregistered, None) => Ok(()),
2683            _ => Err("native session catalog route scope and workspace do not match"),
2684        }
2685    }
2686}
2687
2688#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
2689#[serde(deny_unknown_fields)]
2690pub struct NativeSessionSelection {
2691    pub route: NativeSessionCatalogRoute,
2692    pub catalog_revision: u64,
2693    pub recent_cutoff_unix_ms: u64,
2694    #[serde(deserialize_with = "deserialize_history_candidate_id")]
2695    pub selection_id: String,
2696}
2697
2698impl NativeSessionSelection {
2699    pub fn validate(&self) -> Result<(), &'static str> {
2700        self.route.validate()?;
2701        if self.catalog_revision == 0 {
2702            return Err("native session selection catalog revision is invalid");
2703        }
2704        gate4agent_types::validate_candidate_id(&self.selection_id)
2705            .map_err(|_| "native session selection ID is invalid")
2706    }
2707}
2708
2709#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2710#[serde(deny_unknown_fields)]
2711pub struct NativeSessionCatalogEntry {
2712    pub selection_id: String,
2713    pub title: Option<String>,
2714    pub modified_at_unix_ms: Option<u64>,
2715    pub model: Option<String>,
2716    pub message_count: u64,
2717    pub completed_turn_count: Option<u64>,
2718    #[serde(default, skip_serializing_if = "Option::is_none")]
2719    pub external_group: Option<NativeSessionExternalGroup>,
2720    #[serde(default, skip_serializing_if = "Option::is_none")]
2721    pub record_id: Option<SessionRecordId>,
2722}
2723
2724impl NativeSessionCatalogEntry {
2725    pub fn validate(&self) -> Result<(), gate4agent_types::HistoryValidationError> {
2726        gate4agent_types::NativeSessionCatalogEntry {
2727            selection_id: self.selection_id.clone(),
2728            session_id: "redacted-provider-session".to_owned(),
2729            title: self.title.clone(),
2730            modified_at_unix_ms: self.modified_at_unix_ms,
2731            model: self.model.clone(),
2732            message_count: self.message_count,
2733            completed_turn_count: self.completed_turn_count,
2734        }
2735        .validate()?;
2736        if let Some(group) = self.external_group.as_ref() {
2737            group.validate()?;
2738        }
2739        Ok(())
2740    }
2741
2742    pub fn validate_for_route(
2743        &self,
2744        route: &NativeSessionCatalogRoute,
2745    ) -> Result<(), &'static str> {
2746        self.validate()
2747            .map_err(|_| "native session catalog entry is invalid")?;
2748        match route.scope {
2749            NativeSessionCatalogScope::Workspace if self.external_group.is_none() => Ok(()),
2750            NativeSessionCatalogScope::Unregistered
2751                if self.external_group.is_some() && self.record_id.is_none() => Ok(()),
2752            NativeSessionCatalogScope::Workspace => {
2753                Err("workspace native session entry contains an external group")
2754            }
2755            NativeSessionCatalogScope::Unregistered => {
2756                Err("unregistered native session entry is missing its group or exposes a record")
2757            }
2758        }
2759    }
2760}
2761
2762#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2763#[serde(deny_unknown_fields)]
2764pub struct NativeSessionCatalogPage {
2765    pub window: NativeSessionCatalogWindow,
2766    pub revision: u64,
2767    pub entries: Vec<NativeSessionCatalogEntry>,
2768    pub next_after_selection_id: Option<String>,
2769    pub remaining_count: u32,
2770    pub has_more: bool,
2771}
2772
2773impl NativeSessionCatalogPage {
2774    pub fn validate(&self) -> Result<(), &'static str> {
2775        if self.entries.len() > usize::from(NATIVE_SESSION_CATALOG_LIMIT_MAX) {
2776            return Err("native session catalog page exceeds the bounded entry limit");
2777        }
2778        for (index, entry) in self.entries.iter().enumerate() {
2779            entry
2780                .validate()
2781                .map_err(|_| "native session catalog entry is invalid")?;
2782            if self.entries[..index]
2783                .iter()
2784                .any(|existing| existing.selection_id == entry.selection_id)
2785            {
2786                return Err("native session catalog page contains duplicate selections");
2787            }
2788            if entry.record_id.as_ref().is_some_and(|record_id| {
2789                self.entries[..index]
2790                    .iter()
2791                    .any(|existing| existing.record_id.as_ref() == Some(record_id))
2792            }) {
2793                return Err("native session catalog page contains duplicate managed records");
2794            }
2795        }
2796        if self
2797            .next_after_selection_id
2798            .as_deref()
2799            .is_some_and(|cursor| gate4agent_types::validate_candidate_id(cursor).is_err())
2800            || self.has_more != self.next_after_selection_id.is_some()
2801            || self.has_more != (self.remaining_count > 0)
2802        {
2803            return Err("native session catalog page cursor is invalid");
2804        }
2805        Ok(())
2806    }
2807
2808    pub fn validate_for_route(
2809        &self,
2810        route: &NativeSessionCatalogRoute,
2811    ) -> Result<(), &'static str> {
2812        route.validate()?;
2813        self.validate()?;
2814        for entry in &self.entries {
2815            entry.validate_for_route(route)?;
2816        }
2817        Ok(())
2818    }
2819}
2820
2821pub type NativeSessionPreview = SessionRecordPreview;
2822
2823fn validate_identifier(label: &'static str, value: &str) -> Result<(), NodeIdentifierError> {
2824    if value.is_empty() {
2825        return Err(NodeIdentifierError::Empty { label });
2826    }
2827    if value.len() > MAX_NODE_IDENTIFIER_BYTES {
2828        return Err(NodeIdentifierError::TooLong {
2829            label,
2830            len: value.len(),
2831            max: MAX_NODE_IDENTIFIER_BYTES,
2832        });
2833    }
2834    if !value.bytes().all(|byte| {
2835        byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_')
2836    }) {
2837        return Err(NodeIdentifierError::InvalidCharacters {
2838            label,
2839            value: value.to_owned(),
2840        });
2841    }
2842    if matches!(value.as_bytes().first(), Some(b'-' | b'_'))
2843        || matches!(value.as_bytes().last(), Some(b'-' | b'_'))
2844    {
2845        return Err(NodeIdentifierError::InvalidBoundary {
2846            label,
2847            value: value.to_owned(),
2848        });
2849    }
2850    Ok(())
2851}
2852
2853#[derive(Clone, Debug, Eq, Error, PartialEq)]
2854pub enum NodeIdentifierError {
2855    #[error("{label} ID cannot be empty")]
2856    Empty { label: &'static str },
2857    #[error("{label} ID length {len} exceeds the {max}-byte limit")]
2858    TooLong {
2859        label: &'static str,
2860        len: usize,
2861        max: usize,
2862    },
2863    #[error("{label} ID must contain only lowercase ASCII letters, digits, '-' or '_': {value}")]
2864    InvalidCharacters { label: &'static str, value: String },
2865    #[error("{label} ID cannot start or end with '-' or '_': {value}")]
2866    InvalidBoundary { label: &'static str, value: String },
2867}
2868
2869#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
2870pub struct ProtocolRange {
2871    minimum: u16,
2872    maximum: u16,
2873}
2874
2875impl ProtocolRange {
2876    pub fn new(minimum: u16, maximum: u16) -> Result<Self, ProtocolNegotiationError> {
2877        if minimum == 0 || maximum == 0 || minimum > maximum {
2878            return Err(ProtocolNegotiationError::InvalidRange { minimum, maximum });
2879        }
2880        Ok(Self { minimum, maximum })
2881    }
2882
2883    pub fn exact(version: u16) -> Result<Self, ProtocolNegotiationError> {
2884        Self::new(version, version)
2885    }
2886
2887    pub fn minimum(self) -> u16 {
2888        self.minimum
2889    }
2890
2891    pub fn maximum(self) -> u16 {
2892        self.maximum
2893    }
2894
2895    pub fn contains(self, version: u16) -> bool {
2896        self.minimum <= version && version <= self.maximum
2897    }
2898
2899    pub fn highest_common(self, other: Self) -> Result<u16, ProtocolNegotiationError> {
2900        let minimum = self.minimum.max(other.minimum);
2901        let maximum = self.maximum.min(other.maximum);
2902        if minimum > maximum {
2903            return Err(ProtocolNegotiationError::Disjoint {
2904                local: self,
2905                remote: other,
2906            });
2907        }
2908        Ok(maximum)
2909    }
2910}
2911
2912impl<'de> Deserialize<'de> for ProtocolRange {
2913    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2914    where
2915        D: Deserializer<'de>,
2916    {
2917        #[derive(Deserialize)]
2918        struct WireRange {
2919            minimum: u16,
2920            maximum: u16,
2921        }
2922
2923        let wire = WireRange::deserialize(deserializer)?;
2924        Self::new(wire.minimum, wire.maximum).map_err(serde::de::Error::custom)
2925    }
2926}
2927
2928#[derive(Clone, Debug, Eq, Error, PartialEq)]
2929pub enum ProtocolNegotiationError {
2930    #[error("protocol range {minimum}..={maximum} is invalid")]
2931    InvalidRange { minimum: u16, maximum: u16 },
2932    #[error("protocol ranges {local:?} and {remote:?} do not overlap")]
2933    Disjoint {
2934        local: ProtocolRange,
2935        remote: ProtocolRange,
2936    },
2937    #[error("build stamp mismatch: local={local} remote={remote}")]
2938    BuildStampMismatch { local: String, remote: String },
2939    #[error("provider contract manifest is invalid: {0}")]
2940    InvalidProviderContractManifest(ProviderContractManifestError),
2941}
2942
2943#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2944pub struct CapabilityId(String);
2945
2946#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2947pub struct OperatingSystemId(String);
2948
2949#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2950pub struct ArchitectureId(String);
2951
2952#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2953pub struct ProviderContractRevision(String);
2954
2955#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
2956pub struct AdapterContractRevision(String);
2957
2958macro_rules! compatibility_identifier_impl {
2959    ($type:ident, $label:literal) => {
2960        impl $type {
2961            pub fn new(value: impl Into<String>) -> Result<Self, CompatibilityIdentifierError> {
2962                let value = value.into();
2963                validate_compatibility_identifier($label, &value)?;
2964                Ok(Self(value))
2965            }
2966
2967            pub fn as_str(&self) -> &str {
2968                &self.0
2969            }
2970        }
2971
2972        impl fmt::Display for $type {
2973            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2974                formatter.write_str(self.as_str())
2975            }
2976        }
2977
2978        impl FromStr for $type {
2979            type Err = CompatibilityIdentifierError;
2980
2981            fn from_str(value: &str) -> Result<Self, Self::Err> {
2982                Self::new(value)
2983            }
2984        }
2985
2986        impl Serialize for $type {
2987            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2988            where
2989                S: Serializer,
2990            {
2991                serializer.serialize_str(self.as_str())
2992            }
2993        }
2994
2995        impl<'de> Deserialize<'de> for $type {
2996            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2997            where
2998                D: Deserializer<'de>,
2999            {
3000                let value = String::deserialize(deserializer)?;
3001                Self::new(value).map_err(serde::de::Error::custom)
3002            }
3003        }
3004    };
3005}
3006
3007compatibility_identifier_impl!(CapabilityId, "capability");
3008compatibility_identifier_impl!(OperatingSystemId, "operating system");
3009compatibility_identifier_impl!(ArchitectureId, "architecture");
3010compatibility_identifier_impl!(ProviderRuntimeContractId, "provider runtime contract");
3011
3012impl ProviderRuntimeVersion {
3013    pub fn new(value: impl Into<String>) -> Result<Self, CompatibilityIdentifierError> {
3014        let value = value.into();
3015        if value.is_empty() {
3016            return Err(CompatibilityIdentifierError::Empty {
3017                label: "provider runtime version",
3018            });
3019        }
3020        if value.len() > MAX_PROVIDER_RUNTIME_VERSION_BYTES {
3021            return Err(CompatibilityIdentifierError::TooLong {
3022                label: "provider runtime version",
3023                len: value.len(),
3024                max: MAX_PROVIDER_RUNTIME_VERSION_BYTES,
3025            });
3026        }
3027        let mut components = value.split('.');
3028        let valid_component = |component: &str| {
3029            !component.is_empty()
3030                && component.bytes().all(|byte| byte.is_ascii_digit())
3031                && (component.len() == 1 || !component.starts_with('0'))
3032                && component.parse::<u64>().is_ok()
3033        };
3034        if !components.by_ref().take(3).all(valid_component) || components.next().is_some() {
3035            return Err(CompatibilityIdentifierError::InvalidCharacters {
3036                label: "provider runtime version",
3037                value,
3038            });
3039        }
3040        let component_count = value.bytes().filter(|byte| *byte == b'.').count() + 1;
3041        if component_count != 3 {
3042            return Err(CompatibilityIdentifierError::InvalidCharacters {
3043                label: "provider runtime version",
3044                value,
3045            });
3046        }
3047        Ok(Self(value))
3048    }
3049
3050    pub fn as_str(&self) -> &str {
3051        &self.0
3052    }
3053}
3054
3055impl fmt::Display for ProviderRuntimeVersion {
3056    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3057        formatter.write_str(self.as_str())
3058    }
3059}
3060
3061impl FromStr for ProviderRuntimeVersion {
3062    type Err = CompatibilityIdentifierError;
3063
3064    fn from_str(value: &str) -> Result<Self, Self::Err> {
3065        Self::new(value)
3066    }
3067}
3068
3069impl Serialize for ProviderRuntimeVersion {
3070    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
3071    where
3072        S: Serializer,
3073    {
3074        serializer.serialize_str(self.as_str())
3075    }
3076}
3077
3078impl<'de> Deserialize<'de> for ProviderRuntimeVersion {
3079    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3080    where
3081        D: Deserializer<'de>,
3082    {
3083        let value = String::deserialize(deserializer)?;
3084        Self::new(value).map_err(serde::de::Error::custom)
3085    }
3086}
3087
3088impl ProviderContractRevision {
3089    pub fn new(value: impl Into<String>) -> Result<Self, CompatibilityIdentifierError> {
3090        let value = value.into();
3091        if value.is_empty() {
3092            return Err(CompatibilityIdentifierError::Empty {
3093                label: "provider contract revision",
3094            });
3095        }
3096        if value.len() > MAX_PROVIDER_CONTRACT_REVISION_BYTES {
3097            return Err(CompatibilityIdentifierError::TooLong {
3098                label: "provider contract revision",
3099                len: value.len(),
3100                max: MAX_PROVIDER_CONTRACT_REVISION_BYTES,
3101            });
3102        }
3103        if !value.bytes().all(|byte| byte.is_ascii_graphic()) {
3104            return Err(CompatibilityIdentifierError::InvalidCharacters {
3105                label: "provider contract revision",
3106                value,
3107            });
3108        }
3109        Ok(Self(value))
3110    }
3111
3112    pub fn as_str(&self) -> &str {
3113        &self.0
3114    }
3115}
3116
3117impl fmt::Display for ProviderContractRevision {
3118    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3119        formatter.write_str(self.as_str())
3120    }
3121}
3122
3123impl FromStr for ProviderContractRevision {
3124    type Err = CompatibilityIdentifierError;
3125
3126    fn from_str(value: &str) -> Result<Self, Self::Err> {
3127        Self::new(value)
3128    }
3129}
3130
3131impl Serialize for ProviderContractRevision {
3132    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
3133    where
3134        S: Serializer,
3135    {
3136        serializer.serialize_str(self.as_str())
3137    }
3138}
3139
3140impl<'de> Deserialize<'de> for ProviderContractRevision {
3141    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3142    where
3143        D: Deserializer<'de>,
3144    {
3145        let value = String::deserialize(deserializer)?;
3146        Self::new(value).map_err(serde::de::Error::custom)
3147    }
3148}
3149
3150impl AdapterContractRevision {
3151    pub fn new(value: impl Into<String>) -> Result<Self, CompatibilityIdentifierError> {
3152        let value = value.into();
3153        if value.is_empty() {
3154            return Err(CompatibilityIdentifierError::Empty {
3155                label: "adapter contract revision",
3156            });
3157        }
3158        if value.len() > MAX_ADAPTER_CONTRACT_REVISION_BYTES {
3159            return Err(CompatibilityIdentifierError::TooLong {
3160                label: "adapter contract revision",
3161                len: value.len(),
3162                max: MAX_ADAPTER_CONTRACT_REVISION_BYTES,
3163            });
3164        }
3165        if !value.bytes().all(|byte| byte.is_ascii_graphic()) {
3166            return Err(CompatibilityIdentifierError::InvalidCharacters {
3167                label: "adapter contract revision",
3168                value,
3169            });
3170        }
3171        Ok(Self(value))
3172    }
3173
3174    pub fn as_str(&self) -> &str {
3175        &self.0
3176    }
3177}
3178
3179impl fmt::Display for AdapterContractRevision {
3180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3181        formatter.write_str(self.as_str())
3182    }
3183}
3184
3185impl FromStr for AdapterContractRevision {
3186    type Err = CompatibilityIdentifierError;
3187
3188    fn from_str(value: &str) -> Result<Self, Self::Err> {
3189        Self::new(value)
3190    }
3191}
3192
3193impl Serialize for AdapterContractRevision {
3194    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
3195    where
3196        S: Serializer,
3197    {
3198        serializer.serialize_str(self.as_str())
3199    }
3200}
3201
3202impl<'de> Deserialize<'de> for AdapterContractRevision {
3203    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3204    where
3205        D: Deserializer<'de>,
3206    {
3207        let value = String::deserialize(deserializer)?;
3208        Self::new(value).map_err(serde::de::Error::custom)
3209    }
3210}
3211
3212fn validate_compatibility_identifier(
3213    label: &'static str,
3214    value: &str,
3215) -> Result<(), CompatibilityIdentifierError> {
3216    if value.is_empty() {
3217        return Err(CompatibilityIdentifierError::Empty { label });
3218    }
3219    if value.len() > MAX_COMPATIBILITY_IDENTIFIER_BYTES {
3220        return Err(CompatibilityIdentifierError::TooLong {
3221            label,
3222            len: value.len(),
3223            max: MAX_COMPATIBILITY_IDENTIFIER_BYTES,
3224        });
3225    }
3226    if !value.bytes().all(|byte| {
3227        byte.is_ascii_lowercase()
3228            || byte.is_ascii_digit()
3229            || matches!(byte, b'-' | b'_' | b'.')
3230    }) || !value.as_bytes().first().is_some_and(u8::is_ascii_alphanumeric)
3231        || !value.as_bytes().last().is_some_and(u8::is_ascii_alphanumeric)
3232    {
3233        return Err(CompatibilityIdentifierError::InvalidCharacters {
3234            label,
3235            value: value.to_owned(),
3236        });
3237    }
3238    Ok(())
3239}
3240
3241#[derive(Clone, Debug, Eq, Error, PartialEq)]
3242pub enum CompatibilityIdentifierError {
3243    #[error("{label} identifier cannot be empty")]
3244    Empty { label: &'static str },
3245    #[error("{label} identifier length {len} exceeds the {max}-byte limit")]
3246    TooLong {
3247        label: &'static str,
3248        len: usize,
3249        max: usize,
3250    },
3251    #[error("{label} identifier must be bounded lowercase ASCII: {value}")]
3252    InvalidCharacters { label: &'static str, value: String },
3253}
3254
3255#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
3256#[serde(rename_all = "kebab-case")]
3257pub enum PathStyle {
3258    Windows,
3259    Posix,
3260}
3261
3262#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
3263#[serde(rename_all = "kebab-case")]
3264pub enum PathEncoding {
3265    Utf8,
3266    UnixBytes,
3267}
3268
3269#[derive(Clone, Debug, Eq, Hash, PartialEq)]
3270pub struct OpaqueHostPath(OpaqueHostPathRepr);
3271
3272#[derive(Clone, Debug, Eq, Hash, PartialEq)]
3273enum OpaqueHostPathRepr {
3274    Utf8(String),
3275    UnixBytes(Vec<u8>),
3276}
3277
3278impl OpaqueHostPath {
3279    pub fn utf8(value: String) -> Result<Self, OpaqueHostPathError> {
3280        validate_opaque_host_path(&value.as_bytes())?;
3281        Ok(Self(OpaqueHostPathRepr::Utf8(value)))
3282    }
3283
3284    pub fn unix_bytes(value: Vec<u8>) -> Result<Self, OpaqueHostPathError> {
3285        validate_opaque_host_path(&value)?;
3286        Ok(Self(OpaqueHostPathRepr::UnixBytes(value)))
3287    }
3288
3289    pub fn byte_len(&self) -> usize {
3290        match &self.0 {
3291            OpaqueHostPathRepr::Utf8(value) => value.len(),
3292            OpaqueHostPathRepr::UnixBytes(value) => value.len(),
3293        }
3294    }
3295
3296    pub fn display_text(&self) -> Cow<'_, str> {
3297        match &self.0 {
3298            OpaqueHostPathRepr::Utf8(value) => Cow::Borrowed(value),
3299            OpaqueHostPathRepr::UnixBytes(value) => String::from_utf8_lossy(value),
3300        }
3301    }
3302
3303    pub fn as_utf8(&self) -> Option<&str> {
3304        match &self.0 {
3305            OpaqueHostPathRepr::Utf8(value) => Some(value),
3306            OpaqueHostPathRepr::UnixBytes(_) => None,
3307        }
3308    }
3309
3310    pub fn as_unix_bytes(&self) -> Option<&[u8]> {
3311        match &self.0 {
3312            OpaqueHostPathRepr::Utf8(_) => None,
3313            OpaqueHostPathRepr::UnixBytes(value) => Some(value),
3314        }
3315    }
3316}
3317
3318fn validate_opaque_host_path(value: &[u8]) -> Result<(), OpaqueHostPathError> {
3319    if value.is_empty() {
3320        return Err(OpaqueHostPathError::Empty);
3321    }
3322    if value.len() > MAX_WORKSPACE_ROOT_BYTES {
3323        return Err(OpaqueHostPathError::TooLong {
3324            len: value.len(),
3325            max: MAX_WORKSPACE_ROOT_BYTES,
3326        });
3327    }
3328    if value.contains(&0) {
3329        return Err(OpaqueHostPathError::ContainsNul);
3330    }
3331    Ok(())
3332}
3333
3334#[derive(Clone, Debug, Eq, Error, PartialEq)]
3335pub enum OpaqueHostPathError {
3336    #[error("host path cannot be empty")]
3337    Empty,
3338    #[error("host path length {len} exceeds the {max}-byte limit")]
3339    TooLong { len: usize, max: usize },
3340    #[error("host path cannot contain a NUL byte")]
3341    ContainsNul,
3342}
3343
3344impl Serialize for OpaqueHostPath {
3345    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
3346    where
3347        S: Serializer,
3348    {
3349        match &self.0 {
3350            OpaqueHostPathRepr::Utf8(value) => serializer.serialize_str(value),
3351            OpaqueHostPathRepr::UnixBytes(value) => {
3352                let mut state = serializer.serialize_struct("OpaqueHostPath", 2)?;
3353                state.serialize_field("kind", "unix-bytes")?;
3354                state.serialize_field("bytes", value)?;
3355                state.end()
3356            }
3357        }
3358    }
3359}
3360
3361impl<'de> Deserialize<'de> for OpaqueHostPath {
3362    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3363    where
3364        D: Deserializer<'de>,
3365    {
3366        deserializer.deserialize_any(OpaqueHostPathVisitor)
3367    }
3368}
3369
3370struct OpaqueHostPathVisitor;
3371
3372impl<'de> Visitor<'de> for OpaqueHostPathVisitor {
3373    type Value = OpaqueHostPath;
3374
3375    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3376        formatter.write_str("a bounded UTF-8 path string or strict unix-bytes path object")
3377    }
3378
3379    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
3380    where
3381        E: serde::de::Error,
3382    {
3383        OpaqueHostPath::utf8(value.to_owned()).map_err(E::custom)
3384    }
3385
3386    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
3387    where
3388        E: serde::de::Error,
3389    {
3390        OpaqueHostPath::utf8(value).map_err(E::custom)
3391    }
3392
3393    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
3394    where
3395        M: MapAccess<'de>,
3396    {
3397        let mut kind = None;
3398        let mut bytes = None;
3399        while let Some(field) = map.next_key::<String>()? {
3400            match field.as_str() {
3401                "kind" => {
3402                    if kind.is_some() {
3403                        return Err(serde::de::Error::duplicate_field("kind"));
3404                    }
3405                    kind = Some(map.next_value::<String>()?);
3406                }
3407                "bytes" => {
3408                    if bytes.is_some() {
3409                        return Err(serde::de::Error::duplicate_field("bytes"));
3410                    }
3411                    bytes = Some(map.next_value::<BoundedOpaquePathBytes>()?.0);
3412                }
3413                _ => {
3414                    return Err(serde::de::Error::unknown_field(&field, &["kind", "bytes"]));
3415                }
3416            }
3417        }
3418        let kind = kind.ok_or_else(|| serde::de::Error::missing_field("kind"))?;
3419        if kind != "unix-bytes" {
3420            return Err(serde::de::Error::unknown_variant(&kind, &["unix-bytes"]));
3421        }
3422        let bytes = bytes.ok_or_else(|| serde::de::Error::missing_field("bytes"))?;
3423        OpaqueHostPath::unix_bytes(bytes).map_err(serde::de::Error::custom)
3424    }
3425}
3426
3427struct BoundedOpaquePathBytes(Vec<u8>);
3428
3429impl<'de> Deserialize<'de> for BoundedOpaquePathBytes {
3430    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3431    where
3432        D: Deserializer<'de>,
3433    {
3434        deserializer.deserialize_seq(BoundedOpaquePathBytesVisitor)
3435    }
3436}
3437
3438struct BoundedOpaquePathBytesVisitor;
3439
3440impl<'de> Visitor<'de> for BoundedOpaquePathBytesVisitor {
3441    type Value = BoundedOpaquePathBytes;
3442
3443    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3444        write!(formatter, "at most {MAX_WORKSPACE_ROOT_BYTES} path bytes")
3445    }
3446
3447    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
3448    where
3449        A: SeqAccess<'de>,
3450    {
3451        let mut bytes = Vec::with_capacity(
3452            sequence.size_hint().unwrap_or(0).min(MAX_WORKSPACE_ROOT_BYTES),
3453        );
3454        while let Some(byte) = sequence.next_element::<u8>()? {
3455            if bytes.len() == MAX_WORKSPACE_ROOT_BYTES {
3456                return Err(serde::de::Error::invalid_length(
3457                    bytes.len() + 1,
3458                    &self,
3459                ));
3460            }
3461            bytes.push(byte);
3462        }
3463        Ok(BoundedOpaquePathBytes(bytes))
3464    }
3465}
3466
3467#[derive(Clone, Debug)]
3468pub struct RepositoryPath(RepositoryPathRepr);
3469
3470#[derive(Clone, Debug)]
3471enum RepositoryPathRepr {
3472    Utf8(String),
3473    UnixBytes(Vec<u8>),
3474}
3475
3476impl PartialEq for RepositoryPath {
3477    fn eq(&self, other: &Self) -> bool {
3478        self.as_bytes() == other.as_bytes()
3479    }
3480}
3481
3482impl Eq for RepositoryPath {}
3483
3484impl Hash for RepositoryPath {
3485    fn hash<H: Hasher>(&self, state: &mut H) {
3486        self.as_bytes().hash(state);
3487    }
3488}
3489
3490impl PartialOrd for RepositoryPath {
3491    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
3492        Some(self.cmp(other))
3493    }
3494}
3495
3496impl Ord for RepositoryPath {
3497    fn cmp(&self, other: &Self) -> Ordering {
3498        self.as_bytes().cmp(other.as_bytes())
3499    }
3500}
3501
3502impl RepositoryPath {
3503    pub fn utf8(value: String) -> Result<Self, RepositoryPathError> {
3504        validate_repository_path(value.as_bytes())?;
3505        Ok(Self(RepositoryPathRepr::Utf8(value)))
3506    }
3507
3508    pub fn unix_bytes(value: Vec<u8>) -> Result<Self, RepositoryPathError> {
3509        validate_repository_path(&value)?;
3510        Ok(Self(RepositoryPathRepr::UnixBytes(value)))
3511    }
3512
3513    pub fn byte_len(&self) -> usize {
3514        self.as_bytes().len()
3515    }
3516
3517    pub fn display_text(&self) -> Cow<'_, str> {
3518        match &self.0 {
3519            RepositoryPathRepr::Utf8(value) => Cow::Borrowed(value),
3520            RepositoryPathRepr::UnixBytes(value) => String::from_utf8_lossy(value),
3521        }
3522    }
3523
3524    pub fn as_bytes(&self) -> &[u8] {
3525        match &self.0 {
3526            RepositoryPathRepr::Utf8(value) => value.as_bytes(),
3527            RepositoryPathRepr::UnixBytes(value) => value,
3528        }
3529    }
3530
3531    pub fn as_utf8(&self) -> Option<&str> {
3532        match &self.0 {
3533            RepositoryPathRepr::Utf8(value) => Some(value),
3534            RepositoryPathRepr::UnixBytes(_) => None,
3535        }
3536    }
3537
3538    pub fn as_unix_bytes(&self) -> Option<&[u8]> {
3539        match &self.0 {
3540            RepositoryPathRepr::Utf8(_) => None,
3541            RepositoryPathRepr::UnixBytes(value) => Some(value),
3542        }
3543    }
3544
3545    pub fn is_descendant_of(&self, ancestor: &Self) -> bool {
3546        let path = self.as_bytes();
3547        let ancestor = ancestor.as_bytes();
3548        path.len() > ancestor.len()
3549            && path.starts_with(ancestor)
3550            && path.get(ancestor.len()) == Some(&b'/')
3551    }
3552
3553    pub fn component_count(&self) -> usize {
3554        self.as_bytes().split(|byte| *byte == b'/').count()
3555    }
3556
3557    pub fn depth(&self) -> usize {
3558        self.component_count() - 1
3559    }
3560
3561    pub fn file_name_bytes(&self) -> &[u8] {
3562        self.as_bytes()
3563            .rsplit(|byte| *byte == b'/')
3564            .next()
3565            .expect("validated repository paths have at least one component")
3566    }
3567
3568    pub fn file_name_display_text(&self) -> Cow<'_, str> {
3569        String::from_utf8_lossy(self.file_name_bytes())
3570    }
3571}
3572
3573fn validate_repository_path(value: &[u8]) -> Result<(), RepositoryPathError> {
3574    if value.is_empty() {
3575        return Err(RepositoryPathError::Empty);
3576    }
3577    if value.len() > MAX_REPOSITORY_PATH_BYTES {
3578        return Err(RepositoryPathError::TooLong {
3579            len: value.len(),
3580            max: MAX_REPOSITORY_PATH_BYTES,
3581        });
3582    }
3583    if value.contains(&0) {
3584        return Err(RepositoryPathError::ContainsNul);
3585    }
3586    if value.starts_with(b"/") {
3587        return Err(RepositoryPathError::Absolute);
3588    }
3589    for component in value.split(|byte| *byte == b'/') {
3590        match component {
3591            b"" => return Err(RepositoryPathError::EmptyComponent),
3592            b"." => return Err(RepositoryPathError::CurrentDirectoryComponent),
3593            b".." => return Err(RepositoryPathError::ParentDirectoryComponent),
3594            _ => {}
3595        }
3596    }
3597    Ok(())
3598}
3599
3600#[derive(Clone, Debug, Eq, Error, PartialEq)]
3601pub enum RepositoryPathError {
3602    #[error("repository path cannot be empty")]
3603    Empty,
3604    #[error("repository path length {len} exceeds the {max}-byte limit")]
3605    TooLong { len: usize, max: usize },
3606    #[error("repository path cannot contain a NUL byte")]
3607    ContainsNul,
3608    #[error("repository path must be relative")]
3609    Absolute,
3610    #[error("repository path cannot contain an empty component")]
3611    EmptyComponent,
3612    #[error("repository path cannot contain a current-directory component")]
3613    CurrentDirectoryComponent,
3614    #[error("repository path cannot contain a parent-directory component")]
3615    ParentDirectoryComponent,
3616}
3617
3618impl Serialize for RepositoryPath {
3619    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
3620    where
3621        S: Serializer,
3622    {
3623        match &self.0 {
3624            RepositoryPathRepr::Utf8(value) => serializer.serialize_str(value),
3625            RepositoryPathRepr::UnixBytes(value) => {
3626                let mut state = serializer.serialize_struct("RepositoryPath", 2)?;
3627                state.serialize_field("kind", "unix-bytes")?;
3628                state.serialize_field("bytes", value)?;
3629                state.end()
3630            }
3631        }
3632    }
3633}
3634
3635impl<'de> Deserialize<'de> for RepositoryPath {
3636    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3637    where
3638        D: Deserializer<'de>,
3639    {
3640        deserializer.deserialize_any(RepositoryPathVisitor)
3641    }
3642}
3643
3644struct RepositoryPathVisitor;
3645
3646impl<'de> Visitor<'de> for RepositoryPathVisitor {
3647    type Value = RepositoryPath;
3648
3649    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3650        formatter.write_str(
3651            "a bounded canonical relative UTF-8 repository path string or strict unix-bytes object",
3652        )
3653    }
3654
3655    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
3656    where
3657        E: serde::de::Error,
3658    {
3659        RepositoryPath::utf8(value.to_owned()).map_err(E::custom)
3660    }
3661
3662    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
3663    where
3664        E: serde::de::Error,
3665    {
3666        RepositoryPath::utf8(value).map_err(E::custom)
3667    }
3668
3669    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
3670    where
3671        M: MapAccess<'de>,
3672    {
3673        let mut kind = None;
3674        let mut bytes = None;
3675        while let Some(field) = map.next_key::<String>()? {
3676            match field.as_str() {
3677                "kind" => {
3678                    if kind.is_some() {
3679                        return Err(serde::de::Error::duplicate_field("kind"));
3680                    }
3681                    kind = Some(map.next_value::<String>()?);
3682                }
3683                "bytes" => {
3684                    if bytes.is_some() {
3685                        return Err(serde::de::Error::duplicate_field("bytes"));
3686                    }
3687                    bytes = Some(map.next_value::<BoundedRepositoryPathBytes>()?.0);
3688                }
3689                _ => {
3690                    return Err(serde::de::Error::unknown_field(&field, &["kind", "bytes"]));
3691                }
3692            }
3693        }
3694        let kind = kind.ok_or_else(|| serde::de::Error::missing_field("kind"))?;
3695        if kind != "unix-bytes" {
3696            return Err(serde::de::Error::unknown_variant(&kind, &["unix-bytes"]));
3697        }
3698        let bytes = bytes.ok_or_else(|| serde::de::Error::missing_field("bytes"))?;
3699        RepositoryPath::unix_bytes(bytes).map_err(serde::de::Error::custom)
3700    }
3701}
3702
3703struct BoundedRepositoryPathBytes(Vec<u8>);
3704
3705impl<'de> Deserialize<'de> for BoundedRepositoryPathBytes {
3706    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
3707    where
3708        D: Deserializer<'de>,
3709    {
3710        deserializer.deserialize_seq(BoundedRepositoryPathBytesVisitor)
3711    }
3712}
3713
3714struct BoundedRepositoryPathBytesVisitor;
3715
3716impl<'de> Visitor<'de> for BoundedRepositoryPathBytesVisitor {
3717    type Value = BoundedRepositoryPathBytes;
3718
3719    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
3720        write!(formatter, "at most {MAX_REPOSITORY_PATH_BYTES} repository path bytes")
3721    }
3722
3723    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
3724    where
3725        A: SeqAccess<'de>,
3726    {
3727        let mut bytes = Vec::with_capacity(
3728            sequence.size_hint().unwrap_or(0).min(MAX_REPOSITORY_PATH_BYTES),
3729        );
3730        while let Some(byte) = sequence.next_element::<u8>()? {
3731            if bytes.len() == MAX_REPOSITORY_PATH_BYTES {
3732                return Err(serde::de::Error::invalid_length(bytes.len() + 1, &self));
3733            }
3734            bytes.push(byte);
3735        }
3736        Ok(BoundedRepositoryPathBytes(bytes))
3737    }
3738}
3739
3740#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3741pub struct PathSemantics {
3742    pub style: PathStyle,
3743    pub encoding: PathEncoding,
3744}
3745
3746#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
3747#[serde(rename_all = "kebab-case")]
3748pub enum LocalTransportKind {
3749    WindowsNamedPipe,
3750    UnixDomainSocket,
3751}
3752
3753#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3754pub struct HostDescriptor {
3755    pub operating_system: OperatingSystemId,
3756    pub architecture: ArchitectureId,
3757}
3758
3759#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
3760pub struct StateSchemaSupport {
3761    pub versions: ProtocolRange,
3762}
3763
3764#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3765pub struct ProviderContractSupport {
3766    pub provider: AgentId,
3767    pub revision: ProviderContractRevision,
3768}
3769
3770#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3771pub struct ProviderAdapterContractSupport {
3772    pub provider: AgentId,
3773    pub family: AdapterFamily,
3774    pub adapter_id: AdapterId,
3775    pub revision: AdapterContractRevision,
3776}
3777
3778#[derive(Clone, Debug, Eq, Error, PartialEq)]
3779pub enum ProviderContractManifestError {
3780    #[error("provider contract count {len} exceeds the {max}-entry limit")]
3781    TooManyProviders { len: usize, max: usize },
3782    #[error("provider adapter contract count {len} exceeds the {max}-entry limit")]
3783    TooManyAdapterContracts { len: usize, max: usize },
3784    #[error("provider contract manifest contains duplicate provider {provider:?}")]
3785    DuplicateProvider { provider: AgentId },
3786    #[error("provider adapter contract for {provider:?} has no provider contract")]
3787    UnlinkedAdapterProvider { provider: AgentId },
3788    #[error("provider adapter contract manifest contains duplicate family {family:?} for {provider:?}")]
3789    DuplicateProviderFamily {
3790        provider: AgentId,
3791        family: AdapterFamily,
3792    },
3793}
3794
3795pub fn validate_provider_contract_manifest(
3796    provider_contracts: &[ProviderContractSupport],
3797    provider_adapter_contracts: &[ProviderAdapterContractSupport],
3798) -> Result<(), ProviderContractManifestError> {
3799    if provider_contracts.len() > MAX_PROVIDER_CONTRACTS {
3800        return Err(ProviderContractManifestError::TooManyProviders {
3801            len: provider_contracts.len(),
3802            max: MAX_PROVIDER_CONTRACTS,
3803        });
3804    }
3805    if provider_adapter_contracts.len() > MAX_PROVIDER_ADAPTER_CONTRACTS {
3806        return Err(ProviderContractManifestError::TooManyAdapterContracts {
3807            len: provider_adapter_contracts.len(),
3808            max: MAX_PROVIDER_ADAPTER_CONTRACTS,
3809        });
3810    }
3811    for (index, contract) in provider_contracts.iter().enumerate() {
3812        if provider_contracts[..index]
3813            .iter()
3814            .any(|existing| existing.provider == contract.provider)
3815        {
3816            return Err(ProviderContractManifestError::DuplicateProvider {
3817                provider: contract.provider.clone(),
3818            });
3819        }
3820    }
3821    for (index, contract) in provider_adapter_contracts.iter().enumerate() {
3822        if !provider_contracts
3823            .iter()
3824            .any(|provider| provider.provider == contract.provider)
3825        {
3826            return Err(ProviderContractManifestError::UnlinkedAdapterProvider {
3827                provider: contract.provider.clone(),
3828            });
3829        }
3830        if provider_adapter_contracts[..index].iter().any(|existing| {
3831            existing.provider == contract.provider && existing.family == contract.family
3832        }) {
3833            return Err(ProviderContractManifestError::DuplicateProviderFamily {
3834                provider: contract.provider.clone(),
3835                family: contract.family,
3836            });
3837        }
3838    }
3839    Ok(())
3840}
3841
3842#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3843pub struct ClientCompatibilityOffer {
3844    pub build_stamp: String,
3845    #[serde(default)]
3846    pub capabilities: Vec<CapabilityId>,
3847    #[serde(default, skip_serializing_if = "Option::is_none")]
3848    pub state_schema: Option<StateSchemaSupport>,
3849}
3850
3851impl ClientCompatibilityOffer {
3852    /// An offer carrying this binary's own [`BUILD_STAMP`], no capabilities
3853    /// and no state-schema support -- the minimal offer a client makes when
3854    /// it wants nothing beyond a build-stamp check.
3855    pub fn local() -> Self {
3856        Self {
3857            build_stamp: BUILD_STAMP.to_owned(),
3858            capabilities: Vec::new(),
3859            state_schema: None,
3860        }
3861    }
3862}
3863
3864pub fn production_node_client_compatibility_offer() -> ClientCompatibilityOffer {
3865    ClientCompatibilityOffer {
3866        build_stamp: BUILD_STAMP.to_owned(),
3867        capabilities: [
3868            NODE_COMPATIBILITY_METADATA_CAPABILITY,
3869            NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
3870            NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
3871            CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
3872            NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
3873            NODE_OPAQUE_UNIX_PATH_CAPABILITY,
3874            NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
3875            NODE_PROVIDER_ID_OPEN_CAPABILITY,
3876            NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
3877            NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
3878            NODE_REPOSITORY_PATH_CAPABILITY,
3879            NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
3880            NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
3881            NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
3882            NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
3883            NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
3884            NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
3885            NODE_NATIVE_SESSION_INDEX_CAPABILITY,
3886            NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
3887            NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
3888            NODE_SESSION_TASK_CORRELATION_CAPABILITY,
3889            NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
3890            NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
3891            NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
3892            NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
3893            NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
3894            NODE_AGENT_STREAM_EVENTS_CAPABILITY,
3895            NODE_ACP_CONTROL_CAPABILITY,
3896            NODE_WORKSPACE_FILE_READ_CAPABILITY,
3897            NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
3898            NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
3899            NODE_GIT_READ_CAPABILITY,
3900            NODE_WORKTREE_SELECTION_CAPABILITY,
3901        ]
3902        .into_iter()
3903        .map(|capability| CapabilityId(capability.to_owned()))
3904        .collect(),
3905        state_schema: Some(StateSchemaSupport {
3906            versions: ProtocolRange {
3907                minimum: NODE_STATE_SCHEMA_V1,
3908                maximum: NODE_STATE_SCHEMA_V10,
3909            },
3910        }),
3911    }
3912}
3913
3914#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3915pub struct NodeCompatibilitySupport {
3916    pub build_stamp: String,
3917    #[serde(default)]
3918    pub capabilities: Vec<CapabilityId>,
3919    pub host: HostDescriptor,
3920    pub path_semantics: PathSemantics,
3921    pub local_transport: LocalTransportKind,
3922    pub state_schema: StateSchemaSupport,
3923    #[serde(default)]
3924    pub provider_contracts: Vec<ProviderContractSupport>,
3925    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3926    pub provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3927}
3928
3929#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
3930pub struct NegotiatedNodeCompatibility {
3931    pub build_stamp: String,
3932    #[serde(default)]
3933    pub capabilities: Vec<CapabilityId>,
3934    pub host: HostDescriptor,
3935    pub path_semantics: PathSemantics,
3936    pub local_transport: LocalTransportKind,
3937    #[serde(default, skip_serializing_if = "Option::is_none")]
3938    pub state_schema_version: Option<u16>,
3939    #[serde(default)]
3940    pub provider_contracts: Vec<ProviderContractSupport>,
3941    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3942    pub provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3943}
3944
3945impl NodeCompatibilitySupport {
3946    pub fn negotiate(
3947        &self,
3948        client: &ClientCompatibilityOffer,
3949    ) -> Result<NegotiatedNodeCompatibility, ProtocolNegotiationError> {
3950        if self.build_stamp != client.build_stamp {
3951            return Err(ProtocolNegotiationError::BuildStampMismatch {
3952                local: self.build_stamp.clone(),
3953                remote: client.build_stamp.clone(),
3954            });
3955        }
3956        let capabilities: Vec<CapabilityId> = self
3957            .capabilities
3958            .iter()
3959            .filter(|capability| client.capabilities.contains(capability))
3960            .cloned()
3961            .collect();
3962        let state_schema_version = match client.state_schema {
3963            Some(client_state) => Some(
3964                self.state_schema
3965                    .versions
3966                    .highest_common(client_state.versions)?,
3967            ),
3968            None => None,
3969        };
3970        let provider_manifest_selected = capabilities.iter().any(|capability| {
3971            capability.as_str() == NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY
3972        });
3973        if provider_manifest_selected {
3974            validate_provider_contract_manifest(
3975                &self.provider_contracts,
3976                &self.provider_adapter_contracts,
3977            )
3978            .map_err(ProtocolNegotiationError::InvalidProviderContractManifest)?;
3979        }
3980        Ok(NegotiatedNodeCompatibility {
3981            build_stamp: self.build_stamp.clone(),
3982            capabilities,
3983            host: self.host.clone(),
3984            path_semantics: self.path_semantics.clone(),
3985            local_transport: self.local_transport,
3986            state_schema_version,
3987            provider_contracts: provider_manifest_selected
3988                .then(|| self.provider_contracts.clone())
3989                .unwrap_or_default(),
3990            provider_adapter_contracts: provider_manifest_selected
3991                .then(|| self.provider_adapter_contracts.clone())
3992                .unwrap_or_default(),
3993        })
3994    }
3995}
3996
3997#[derive(Serialize)]
3998struct NodeCompatibilityAuthBinding<'a> {
3999    offer: &'a ClientCompatibilityOffer,
4000    selected: &'a NegotiatedNodeCompatibility,
4001}
4002
4003pub fn encode_node_compatibility_auth_binding(
4004    offer: &ClientCompatibilityOffer,
4005    selected: &NegotiatedNodeCompatibility,
4006) -> Result<Vec<u8>, NodeCompatibilityAuthBindingError> {
4007    let encoded = serde_json::to_vec(&NodeCompatibilityAuthBinding { offer, selected })?;
4008    if encoded.len() > MAX_NODE_HELLO_FRAME_BYTES {
4009        return Err(NodeCompatibilityAuthBindingError::TooLarge {
4010            len: encoded.len(),
4011            max: MAX_NODE_HELLO_FRAME_BYTES,
4012        });
4013    }
4014    Ok(encoded)
4015}
4016
4017#[derive(Debug, Error)]
4018pub enum NodeCompatibilityAuthBindingError {
4019    #[error("node compatibility authentication binding serialization failed: {0}")]
4020    Serialization(#[from] serde_json::Error),
4021    #[error("node compatibility authentication binding length {len} exceeds the {max}-byte limit")]
4022    TooLarge { len: usize, max: usize },
4023}
4024
4025pub fn validate_node_negotiated_handshake_capacity(
4026    support: &NodeCompatibilitySupport,
4027) -> Result<(), NodeNegotiatedHandshakeCapacityError> {
4028    let offer = production_node_client_compatibility_offer();
4029    let selected = support.negotiate(&offer)?;
4030    encode_node_compatibility_auth_binding(&offer, &selected)?;
4031    validate_node_handshake_frame_capacity(
4032        "negotiated client hello",
4033        &ClientFrame::Hello(ClientHello {
4034            build_stamp: BUILD_STAMP.to_owned(),
4035            role: ClientRole::Observer,
4036            client_nonce: [u8::MAX; NODE_AUTH_NONCE_BYTES],
4037            compatibility: Some(offer),
4038        }),
4039    )?;
4040    validate_node_handshake_frame_capacity(
4041        "negotiated server challenge",
4042        &ServerFrame::Challenge(ServerChallenge {
4043            build_stamp: BUILD_STAMP.to_owned(),
4044            server_nonce: [u8::MAX; NODE_AUTH_NONCE_BYTES],
4045            server_proof: [u8::MAX; NODE_AUTH_PROOF_BYTES],
4046            compatibility: Some(selected),
4047        }),
4048    )
4049}
4050
4051fn validate_node_handshake_frame_capacity<T>(
4052    frame: &'static str,
4053    value: &T,
4054) -> Result<(), NodeNegotiatedHandshakeCapacityError>
4055where
4056    T: Serialize,
4057{
4058    let encoded = serde_json::to_vec(value).map_err(|source| {
4059        NodeNegotiatedHandshakeCapacityError::Serialization { frame, source }
4060    })?;
4061    if encoded.is_empty() || encoded.len() > MAX_NODE_HELLO_FRAME_BYTES {
4062        return Err(NodeNegotiatedHandshakeCapacityError::FrameTooLarge {
4063            frame,
4064            len: encoded.len(),
4065            max: MAX_NODE_HELLO_FRAME_BYTES,
4066        });
4067    }
4068    Ok(())
4069}
4070
4071#[derive(Debug, Error)]
4072pub enum NodeNegotiatedHandshakeCapacityError {
4073    #[error(transparent)]
4074    Negotiation(#[from] ProtocolNegotiationError),
4075    #[error(transparent)]
4076    AuthenticationBinding(#[from] NodeCompatibilityAuthBindingError),
4077    #[error("{frame} JSON serialization failed: {source}")]
4078    Serialization {
4079        frame: &'static str,
4080        #[source]
4081        source: serde_json::Error,
4082    },
4083    #[error("{frame} length {len} is outside 1..={max}")]
4084    FrameTooLarge {
4085        frame: &'static str,
4086        len: usize,
4087        max: usize,
4088    },
4089}
4090
4091#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
4092pub struct SessionKey {
4093    pub instance_id: AgentInstanceId,
4094    pub generation: SessionGeneration,
4095}
4096
4097#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
4098pub struct SessionAddress {
4099    pub workspace_id: WorkspaceId,
4100    pub session: SessionKey,
4101}
4102
4103#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4104#[serde(rename_all = "kebab-case")]
4105pub enum ManagedSessionState {
4106    IdentityPending,
4107    Live,
4108    Dormant,
4109    Unavailable,
4110}
4111
4112/// Node-local policy for retiring dead `ManagedSessionState::Unavailable`
4113/// records so a long-lived node's durable state does not grow forever.
4114/// Both fields default to `0` (disabled) -- a freshly started node must
4115/// never silently delete a record until an operator has chosen real values
4116/// via `--session-record-retention-age-ms` / `--session-record-retention-keep`.
4117/// Never applies to `Live`, `IdentityPending`, or `Dormant` records: only
4118/// `Unavailable` is both inert (no `active_session`) and not resumable.
4119#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
4120pub struct SessionRecordRetentionConfig {
4121    /// Minimum age (`now - updated_at_unix_ms`) an `Unavailable` record must
4122    /// reach before it is eligible for retirement. `0` disables the age test.
4123    pub age_ms: u64,
4124    /// Per-`workspace_id` floor: the newest `keep_per_workspace` `Unavailable`
4125    /// records (by `updated_at_unix_ms`) in a workspace are never retired by
4126    /// this test. `0` disables the keep-N test.
4127    pub keep_per_workspace: u32,
4128}
4129
4130#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4131#[serde(deny_unknown_fields)]
4132pub struct SessionTaskBindingV1 {
4133    pub revision: u64,
4134    pub task_id: Option<TaskId>,
4135    pub changed_at_unix_ms: u64,
4136}
4137
4138#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4139#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
4140pub enum SessionTaskTargetV1 {
4141    New,
4142    Existing { task_id: TaskId },
4143    Clear,
4144}
4145
4146#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
4147pub struct ManagedSessionRecord {
4148    pub record_id: SessionRecordId,
4149    pub display_name: String,
4150    pub provider: AgentId,
4151    pub mode: SessionMode,
4152    pub state: ManagedSessionState,
4153    pub workspace_id: WorkspaceId,
4154    pub canonical_root: OpaqueHostPath,
4155    pub provider_session: Option<ProviderSessionIdentity>,
4156    pub active_session: Option<SessionAddress>,
4157    #[serde(default, skip_serializing_if = "Option::is_none")]
4158    pub environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
4159    #[serde(default, skip_serializing_if = "Option::is_none")]
4160    pub bundle: Option<ResolvedBundleReceipt>,
4161    #[serde(default, skip_serializing_if = "Option::is_none")]
4162    pub context_id: Option<SpawnContextId>,
4163    #[serde(default, skip_serializing_if = "Option::is_none")]
4164    pub context: Option<ResolvedContextPackReceipt>,
4165    #[serde(default, skip_serializing_if = "Option::is_none")]
4166    pub exported_context: Option<ResolvedContextPackReceipt>,
4167    #[serde(default, skip_serializing_if = "Option::is_none")]
4168    pub task_binding: Option<SessionTaskBindingV1>,
4169    pub created_at_unix_ms: u64,
4170    pub updated_at_unix_ms: u64,
4171    pub last_error: Option<String>,
4172}
4173
4174impl ManagedSessionRecord {
4175    pub fn context_binding_is_valid(&self) -> bool {
4176        context_receipt_binding_is_valid(self.context_id.as_ref(), self.context.as_ref())
4177    }
4178
4179    pub fn exported_context_is_valid(&self) -> bool {
4180        self.exported_context.as_ref().map_or(true, |pack| {
4181            pack.is_valid() && pack.lineage.source_provider == self.provider
4182        })
4183    }
4184
4185    pub fn task_binding_is_valid(&self) -> bool {
4186        self.task_binding
4187            .as_ref()
4188            .map_or(true, |binding| {
4189                binding.revision > 0
4190                    && binding.changed_at_unix_ms > 0
4191                    && binding.changed_at_unix_ms >= self.created_at_unix_ms
4192                    && binding.changed_at_unix_ms <= self.updated_at_unix_ms
4193            })
4194    }
4195}
4196
4197impl<'de> Deserialize<'de> for ManagedSessionRecord {
4198    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4199    where
4200        D: Deserializer<'de>,
4201    {
4202        #[derive(Deserialize)]
4203        struct WireRecord {
4204            record_id: SessionRecordId,
4205            display_name: String,
4206            provider: AgentId,
4207            mode: SessionMode,
4208            state: ManagedSessionState,
4209            workspace_id: WorkspaceId,
4210            canonical_root: OpaqueHostPath,
4211            provider_session: Option<ProviderSessionIdentity>,
4212            active_session: Option<SessionAddress>,
4213            #[serde(default)]
4214            environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
4215            #[serde(default)]
4216            bundle: Option<ResolvedBundleReceipt>,
4217            #[serde(default)]
4218            context_id: Option<SpawnContextId>,
4219            #[serde(default)]
4220            context: Option<ResolvedContextPackReceipt>,
4221            #[serde(default)]
4222            exported_context: Option<ResolvedContextPackReceipt>,
4223            #[serde(default)]
4224            task_binding: Option<SessionTaskBindingV1>,
4225            created_at_unix_ms: u64,
4226            updated_at_unix_ms: u64,
4227            last_error: Option<String>,
4228        }
4229
4230        let wire = WireRecord::deserialize(deserializer)?;
4231        let record = Self {
4232            record_id: wire.record_id,
4233            display_name: wire.display_name,
4234            provider: wire.provider,
4235            mode: wire.mode,
4236            state: wire.state,
4237            workspace_id: wire.workspace_id,
4238            canonical_root: wire.canonical_root,
4239            provider_session: wire.provider_session,
4240            active_session: wire.active_session,
4241            environment_profile: wire.environment_profile,
4242            bundle: wire.bundle,
4243            context_id: wire.context_id,
4244            context: wire.context,
4245            exported_context: wire.exported_context,
4246            task_binding: wire.task_binding,
4247            created_at_unix_ms: wire.created_at_unix_ms,
4248            updated_at_unix_ms: wire.updated_at_unix_ms,
4249            last_error: wire.last_error,
4250        };
4251        if !record.context_binding_is_valid() {
4252            return Err(serde::de::Error::custom(
4253                "managed session context id and materialization receipt are not correlated",
4254            ));
4255        }
4256        if !record.exported_context_is_valid() {
4257            return Err(serde::de::Error::custom(
4258                "managed session exported context receipt is invalid or from a different provider",
4259            ));
4260        }
4261        if !record.task_binding_is_valid() {
4262            return Err(serde::de::Error::custom(
4263                "managed session task binding revision or timestamp is invalid",
4264            ));
4265        }
4266        Ok(record)
4267    }
4268}
4269
4270#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4271pub struct WorkspaceSnapshot {
4272    pub workspace_id: WorkspaceId,
4273    pub canonical_root: OpaqueHostPath,
4274    pub sessions: Vec<SessionSnapshot>,
4275    #[serde(default, skip_serializing_if = "Option::is_none")]
4276    pub worktree_service_mode: Option<WorktreeServiceMode>,
4277    #[serde(default, skip_serializing_if = "Option::is_none")]
4278    pub managed_worktree_profiles: Option<WorktreeProfileInventory>,
4279}
4280
4281#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4282#[serde(rename_all = "kebab-case")]
4283pub enum WorktreeServiceMode {
4284    Manual,
4285    Managed,
4286    Off,
4287}
4288
4289#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4290#[serde(rename_all = "kebab-case")]
4291pub enum WorkspaceEntryKind {
4292    File,
4293    Directory,
4294}
4295
4296#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4297pub struct WorkspaceEntry {
4298    pub relative_path: RepositoryPath,
4299    pub kind: WorkspaceEntryKind,
4300}
4301
4302#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4303pub struct GitStatusEntry {
4304    pub index_status: String,
4305    pub worktree_status: String,
4306    pub path: RepositoryPath,
4307    #[serde(default, skip_serializing_if = "Option::is_none")]
4308    pub previous_path: Option<RepositoryPath>,
4309}
4310
4311#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4312pub struct GitCommitSummary {
4313    pub id: String,
4314    pub summary: String,
4315}
4316
4317#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
4318#[serde(transparent)]
4319pub struct GitObjectId(String);
4320
4321impl GitObjectId {
4322    pub fn new(value: String) -> Result<Self, GitObjectIdError> {
4323        if !matches!(value.len(), 40 | 64)
4324            || !value.bytes().all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
4325        {
4326            return Err(GitObjectIdError);
4327        }
4328        Ok(Self(value))
4329    }
4330
4331    pub fn as_str(&self) -> &str {
4332        &self.0
4333    }
4334}
4335
4336impl<'de> Deserialize<'de> for GitObjectId {
4337    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4338    where
4339        D: Deserializer<'de>,
4340    {
4341        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
4342    }
4343}
4344
4345#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
4346#[error("git object id must be a 40- or 64-character lowercase hexadecimal digest")]
4347pub struct GitObjectIdError;
4348
4349#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4350#[serde(rename_all = "kebab-case")]
4351pub enum GitSignatureStatus {
4352    Good,
4353    Bad,
4354    UnknownValidity,
4355    ExpiredSignature,
4356    ExpiredKey,
4357    RevokedKey,
4358    CannotCheck,
4359    NoSignature,
4360}
4361
4362#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4363pub struct GitCommitDetails {
4364    pub id: GitObjectId,
4365    pub parents: Vec<GitObjectId>,
4366    pub subject: String,
4367    pub author_name: String,
4368    pub author_email: String,
4369    pub authored_at: String,
4370    pub committer_name: String,
4371    pub committer_email: String,
4372    pub committed_at: String,
4373    pub signature_status: GitSignatureStatus,
4374    pub signer: Option<String>,
4375}
4376
4377#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4378pub struct GitHistoryPage {
4379    pub commits: Vec<GitCommitDetails>,
4380    pub next_before: Option<GitObjectId>,
4381    pub truncated: bool,
4382}
4383
4384#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4385#[serde(tag = "kind", rename_all = "kebab-case")]
4386pub enum GitDiffMode {
4387    Working,
4388    Staged,
4389    Commit { revision: GitObjectId },
4390}
4391
4392#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4393pub struct GitDiffRequest {
4394    pub mode: GitDiffMode,
4395    pub path: Option<RepositoryPath>,
4396}
4397
4398#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4399pub struct GitDiff {
4400    pub mode: GitDiffMode,
4401    pub path: Option<RepositoryPath>,
4402    pub text: String,
4403    pub truncated: bool,
4404}
4405
4406#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4407pub struct GitWorktreeSnapshot {
4408    pub path: OpaqueHostPath,
4409    pub head: String,
4410    pub branch: Option<String>,
4411    pub is_bare: bool,
4412    pub is_main: bool,
4413    pub locked: bool,
4414    pub lock_reason: Option<String>,
4415    pub prunable: bool,
4416    pub prunable_reason: Option<String>,
4417    pub workspace_id: Option<WorkspaceId>,
4418}
4419
4420#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4421#[serde(deny_unknown_fields)]
4422pub struct ManagedWorktreeGitScope {
4423    pub lease_id: ManagedWorktreeLeaseId,
4424    pub source_workspace_id: WorkspaceId,
4425    #[serde(deserialize_with = "deserialize_managed_worktree_git_branch")]
4426    pub branch: String,
4427    pub base_commit: GitObjectId,
4428    pub active_session_count: u16,
4429    pub managed_record_count: u16,
4430}
4431
4432fn deserialize_managed_worktree_git_branch<'de, D>(deserializer: D) -> Result<String, D::Error>
4433where
4434    D: Deserializer<'de>,
4435{
4436    let branch = String::deserialize(deserializer)?;
4437    if branch.is_empty() || branch.len() > MAX_REPOSITORY_PATH_BYTES {
4438        return Err(serde::de::Error::custom(
4439            "managed worktree git branch must be non-empty and bounded",
4440        ));
4441    }
4442    Ok(branch)
4443}
4444
4445#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4446pub struct GitSnapshot {
4447    pub is_repository: bool,
4448    pub branch: Option<String>,
4449    pub status: Vec<GitStatusEntry>,
4450    pub recent_commits: Vec<GitCommitSummary>,
4451    #[serde(default)]
4452    pub worktrees: Vec<GitWorktreeSnapshot>,
4453    #[serde(default, skip_serializing_if = "Option::is_none")]
4454    pub managed_worktree: Option<ManagedWorktreeGitScope>,
4455    pub truncated: bool,
4456    pub diagnostic: Option<String>,
4457}
4458
4459impl GitSnapshot {
4460    pub fn managed_worktree_is_valid_for(&self, workspace_id: &WorkspaceId) -> bool {
4461        self.managed_worktree.as_ref().map_or(true, |scope| {
4462            self.is_repository
4463                && self.branch.as_deref() == Some(scope.branch.as_str())
4464                && &scope.source_workspace_id != workspace_id
4465                && (u32::from(scope.active_session_count)
4466                    + u32::from(scope.managed_record_count))
4467                    > 0
4468        })
4469    }
4470}
4471
4472/// Additive: records why (if at all) a `WorkspaceInspection` was cut short
4473/// by the node's own inner walk+git time/entry budget
4474/// (`GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS` /
4475/// `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`) — distinct from
4476/// `tree_truncated`, which only reflects the walk's fixed per-response caps
4477/// (`WORKSPACE_TREE_MAX_ENTRIES` / `WORKSPACE_TREE_MAX_DEPTH`). Every field
4478/// is `#[serde(default)]` and the field itself is optional on
4479/// `WorkspaceInspection`, so payloads produced before this field existed
4480/// still parse.
4481#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4482pub struct WorkspaceInspectionTruncationV1 {
4483    /// The directory walk stopped early because the shared walk+git time
4484    /// budget elapsed while entries were still being visited.
4485    #[serde(default)]
4486    pub walk_time_budget_exceeded: bool,
4487    /// The directory walk stopped early because it visited the configured
4488    /// entry cap's worth of directory entries.
4489    #[serde(default)]
4490    pub walk_entry_cap_exceeded: bool,
4491    /// The git phase (branch/status/log/worktree probes) was skipped or cut
4492    /// short because the shared time budget was already spent by the walk;
4493    /// see `GitSnapshot::diagnostic` for which probes ran.
4494    #[serde(default)]
4495    pub git_time_budget_exceeded: bool,
4496    /// Total directory entries the walk visited (pushed to `entries` or
4497    /// not) before it stopped, for operator/log correlation.
4498    #[serde(default)]
4499    pub entries_visited: u64,
4500    /// Milliseconds actually spent inside the shared walk+git budget
4501    /// window before the response was returned.
4502    #[serde(default)]
4503    pub elapsed_ms: u64,
4504}
4505
4506#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
4507pub struct WorkspaceInspection {
4508    pub workspace_id: WorkspaceId,
4509    pub entries: Vec<WorkspaceEntry>,
4510    pub tree_truncated: bool,
4511    pub git: GitSnapshot,
4512    #[serde(default, skip_serializing_if = "Option::is_none")]
4513    pub truncation: Option<WorkspaceInspectionTruncationV1>,
4514}
4515
4516impl<'de> Deserialize<'de> for WorkspaceInspection {
4517    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4518    where
4519        D: Deserializer<'de>,
4520    {
4521        #[derive(Deserialize)]
4522        struct WireInspection {
4523            workspace_id: WorkspaceId,
4524            entries: Vec<WorkspaceEntry>,
4525            tree_truncated: bool,
4526            git: GitSnapshot,
4527            #[serde(default)]
4528            truncation: Option<WorkspaceInspectionTruncationV1>,
4529        }
4530
4531        let wire = WireInspection::deserialize(deserializer)?;
4532        if !wire.git.managed_worktree_is_valid_for(&wire.workspace_id) {
4533            return Err(serde::de::Error::custom(
4534                "managed worktree git scope is inconsistent with workspace inspection",
4535            ));
4536        }
4537        Ok(Self {
4538            workspace_id: wire.workspace_id,
4539            entries: wire.entries,
4540            tree_truncated: wire.tree_truncated,
4541            git: wire.git,
4542            truncation: wire.truncation,
4543        })
4544    }
4545}
4546
4547#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
4548#[serde(deny_unknown_fields)]
4549pub struct HostDirectoryEntry {
4550    pub path: OpaqueHostPath,
4551    pub display_name: String,
4552    pub is_link: bool,
4553}
4554
4555impl HostDirectoryEntry {
4556    pub fn new(
4557        path: OpaqueHostPath,
4558        display_name: String,
4559        is_link: bool,
4560    ) -> Result<Self, HostDirectoryEntryError> {
4561        if path.as_utf8().is_none() {
4562            return Err(HostDirectoryEntryError::NonUtf8Path);
4563        }
4564        if display_name.is_empty() {
4565            return Err(HostDirectoryEntryError::EmptyDisplayName);
4566        }
4567        if display_name.len() > MAX_HOST_DIRECTORY_DISPLAY_NAME_BYTES {
4568            return Err(HostDirectoryEntryError::DisplayNameTooLong {
4569                len: display_name.len(),
4570                max: MAX_HOST_DIRECTORY_DISPLAY_NAME_BYTES,
4571            });
4572        }
4573        if display_name.chars().any(char::is_control) {
4574            return Err(HostDirectoryEntryError::ControlCharacter);
4575        }
4576        Ok(Self { path, display_name, is_link })
4577    }
4578}
4579
4580impl<'de> Deserialize<'de> for HostDirectoryEntry {
4581    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4582    where
4583        D: Deserializer<'de>,
4584    {
4585        #[derive(Deserialize)]
4586        #[serde(deny_unknown_fields)]
4587        struct WireEntry {
4588            path: OpaqueHostPath,
4589            display_name: String,
4590            is_link: bool,
4591        }
4592
4593        let wire = WireEntry::deserialize(deserializer)?;
4594        Self::new(wire.path, wire.display_name, wire.is_link)
4595            .map_err(serde::de::Error::custom)
4596    }
4597}
4598
4599#[derive(Clone, Debug, Eq, Error, PartialEq)]
4600pub enum HostDirectoryEntryError {
4601    #[error("host directory path must use the UTF-8 wire representation")]
4602    NonUtf8Path,
4603    #[error("host directory display name cannot be empty")]
4604    EmptyDisplayName,
4605    #[error("host directory display name length {len} exceeds the {max}-byte limit")]
4606    DisplayNameTooLong { len: usize, max: usize },
4607    #[error("host directory display name cannot contain control characters")]
4608    ControlCharacter,
4609}
4610
4611#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4612#[serde(deny_unknown_fields)]
4613pub struct HostDirectoryListing {
4614    pub directory: Option<OpaqueHostPath>,
4615    pub parent: Option<OpaqueHostPath>,
4616    #[serde(deserialize_with = "deserialize_host_directory_entries")]
4617    pub entries: Vec<HostDirectoryEntry>,
4618    pub next_after: Option<OpaqueHostPath>,
4619    /// True only when another page of supported directory entries is available.
4620    pub incomplete: bool,
4621}
4622
4623fn deserialize_host_directory_entries<'de, D>(
4624    deserializer: D,
4625) -> Result<Vec<HostDirectoryEntry>, D::Error>
4626where
4627    D: Deserializer<'de>,
4628{
4629    struct HostDirectoryEntriesVisitor;
4630
4631    impl<'de> Visitor<'de> for HostDirectoryEntriesVisitor {
4632        type Value = Vec<HostDirectoryEntry>;
4633
4634        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
4635            write!(formatter, "at most {MAX_HOST_DIRECTORY_ENTRIES} host directory entries")
4636        }
4637
4638        fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
4639        where
4640            A: SeqAccess<'de>,
4641        {
4642            let mut entries = Vec::with_capacity(
4643                sequence
4644                    .size_hint()
4645                    .unwrap_or(0)
4646                    .min(MAX_HOST_DIRECTORY_ENTRIES),
4647            );
4648            while let Some(entry) = sequence.next_element::<HostDirectoryEntry>()? {
4649                if entries.len() == MAX_HOST_DIRECTORY_ENTRIES {
4650                    return Err(serde::de::Error::invalid_length(entries.len() + 1, &self));
4651                }
4652                entries.push(entry);
4653            }
4654            Ok(entries)
4655        }
4656    }
4657
4658    deserializer.deserialize_seq(HostDirectoryEntriesVisitor)
4659}
4660
4661#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4662pub struct WorkspaceFileRead {
4663    pub workspace_id: WorkspaceId,
4664    pub path: RepositoryPath,
4665    pub content: WorkspaceFileContent,
4666    #[serde(default, skip_serializing_if = "Option::is_none")]
4667    pub revision: Option<WorkspaceFileRevision>,
4668}
4669
4670#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
4671#[serde(transparent)]
4672pub struct WorkspaceFileRevision(String);
4673
4674impl WorkspaceFileRevision {
4675    pub fn new(value: String) -> Result<Self, WorkspaceFileRevisionError> {
4676        if value.len() != 64
4677            || !value.bytes().all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
4678        {
4679            return Err(WorkspaceFileRevisionError);
4680        }
4681        Ok(Self(value))
4682    }
4683
4684    pub fn as_str(&self) -> &str {
4685        &self.0
4686    }
4687}
4688
4689impl<'de> Deserialize<'de> for WorkspaceFileRevision {
4690    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4691    where
4692        D: Deserializer<'de>,
4693    {
4694        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
4695    }
4696}
4697
4698#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
4699#[error("workspace file revision must be a 64-character lowercase SHA-256 digest")]
4700pub struct WorkspaceFileRevisionError;
4701
4702#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
4703#[serde(tag = "kind", rename_all = "kebab-case")]
4704pub enum WorkspaceFileContent {
4705    Utf8 { text: String, byte_len: u32 },
4706    NonUtf8 { byte_len: u32 },
4707    TooLarge { limit_bytes: u32 },
4708}
4709
4710#[derive(Deserialize)]
4711#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
4712enum WorkspaceFileContentWire {
4713    Utf8 { text: String, byte_len: u32 },
4714    NonUtf8 { byte_len: u32 },
4715    TooLarge { limit_bytes: u32 },
4716}
4717
4718impl<'de> Deserialize<'de> for WorkspaceFileContent {
4719    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4720    where
4721        D: Deserializer<'de>,
4722    {
4723        let wire = WorkspaceFileContentWire::deserialize(deserializer)?;
4724        let limit_bytes = MAX_WORKSPACE_FILE_BYTES as u32;
4725        match wire {
4726            WorkspaceFileContentWire::Utf8 { text, byte_len } => {
4727                let actual_bytes = text.len();
4728                if actual_bytes > MAX_WORKSPACE_FILE_BYTES {
4729                    return Err(serde::de::Error::custom(format!(
4730                        "workspace file content length {actual_bytes} exceeds the {MAX_WORKSPACE_FILE_BYTES}-byte limit",
4731                    )));
4732                }
4733                if u64::from(byte_len) != actual_bytes as u64 {
4734                    return Err(serde::de::Error::custom(format!(
4735                        "workspace file content declares {byte_len} bytes but contains {actual_bytes}",
4736                    )));
4737                }
4738                Ok(Self::Utf8 { text, byte_len })
4739            }
4740            WorkspaceFileContentWire::NonUtf8 { byte_len } => {
4741                if byte_len > limit_bytes {
4742                    return Err(serde::de::Error::custom(format!(
4743                        "workspace non-UTF-8 file length {byte_len} exceeds the {MAX_WORKSPACE_FILE_BYTES}-byte limit",
4744                    )));
4745                }
4746                Ok(Self::NonUtf8 { byte_len })
4747            }
4748            WorkspaceFileContentWire::TooLarge {
4749                limit_bytes: declared_limit,
4750            } => {
4751                if declared_limit != limit_bytes {
4752                    return Err(serde::de::Error::custom(format!(
4753                        "workspace file limit {declared_limit} does not match protocol limit {limit_bytes}",
4754                    )));
4755                }
4756                Ok(Self::TooLarge { limit_bytes })
4757            }
4758        }
4759    }
4760}
4761
4762#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4763#[serde(rename_all = "kebab-case")]
4764pub enum AgentProgressCurrentV1 {
4765    Idle,
4766    Working,
4767    WaitingForInput,
4768    Blocked,
4769}
4770
4771impl From<ProviderActivity> for AgentProgressCurrentV1 {
4772    fn from(activity: ProviderActivity) -> Self {
4773        match activity {
4774            ProviderActivity::Idle => Self::Idle,
4775            ProviderActivity::Working => Self::Working,
4776            ProviderActivity::WaitingForInput => Self::WaitingForInput,
4777            ProviderActivity::Blocked => Self::Blocked,
4778        }
4779    }
4780}
4781
4782#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4783#[serde(rename_all = "kebab-case")]
4784pub enum AgentProgressAttentionKindV1 {
4785    Approval,
4786    Question,
4787}
4788
4789#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4790#[serde(deny_unknown_fields)]
4791pub struct AgentProgressAttentionV1 {
4792    pub kind: AgentProgressAttentionKindV1,
4793    #[serde(default, skip_serializing_if = "Option::is_none")]
4794    pub tool_label: Option<String>,
4795}
4796
4797#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4798#[serde(rename_all = "kebab-case")]
4799pub enum AgentProgressEventKindV1 {
4800    SessionStarted,
4801    SessionIdentityObserved,
4802    TurnStarted,
4803    WorkingObserved,
4804    Text,
4805    Thinking,
4806    ToolStarted,
4807    ToolCompleted,
4808    TurnCompleted,
4809    TurnInterrupted,
4810    SessionEnded,
4811    Error,
4812    Ready,
4813    InteractionRequested,
4814    InteractionResolved,
4815    SubagentStarted,
4816    SubagentStopped,
4817    RateLimited,
4818    HostRequestObserved,
4819    UnrecognizedNotification,
4820}
4821
4822#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4823#[serde(deny_unknown_fields)]
4824pub struct AgentProgressUsageV1 {
4825    pub input_tokens: u64,
4826    pub output_tokens: u64,
4827    pub cache_read_tokens: u64,
4828    pub cache_write_tokens: u64,
4829    pub reasoning_tokens: u64,
4830}
4831
4832#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
4833#[serde(deny_unknown_fields)]
4834pub struct AgentProgressV1 {
4835    pub provider_sequence: u64,
4836    pub activity: ProviderActivity,
4837    pub completed_turns: u64,
4838    #[serde(default, skip_serializing_if = "Option::is_none")]
4839    pub usage: Option<AgentProgressUsageV1>,
4840    pub current: AgentProgressCurrentV1,
4841    pub active_tool_labels: Vec<String>,
4842    pub active_tool_count: u32,
4843    #[serde(default, skip_serializing_if = "Option::is_none")]
4844    pub attention: Option<AgentProgressAttentionV1>,
4845    pub subagent_count: u32,
4846    #[serde(default, skip_serializing_if = "Option::is_none")]
4847    pub last_event_kind: Option<AgentProgressEventKindV1>,
4848    pub gap_count: u64,
4849    pub stale: bool,
4850    pub truncated: bool,
4851}
4852
4853impl<'de> Deserialize<'de> for AgentProgressV1 {
4854    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4855    where
4856        D: Deserializer<'de>,
4857    {
4858        #[derive(Deserialize)]
4859        #[serde(deny_unknown_fields)]
4860        struct WireProgress {
4861            provider_sequence: u64,
4862            activity: ProviderActivity,
4863            completed_turns: u64,
4864            #[serde(default)]
4865            usage: Option<AgentProgressUsageV1>,
4866            current: AgentProgressCurrentV1,
4867            active_tool_labels: Vec<String>,
4868            active_tool_count: u32,
4869            #[serde(default)]
4870            attention: Option<AgentProgressAttentionV1>,
4871            subagent_count: u32,
4872            #[serde(default)]
4873            last_event_kind: Option<AgentProgressEventKindV1>,
4874            gap_count: u64,
4875            stale: bool,
4876            truncated: bool,
4877        }
4878
4879        let wire = WireProgress::deserialize(deserializer)?;
4880        if wire.active_tool_labels.len() > MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
4881            return Err(serde::de::Error::custom(
4882                "agent progress contains too many active tool labels",
4883            ));
4884        }
4885        if usize::try_from(wire.active_tool_count).unwrap_or(usize::MAX)
4886            < wire.active_tool_labels.len()
4887        {
4888            return Err(serde::de::Error::custom(
4889                "agent progress active tool count is smaller than its labels",
4890            ));
4891        }
4892        for label in &wire.active_tool_labels {
4893            validate_agent_progress_tool_label(label)
4894                .map_err(serde::de::Error::custom)?;
4895        }
4896        if let Some(label) = wire
4897            .attention
4898            .as_ref()
4899            .and_then(|attention| attention.tool_label.as_deref())
4900        {
4901            validate_agent_progress_tool_label(label)
4902                .map_err(serde::de::Error::custom)?;
4903        }
4904        if wire.current != AgentProgressCurrentV1::from(wire.activity) {
4905            return Err(serde::de::Error::custom(
4906                "agent progress current state conflicts with provider activity",
4907            ));
4908        }
4909        Ok(Self {
4910            provider_sequence: wire.provider_sequence,
4911            activity: wire.activity,
4912            completed_turns: wire.completed_turns,
4913            usage: wire.usage,
4914            current: wire.current,
4915            active_tool_labels: wire.active_tool_labels,
4916            active_tool_count: wire.active_tool_count,
4917            attention: wire.attention,
4918            subagent_count: wire.subagent_count,
4919            last_event_kind: wire.last_event_kind,
4920            gap_count: wire.gap_count,
4921            stale: wire.stale,
4922            truncated: wire.truncated,
4923        })
4924    }
4925}
4926
4927fn validate_agent_progress_tool_label(label: &str) -> Result<(), &'static str> {
4928    if !matches!(
4929        label,
4930        "Read" | "Write" | "Edit" | "Shell" | "Search" | "Browse" | "Git"
4931            | "Ask" | "Task" | "Tool"
4932    ) {
4933        return Err("agent progress tool label is outside the safe class vocabulary");
4934    }
4935    Ok(())
4936}
4937
4938#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4939#[serde(deny_unknown_fields)]
4940pub struct SessionAgentProgress {
4941    pub address: SessionAddress,
4942    pub progress: AgentProgressV1,
4943}
4944
4945#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4946pub struct NodeSnapshot {
4947    pub node_id: NodeId,
4948    pub enabled_providers: Vec<AgentId>,
4949    #[serde(default, skip_serializing_if = "ProviderRuntimeStatuses::is_empty")]
4950    pub provider_runtime_statuses: ProviderRuntimeStatuses,
4951    pub workspaces: Vec<WorkspaceSnapshot>,
4952    #[serde(default)]
4953    pub session_records: Vec<ManagedSessionRecord>,
4954    #[serde(
4955        default,
4956        skip_serializing_if = "Vec::is_empty",
4957        deserialize_with = "deserialize_managed_worktree_leases"
4958    )]
4959    pub managed_worktrees: Vec<ManagedWorktreeLeaseSnapshot>,
4960    #[serde(default, skip_serializing_if = "Option::is_none")]
4961    pub launch_inventory: Option<LaunchInventory>,
4962    #[serde(
4963        default,
4964        skip_serializing_if = "Vec::is_empty",
4965        deserialize_with = "deserialize_agent_progress_entries"
4966    )]
4967    pub agent_progress: Vec<SessionAgentProgress>,
4968}
4969
4970impl NodeSnapshot {
4971    pub fn requires_child_environment_profile_capability(&self) -> bool {
4972        self.session_records
4973            .iter()
4974            .any(|record| record.environment_profile.is_some())
4975            || self.launch_inventory.as_ref().is_some_and(|inventory| {
4976                inventory.spawn_profiles.as_ref().is_some_and(|profiles| {
4977                    profiles
4978                        .iter()
4979                        .any(|profile| profile.environment_profile.is_some())
4980                })
4981            })
4982    }
4983
4984    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
4985        self.session_records
4986            .iter()
4987            .any(|record| record.bundle.is_some())
4988    }
4989
4990    pub fn requires_history_context_pack_capability(&self) -> bool {
4991        self.session_records.iter().any(|record| {
4992            record.context_id.is_some() || record.context.is_some()
4993        })
4994    }
4995}
4996
4997fn deserialize_agent_progress_entries<'de, D>(
4998    deserializer: D,
4999) -> Result<Vec<SessionAgentProgress>, D::Error>
5000where
5001    D: Deserializer<'de>,
5002{
5003    struct AgentProgressEntriesVisitor;
5004
5005    impl<'de> Visitor<'de> for AgentProgressEntriesVisitor {
5006        type Value = Vec<SessionAgentProgress>;
5007
5008        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5009            write!(
5010                formatter,
5011                "at most {MAX_AGENT_PROGRESS_ENTRIES} bounded agent progress entries",
5012            )
5013        }
5014
5015        fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
5016        where
5017            A: SeqAccess<'de>,
5018        {
5019            let mut entries = Vec::with_capacity(
5020                sequence
5021                    .size_hint()
5022                    .unwrap_or(0)
5023                    .min(MAX_AGENT_PROGRESS_ENTRIES),
5024            );
5025            while let Some(value) = sequence.next_element::<serde_json::Value>()? {
5026                if entries.len() == MAX_AGENT_PROGRESS_ENTRIES {
5027                    continue;
5028                }
5029                let Ok(encoded) = serde_json::to_vec(&value) else {
5030                    continue;
5031                };
5032                if encoded.len() > MAX_AGENT_PROGRESS_ENTRY_BYTES {
5033                    continue;
5034                }
5035                let Ok(entry) = serde_json::from_value::<SessionAgentProgress>(value) else {
5036                    continue;
5037                };
5038                if entries.iter().any(|existing: &SessionAgentProgress| {
5039                    existing.address == entry.address
5040                }) {
5041                    continue;
5042                }
5043                entries.push(entry);
5044            }
5045            Ok(entries)
5046        }
5047    }
5048
5049    deserializer.deserialize_seq(AgentProgressEntriesVisitor)
5050}
5051
5052fn deserialize_history_discovery_limit<'de, D>(deserializer: D) -> Result<u16, D::Error>
5053where
5054    D: Deserializer<'de>,
5055{
5056    let limit = u16::deserialize(deserializer)?;
5057    if !(1..=HISTORY_DISCOVERY_LIMIT_MAX).contains(&limit) {
5058        return Err(serde::de::Error::custom(
5059            "history discovery limit is outside the supported bounded range",
5060        ));
5061    }
5062    Ok(limit)
5063}
5064
5065fn deserialize_native_session_catalog_limit<'de, D>(deserializer: D) -> Result<u16, D::Error>
5066where
5067    D: Deserializer<'de>,
5068{
5069    let limit = u16::deserialize(deserializer)?;
5070    if !(1..=NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
5071        return Err(serde::de::Error::custom(
5072            "native session catalog limit is outside the supported bounded range",
5073        ));
5074    }
5075    Ok(limit)
5076}
5077
5078fn deserialize_native_session_catalog_entries<'de, D>(
5079    deserializer: D,
5080) -> Result<Vec<NativeSessionCatalogEntry>, D::Error>
5081where
5082    D: Deserializer<'de>,
5083{
5084    let entries = Vec::<NativeSessionCatalogEntry>::deserialize(deserializer)?;
5085    if entries.len() > usize::from(NATIVE_SESSION_CATALOG_LIMIT_MAX) {
5086        return Err(serde::de::Error::custom(
5087            "native session catalog exceeds the supported bounded range",
5088        ));
5089    }
5090    for (index, entry) in entries.iter().enumerate() {
5091        entry.validate().map_err(serde::de::Error::custom)?;
5092        if entries[..index]
5093            .iter()
5094            .any(|existing| existing.selection_id == entry.selection_id)
5095        {
5096            return Err(serde::de::Error::custom(
5097                "native session catalog contains a duplicate selection ID",
5098            ));
5099        }
5100    }
5101    Ok(entries)
5102}
5103
5104fn validate_native_session_catalog_entries(
5105    route: &NativeSessionCatalogRoute,
5106    entries: &[NativeSessionCatalogEntry],
5107) -> Result<(), &'static str> {
5108    if entries.len() > usize::from(NATIVE_SESSION_CATALOG_LIMIT_MAX) {
5109        return Err("native session catalog exceeds the bounded entry limit");
5110    }
5111    for (index, entry) in entries.iter().enumerate() {
5112        entry.validate_for_route(route)?;
5113        if entries[..index]
5114            .iter()
5115            .any(|existing| existing.selection_id == entry.selection_id)
5116        {
5117            return Err("native session catalog contains duplicate selections");
5118        }
5119        if entry.record_id.as_ref().is_some_and(|record_id| {
5120            entries[..index]
5121                .iter()
5122                .any(|existing| existing.record_id.as_ref() == Some(record_id))
5123        }) {
5124            return Err("native session catalog contains duplicate managed records");
5125        }
5126    }
5127    Ok(())
5128}
5129
5130fn deserialize_optional_native_session_catalog_summary<'de, D>(
5131    deserializer: D,
5132) -> Result<Option<NativeSessionCatalogSummary>, D::Error>
5133where
5134    D: Deserializer<'de>,
5135{
5136    let summary = Option::<NativeSessionCatalogSummary>::deserialize(deserializer)?;
5137    if let Some(summary) = summary.as_ref() {
5138        summary.validate().map_err(serde::de::Error::custom)?;
5139    }
5140    Ok(summary)
5141}
5142
5143fn deserialize_native_session_catalog_page<'de, D>(
5144    deserializer: D,
5145) -> Result<NativeSessionCatalogPage, D::Error>
5146where
5147    D: Deserializer<'de>,
5148{
5149    let page = NativeSessionCatalogPage::deserialize(deserializer)?;
5150    page.validate().map_err(serde::de::Error::custom)?;
5151    Ok(page)
5152}
5153
5154fn deserialize_native_session_preview_limit<'de, D>(deserializer: D) -> Result<u16, D::Error>
5155where
5156    D: Deserializer<'de>,
5157{
5158    let limit = u16::deserialize(deserializer)?;
5159    if !(1..=NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX).contains(&limit) {
5160        return Err(serde::de::Error::custom(
5161            "native session preview limit is outside the supported bounded range",
5162        ));
5163    }
5164    Ok(limit)
5165}
5166
5167fn deserialize_native_session_preview<'de, D>(
5168    deserializer: D,
5169) -> Result<NativeSessionPreview, D::Error>
5170where
5171    D: Deserializer<'de>,
5172{
5173    let preview = NativeSessionPreview::deserialize(deserializer)?;
5174    preview.validate().map_err(serde::de::Error::custom)?;
5175    Ok(preview)
5176}
5177
5178fn deserialize_session_record_preview<'de, D>(
5179    deserializer: D,
5180) -> Result<SessionRecordPreview, D::Error>
5181where
5182    D: Deserializer<'de>,
5183{
5184    let preview = SessionRecordPreview::deserialize(deserializer)?;
5185    preview.validate().map_err(serde::de::Error::custom)?;
5186    Ok(preview)
5187}
5188
5189fn deserialize_history_candidate_id<'de, D>(deserializer: D) -> Result<String, D::Error>
5190where
5191    D: Deserializer<'de>,
5192{
5193    let candidate_id = String::deserialize(deserializer)?;
5194    gate4agent_types::validate_candidate_id(&candidate_id)
5195        .map_err(serde::de::Error::custom)?;
5196    Ok(candidate_id)
5197}
5198
5199fn deserialize_optional_history_candidate_id<'de, D>(
5200    deserializer: D,
5201) -> Result<Option<String>, D::Error>
5202where
5203    D: Deserializer<'de>,
5204{
5205    let candidate_id = Option::<String>::deserialize(deserializer)?;
5206    if let Some(candidate_id) = candidate_id.as_deref() {
5207        gate4agent_types::validate_candidate_id(candidate_id)
5208            .map_err(serde::de::Error::custom)?;
5209    }
5210    Ok(candidate_id)
5211}
5212
5213fn deserialize_history_session_id<'de, D>(deserializer: D) -> Result<String, D::Error>
5214where
5215    D: Deserializer<'de>,
5216{
5217    let session_id = String::deserialize(deserializer)?;
5218    let validation = gate4agent_types::HistorySessionRecord {
5219        session_id: session_id.clone(),
5220        title: None,
5221        cwd: None,
5222        model: None,
5223        message_count: 0,
5224        completed_turn_count: None,
5225        total_tokens: 0,
5226        messages: Vec::new(),
5227    };
5228    validation.validate().map_err(serde::de::Error::custom)?;
5229    Ok(session_id)
5230}
5231
5232fn deserialize_history_candidates<'de, D>(
5233    deserializer: D,
5234) -> Result<Vec<HistoryCandidateSummary>, D::Error>
5235where
5236    D: Deserializer<'de>,
5237{
5238    let candidates = Vec::<HistoryCandidateSummary>::deserialize(deserializer)?;
5239    if candidates.len() > usize::from(HISTORY_DISCOVERY_LIMIT_MAX) {
5240        return Err(serde::de::Error::custom(
5241            "history candidate count exceeds the discovery limit",
5242        ));
5243    }
5244    for (index, candidate) in candidates.iter().enumerate() {
5245        candidate.validate().map_err(serde::de::Error::custom)?;
5246        if candidates[..index]
5247            .iter()
5248            .any(|existing| existing.id == candidate.id)
5249        {
5250            return Err(serde::de::Error::custom(
5251                "history candidates contain a duplicate candidate ID",
5252            ));
5253        }
5254    }
5255    Ok(candidates)
5256}
5257
5258fn deserialize_managed_worktree_leases<'de, D>(
5259    deserializer: D,
5260) -> Result<Vec<ManagedWorktreeLeaseSnapshot>, D::Error>
5261where
5262    D: Deserializer<'de>,
5263{
5264    struct ManagedWorktreeLeasesVisitor;
5265
5266    impl<'de> Visitor<'de> for ManagedWorktreeLeasesVisitor {
5267        type Value = Vec<ManagedWorktreeLeaseSnapshot>;
5268
5269        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5270            write!(
5271                formatter,
5272                "at most {MAX_MANAGED_WORKTREE_LEASES} managed worktree leases",
5273            )
5274        }
5275
5276        fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
5277        where
5278            A: SeqAccess<'de>,
5279        {
5280            let mut leases = Vec::with_capacity(
5281                sequence
5282                    .size_hint()
5283                    .unwrap_or(0)
5284                    .min(MAX_MANAGED_WORKTREE_LEASES),
5285            );
5286            while let Some(lease) = sequence.next_element::<ManagedWorktreeLeaseSnapshot>()? {
5287                if leases.len() == MAX_MANAGED_WORKTREE_LEASES {
5288                    return Err(serde::de::Error::invalid_length(leases.len() + 1, &self));
5289                }
5290                if leases.iter().any(|existing: &ManagedWorktreeLeaseSnapshot| {
5291                    existing.lease_id == lease.lease_id
5292                }) {
5293                    return Err(serde::de::Error::custom(
5294                        "managed worktree snapshot contains a duplicate lease ID",
5295                    ));
5296                }
5297                if leases.iter().any(|existing: &ManagedWorktreeLeaseSnapshot| {
5298                    existing.workspace_id == lease.workspace_id
5299                }) {
5300                    return Err(serde::de::Error::custom(
5301                        "managed worktree snapshot contains a duplicate workspace ID",
5302                    ));
5303                }
5304                leases.push(lease);
5305            }
5306            Ok(leases)
5307        }
5308    }
5309
5310    deserializer.deserialize_seq(ManagedWorktreeLeasesVisitor)
5311}
5312
5313#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5314pub struct ClientHello {
5315    pub build_stamp: String,
5316    pub role: ClientRole,
5317    pub client_nonce: [u8; NODE_AUTH_NONCE_BYTES],
5318    #[serde(default, skip_serializing_if = "Option::is_none")]
5319    pub compatibility: Option<ClientCompatibilityOffer>,
5320}
5321
5322impl ClientHello {
5323    pub fn new(role: ClientRole, client_nonce: [u8; NODE_AUTH_NONCE_BYTES]) -> Self {
5324        Self {
5325            build_stamp: BUILD_STAMP.to_owned(),
5326            role,
5327            client_nonce,
5328            compatibility: None,
5329        }
5330    }
5331
5332    pub fn negotiating(
5333        role: ClientRole,
5334        client_nonce: [u8; NODE_AUTH_NONCE_BYTES],
5335        compatibility: ClientCompatibilityOffer,
5336    ) -> Self {
5337        Self {
5338            build_stamp: BUILD_STAMP.to_owned(),
5339            role,
5340            client_nonce,
5341            compatibility: Some(compatibility),
5342        }
5343    }
5344}
5345
5346#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5347pub struct ServerChallenge {
5348    pub build_stamp: String,
5349    pub server_nonce: [u8; NODE_AUTH_NONCE_BYTES],
5350    pub server_proof: [u8; NODE_AUTH_PROOF_BYTES],
5351    #[serde(default, skip_serializing_if = "Option::is_none")]
5352    pub compatibility: Option<NegotiatedNodeCompatibility>,
5353}
5354
5355#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5356pub struct ClientAuthentication {
5357    pub client_proof: [u8; NODE_AUTH_PROOF_BYTES],
5358}
5359
5360#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5361pub struct ControllerState {
5362    pub connection_id: u64,
5363    pub lease_remaining_ms: u64,
5364}
5365
5366#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5367#[serde(transparent)]
5368pub struct DeliveryBlobDigestV1(String);
5369
5370#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5371#[serde(transparent)]
5372pub struct DeliveryManifestDigestV2(String);
5373
5374macro_rules! delivery_digest_impl {
5375    ($type:ident, $label:literal) => {
5376        impl $type {
5377            pub fn new(value: impl Into<String>) -> Result<Self, DeliveryDigestError> {
5378                let value = value.into();
5379                let hex = value.strip_prefix("sha256:").ok_or(DeliveryDigestError($label))?;
5380                if hex.len() != 64
5381                    || !hex.bytes().all(|byte| {
5382                        byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
5383                    })
5384                {
5385                    return Err(DeliveryDigestError($label));
5386                }
5387                Ok(Self(value))
5388            }
5389
5390            pub fn as_str(&self) -> &str {
5391                &self.0
5392            }
5393        }
5394
5395        impl fmt::Display for $type {
5396            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5397                formatter.write_str(self.as_str())
5398            }
5399        }
5400
5401        impl FromStr for $type {
5402            type Err = DeliveryDigestError;
5403
5404            fn from_str(value: &str) -> Result<Self, Self::Err> {
5405                Self::new(value)
5406            }
5407        }
5408
5409        impl<'de> Deserialize<'de> for $type {
5410            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5411            where
5412                D: Deserializer<'de>,
5413            {
5414                Self::new(String::deserialize(deserializer)?)
5415                    .map_err(serde::de::Error::custom)
5416            }
5417        }
5418    };
5419}
5420
5421delivery_digest_impl!(DeliveryBlobDigestV1, "delivery blob");
5422delivery_digest_impl!(DeliveryManifestDigestV2, "delivery manifest");
5423
5424#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
5425#[error("{0} digest must be sha256: followed by exactly 64 lowercase hexadecimal characters")]
5426pub struct DeliveryDigestError(&'static str);
5427
5428#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5429#[serde(transparent)]
5430pub struct DeliveryStageId(String);
5431
5432impl DeliveryStageId {
5433    pub fn new(value: impl Into<String>) -> Result<Self, DeliveryStageIdError> {
5434        let value = value.into();
5435        let hex = value
5436            .strip_prefix("delivery-stage-")
5437            .ok_or(DeliveryStageIdError)?;
5438        if hex.len() != DELIVERY_STAGE_NONCE_BYTES * 2
5439            || !hex.bytes().all(|byte| {
5440                byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
5441            })
5442        {
5443            return Err(DeliveryStageIdError);
5444        }
5445        Ok(Self(value))
5446    }
5447
5448    pub fn from_nonce(nonce: [u8; DELIVERY_STAGE_NONCE_BYTES]) -> Self {
5449        let mut value = String::with_capacity(15 + DELIVERY_STAGE_NONCE_BYTES * 2);
5450        value.push_str("delivery-stage-");
5451        for byte in nonce {
5452            use std::fmt::Write as _;
5453            write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
5454        }
5455        Self(value)
5456    }
5457
5458    pub fn as_str(&self) -> &str {
5459        &self.0
5460    }
5461}
5462
5463impl fmt::Display for DeliveryStageId {
5464    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5465        formatter.write_str(self.as_str())
5466    }
5467}
5468
5469impl<'de> Deserialize<'de> for DeliveryStageId {
5470    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5471    where
5472        D: Deserializer<'de>,
5473    {
5474        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
5475    }
5476}
5477
5478#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
5479#[error("delivery stage ID must be delivery-stage- followed by exactly 32 lowercase hexadecimal characters")]
5480pub struct DeliveryStageIdError;
5481
5482#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5483#[serde(transparent)]
5484pub struct DeliveryRelativePathV2(String);
5485
5486impl DeliveryRelativePathV2 {
5487    pub fn new(value: impl Into<String>) -> Result<Self, DeliveryRelativePathError> {
5488        let value = value.into();
5489        if value.is_empty()
5490            || value.len() > MAX_DELIVERY_RELATIVE_PATH_BYTES
5491            || value.starts_with('/')
5492            || value.ends_with('/')
5493            || value.contains('\\')
5494            || value.chars().any(char::is_control)
5495            || value
5496                .split('/')
5497                .any(|part| !delivery_path_component_is_portable(part))
5498        {
5499            return Err(DeliveryRelativePathError);
5500        }
5501        Ok(Self(value))
5502    }
5503
5504    pub fn as_str(&self) -> &str {
5505        &self.0
5506    }
5507}
5508
5509fn delivery_path_component_is_portable(component: &str) -> bool {
5510    let invalid_character = component.chars().any(|character| {
5511        character <= '\u{1f}'
5512            || matches!(character, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
5513    });
5514    let stem = component.split('.').next().unwrap_or(component);
5515    let uppercase_stem = stem.to_ascii_uppercase();
5516    let reserved = matches!(uppercase_stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
5517        || delivery_reserved_numbered_name(&uppercase_stem, "COM")
5518        || delivery_reserved_numbered_name(&uppercase_stem, "LPT");
5519    !component.is_empty()
5520        && component != "."
5521        && component != ".."
5522        && !component.ends_with('.')
5523        && !component.ends_with(' ')
5524        && !invalid_character
5525        && !reserved
5526}
5527
5528fn delivery_reserved_numbered_name(value: &str, prefix: &str) -> bool {
5529    value.strip_prefix(prefix).is_some_and(|suffix| {
5530        suffix.len() == 1 && matches!(suffix.as_bytes()[0], b'1'..=b'9')
5531    })
5532}
5533
5534impl fmt::Display for DeliveryRelativePathV2 {
5535    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5536        formatter.write_str(self.as_str())
5537    }
5538}
5539
5540impl<'de> Deserialize<'de> for DeliveryRelativePathV2 {
5541    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5542    where
5543        D: Deserializer<'de>,
5544    {
5545        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
5546    }
5547}
5548
5549#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
5550#[error("delivery path must be a safe forward-slash relative path of at most 512 UTF-8 bytes")]
5551pub struct DeliveryRelativePathError;
5552
5553#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5554#[serde(rename_all = "kebab-case")]
5555pub enum DeliveryScopeV2 {
5556    Workspace,
5557    Session,
5558}
5559
5560#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5561#[serde(rename_all = "kebab-case")]
5562pub enum DeliveryComponentKindV2 {
5563    Skill,
5564    PluginManifest,
5565    Prompt,
5566    Instructions,
5567    AgentDefinition,
5568    Command,
5569    File,
5570    Template,
5571    McpDeclaration,
5572}
5573
5574#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5575#[serde(deny_unknown_fields)]
5576pub struct DeliveryBlobReceiptV1 {
5577    pub digest: DeliveryBlobDigestV1,
5578    pub byte_len: u64,
5579}
5580
5581impl DeliveryBlobReceiptV1 {
5582    pub fn new(
5583        digest: DeliveryBlobDigestV1,
5584        byte_len: u64,
5585    ) -> Result<Self, DeliveryContractError> {
5586        if byte_len > MAX_DELIVERY_FILE_BYTES as u64 {
5587            return Err(DeliveryContractError::FileTooLarge);
5588        }
5589        Ok(Self { digest, byte_len })
5590    }
5591}
5592
5593impl<'de> Deserialize<'de> for DeliveryBlobReceiptV1 {
5594    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5595    where
5596        D: Deserializer<'de>,
5597    {
5598        #[derive(Deserialize)]
5599        #[serde(deny_unknown_fields)]
5600        struct Wire {
5601            digest: DeliveryBlobDigestV1,
5602            byte_len: u64,
5603        }
5604
5605        let wire = Wire::deserialize(deserializer)?;
5606        Self::new(wire.digest, wire.byte_len).map_err(serde::de::Error::custom)
5607    }
5608}
5609
5610#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5611#[serde(deny_unknown_fields)]
5612pub struct DeliveryComponentV2 {
5613    pub kind: DeliveryComponentKindV2,
5614    pub scope: DeliveryScopeV2,
5615    pub relative_path: DeliveryRelativePathV2,
5616    pub blob: DeliveryBlobReceiptV1,
5617}
5618
5619#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
5620#[serde(deny_unknown_fields)]
5621pub struct DeliveryBundleManifestV2 {
5622    pub bundle_id: SpawnBundleId,
5623    pub revision: SpawnBundleRevision,
5624    pub bundle_digest: SpawnBundleDigest,
5625    pub manifest_digest: DeliveryManifestDigestV2,
5626    pub components: Vec<DeliveryComponentV2>,
5627}
5628
5629impl DeliveryBundleManifestV2 {
5630    pub fn validate(&self) -> Result<(), DeliveryContractError> {
5631        if self.components.is_empty() {
5632            return Err(DeliveryContractError::EmptyManifest);
5633        }
5634        if self.components.len() > MAX_DELIVERY_FILES {
5635            return Err(DeliveryContractError::TooManyFiles);
5636        }
5637        let mut total = 0_u64;
5638        let mut previous_path: Option<&str> = None;
5639        let mut folded_paths = std::collections::BTreeSet::new();
5640        for component in &self.components {
5641            let path = component.relative_path.as_str();
5642            if previous_path.is_some_and(|previous| previous >= path) {
5643                return Err(DeliveryContractError::ComponentsNotOrdered);
5644            }
5645            previous_path = Some(path);
5646            let folded = path.to_lowercase();
5647            if !folded_paths.insert(folded) {
5648                return Err(DeliveryContractError::CaseFoldPathCollision);
5649            }
5650            total = total
5651                .checked_add(component.blob.byte_len)
5652                .ok_or(DeliveryContractError::TotalTooLarge)?;
5653            if total > MAX_DELIVERY_TOTAL_BYTES as u64 {
5654                return Err(DeliveryContractError::TotalTooLarge);
5655            }
5656        }
5657        Ok(())
5658    }
5659
5660    pub fn canonical_manifest_digest_material(&self) -> Vec<u8> {
5661        fn push_field(material: &mut Vec<u8>, value: &[u8]) {
5662            material.extend_from_slice(&(value.len() as u32).to_be_bytes());
5663            material.extend_from_slice(value);
5664        }
5665
5666        let mut material = Vec::new();
5667        material.extend_from_slice(b"g4a-delivery-manifest-v2\0");
5668        push_field(&mut material, self.bundle_id.as_str().as_bytes());
5669        push_field(&mut material, self.revision.as_str().as_bytes());
5670        push_field(&mut material, self.bundle_digest.as_str().as_bytes());
5671        material.extend_from_slice(&(self.components.len() as u32).to_be_bytes());
5672        for component in &self.components {
5673            material.push(component.kind.canonical_tag());
5674            material.push(component.scope.canonical_tag());
5675            push_field(&mut material, component.relative_path.as_str().as_bytes());
5676            push_field(&mut material, component.blob.digest.as_str().as_bytes());
5677            material.extend_from_slice(&component.blob.byte_len.to_be_bytes());
5678        }
5679        material
5680    }
5681}
5682
5683impl DeliveryComponentKindV2 {
5684    pub fn canonical_tag(self) -> u8 {
5685        match self {
5686            Self::Skill => 1,
5687            Self::PluginManifest => 2,
5688            Self::Prompt => 3,
5689            Self::Instructions => 4,
5690            Self::AgentDefinition => 5,
5691            Self::Command => 6,
5692            Self::File => 7,
5693            Self::Template => 8,
5694            Self::McpDeclaration => 9,
5695        }
5696    }
5697}
5698
5699impl DeliveryScopeV2 {
5700    pub fn canonical_tag(self) -> u8 {
5701        match self {
5702            Self::Workspace => 1,
5703            Self::Session => 2,
5704        }
5705    }
5706}
5707
5708impl<'de> Deserialize<'de> for DeliveryBundleManifestV2 {
5709    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5710    where
5711        D: Deserializer<'de>,
5712    {
5713        #[derive(Deserialize)]
5714        #[serde(deny_unknown_fields)]
5715        struct Wire {
5716            bundle_id: SpawnBundleId,
5717            revision: SpawnBundleRevision,
5718            bundle_digest: SpawnBundleDigest,
5719            manifest_digest: DeliveryManifestDigestV2,
5720            components: Vec<DeliveryComponentV2>,
5721        }
5722
5723        let wire = Wire::deserialize(deserializer)?;
5724        let manifest = Self {
5725            bundle_id: wire.bundle_id,
5726            revision: wire.revision,
5727            bundle_digest: wire.bundle_digest,
5728            manifest_digest: wire.manifest_digest,
5729            components: wire.components,
5730        };
5731        manifest.validate().map_err(serde::de::Error::custom)?;
5732        Ok(manifest)
5733    }
5734}
5735
5736#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
5737#[serde(transparent)]
5738pub struct DeliveryBlobChunkHexV1(String);
5739
5740impl DeliveryBlobChunkHexV1 {
5741    pub fn new(value: impl Into<String>) -> Result<Self, DeliveryContractError> {
5742        let value = value.into();
5743        if value.is_empty()
5744            || value.len() > MAX_DELIVERY_CHUNK_RAW_BYTES * 2
5745            || value.len() % 2 != 0
5746            || !value.bytes().all(|byte| {
5747                byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
5748            })
5749        {
5750            return Err(DeliveryContractError::InvalidChunkHex);
5751        }
5752        Ok(Self(value))
5753    }
5754
5755    pub fn as_str(&self) -> &str {
5756        &self.0
5757    }
5758
5759    pub fn raw_len(&self) -> usize {
5760        self.0.len() / 2
5761    }
5762
5763    pub fn decode(&self) -> Vec<u8> {
5764        fn nibble(value: u8) -> u8 {
5765            match value {
5766                b'0'..=b'9' => value - b'0',
5767                b'a'..=b'f' => value - b'a' + 10,
5768                _ => unreachable!("validated delivery chunk contains only lowercase hex"),
5769            }
5770        }
5771
5772        self.0
5773            .as_bytes()
5774            .chunks_exact(2)
5775            .map(|pair| (nibble(pair[0]) << 4) | nibble(pair[1]))
5776            .collect()
5777    }
5778}
5779
5780impl<'de> Deserialize<'de> for DeliveryBlobChunkHexV1 {
5781    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5782    where
5783        D: Deserializer<'de>,
5784    {
5785        Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
5786    }
5787}
5788
5789#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
5790pub enum DeliveryContractError {
5791    #[error("delivery manifest must contain at least one component")]
5792    EmptyManifest,
5793    #[error("delivery manifest exceeds the file count limit")]
5794    TooManyFiles,
5795    #[error("delivery file exceeds the byte limit")]
5796    FileTooLarge,
5797    #[error("delivery manifest exceeds the total byte limit")]
5798    TotalTooLarge,
5799    #[error("delivery components must be strictly ordered by relative path")]
5800    ComponentsNotOrdered,
5801    #[error("delivery component paths collide under case folding")]
5802    CaseFoldPathCollision,
5803    #[error("delivery blob receipts must be strictly ordered and unique by digest")]
5804    BlobReceiptsNotOrdered,
5805    #[error("delivery chunk must encode 1 through 49152 raw bytes as lowercase hexadecimal")]
5806    InvalidChunkHex,
5807    #[error("delivery chunk exceeds the declared file bounds")]
5808    ChunkOutOfBounds,
5809}
5810
5811fn validate_delivery_blob_receipts(
5812    blobs: &[DeliveryBlobReceiptV1],
5813) -> Result<(), DeliveryContractError> {
5814    if blobs.len() > MAX_DELIVERY_FILES {
5815        return Err(DeliveryContractError::TooManyFiles);
5816    }
5817    let mut previous: Option<&DeliveryBlobDigestV1> = None;
5818    for blob in blobs {
5819        if previous.is_some_and(|digest| digest >= &blob.digest) {
5820            return Err(DeliveryContractError::BlobReceiptsNotOrdered);
5821        }
5822        previous = Some(&blob.digest);
5823    }
5824    Ok(())
5825}
5826
5827fn deserialize_delivery_blob_receipts<'de, D>(
5828    deserializer: D,
5829) -> Result<Vec<DeliveryBlobReceiptV1>, D::Error>
5830where
5831    D: Deserializer<'de>,
5832{
5833    let blobs = Vec::<DeliveryBlobReceiptV1>::deserialize(deserializer)?;
5834    validate_delivery_blob_receipts(&blobs).map_err(serde::de::Error::custom)?;
5835    Ok(blobs)
5836}
5837
5838fn deserialize_delivery_blob_digests<'de, D>(
5839    deserializer: D,
5840) -> Result<Vec<DeliveryBlobDigestV1>, D::Error>
5841where
5842    D: Deserializer<'de>,
5843{
5844    let digests = Vec::<DeliveryBlobDigestV1>::deserialize(deserializer)?;
5845    if digests.len() > MAX_DELIVERY_FILES
5846        || digests.windows(2).any(|pair| pair[0] >= pair[1])
5847    {
5848        return Err(serde::de::Error::custom(
5849            "delivery blob digests must be bounded, strictly ordered, and unique",
5850        ));
5851    }
5852    Ok(digests)
5853}
5854
5855#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5856#[serde(deny_unknown_fields)]
5857pub struct DeliveryCommitReceiptV1 {
5858    pub bundle_id: SpawnBundleId,
5859    pub revision: SpawnBundleRevision,
5860    pub bundle_digest: SpawnBundleDigest,
5861    pub manifest_digest: DeliveryManifestDigestV2,
5862    #[serde(deserialize_with = "deserialize_delivery_blob_receipts")]
5863    pub blobs: Vec<DeliveryBlobReceiptV1>,
5864}
5865
5866#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
5867pub struct NodeCursor {
5868    pub incarnation_id: NodeIncarnationId,
5869    pub sequence: u64,
5870}
5871
5872#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5873pub struct NodeHello {
5874    pub build_stamp: String,
5875    pub incarnation_id: NodeIncarnationId,
5876    pub connection_id: u64,
5877    pub role: ClientRole,
5878    pub event_sequence: u64,
5879    pub controller: Option<ControllerState>,
5880    pub snapshot: NodeSnapshot,
5881    #[serde(default, skip_serializing_if = "Option::is_none")]
5882    pub compatibility: Option<NegotiatedNodeCompatibility>,
5883}
5884
5885#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5886pub struct RequestEnvelope {
5887    pub request_id: u64,
5888    pub request: NodeRequest,
5889}
5890
5891#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5892#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5893pub enum NodeRequest {
5894    Snapshot,
5895    Resync { after_sequence: u64 },
5896    ArmHarnessMcpReservation {
5897        reservation_id: HarnessMcpReservationId,
5898        activation_digest: HarnessMcpActivationDigest,
5899        spawn_spec: SpawnSpec,
5900        launch: HarnessMcpLaunchV1,
5901        expires_at_unix_ms: u64,
5902    },
5903    SpawnSpecWithHarnessMcp {
5904        reservation_id: HarnessMcpReservationId,
5905        activation_digest: HarnessMcpActivationDigest,
5906        spec: SpawnSpec,
5907        deadline_unix_ms: u64,
5908    },
5909    ActivateHarnessMcpReservation {
5910        reservation_id: HarnessMcpReservationId,
5911        activation_digest: HarnessMcpActivationDigest,
5912        record_id: SessionRecordId,
5913        session: SessionAddress,
5914    },
5915    AbortHarnessMcpReservation {
5916        reservation_id: HarnessMcpReservationId,
5917        activation_digest: HarnessMcpActivationDigest,
5918    },
5919    PutHarnessMcpReplyChunk {
5920        reservation_id: HarnessMcpReservationId,
5921        activation_digest: HarnessMcpActivationDigest,
5922        record_id: SessionRecordId,
5923        session: SessionAddress,
5924        call_id: HarnessMcpCallId,
5925        offset: u32,
5926        final_chunk: bool,
5927        chunk_hex: HarnessMcpReplyChunkHexV1,
5928    },
5929    RejectHarnessMcpCall {
5930        reservation_id: HarnessMcpReservationId,
5931        activation_digest: HarnessMcpActivationDigest,
5932        record_id: SessionRecordId,
5933        session: SessionAddress,
5934        call_id: HarnessMcpCallId,
5935        reason: HarnessMcpRejectReasonV1,
5936    },
5937    BeginDeliveryStage {
5938        manifest: DeliveryBundleManifestV2,
5939    },
5940    PutDeliveryBlobChunk {
5941        stage_id: DeliveryStageId,
5942        blob_digest: DeliveryBlobDigestV1,
5943        offset: u64,
5944        chunk_hex: DeliveryBlobChunkHexV1,
5945    },
5946    CommitDeliveryStage {
5947        stage_id: DeliveryStageId,
5948    },
5949    AbortDeliveryStage {
5950        stage_id: DeliveryStageId,
5951    },
5952    BrowseHostDirectories {
5953        directory: Option<OpaqueHostPath>,
5954        after: Option<OpaqueHostPath>,
5955    },
5956    InspectWorkspace { workspace_id: WorkspaceId },
5957    ReadWorkspaceFile {
5958        workspace_id: WorkspaceId,
5959        path: RepositoryPath,
5960    },
5961    WriteWorkspaceFile {
5962        workspace_id: WorkspaceId,
5963        path: RepositoryPath,
5964        expected_revision: WorkspaceFileRevision,
5965        #[serde(deserialize_with = "deserialize_workspace_file_text")]
5966        text: String,
5967    },
5968    CreateWorkspaceFile {
5969        workspace_id: WorkspaceId,
5970        path: RepositoryPath,
5971    },
5972    CreateWorkspaceDirectory {
5973        workspace_id: WorkspaceId,
5974        path: RepositoryPath,
5975    },
5976    ReadGitHistory {
5977        workspace_id: WorkspaceId,
5978        #[serde(default, skip_serializing_if = "Option::is_none")]
5979        path: Option<RepositoryPath>,
5980        before: Option<GitObjectId>,
5981        #[serde(deserialize_with = "deserialize_git_history_limit")]
5982        limit: u16,
5983    },
5984    ReadGitDiff {
5985        workspace_id: WorkspaceId,
5986        request: GitDiffRequest,
5987    },
5988    AcquireController { lease_ms: u64 },
5989    ReleaseController,
5990    RegisterWorkspace {
5991        workspace_id: WorkspaceId,
5992        root: OpaqueHostPath,
5993    },
5994    CreateStandaloneWorkspace {
5995        workspace_id: WorkspaceId,
5996        root: OpaqueHostPath,
5997        #[serde(default, deserialize_with = "deserialize_optional_initial_branch")]
5998        initial_branch: Option<String>,
5999    },
6000    UnregisterWorkspace {
6001        workspace_id: WorkspaceId,
6002    },
6003    CreateWorktree {
6004        source_workspace_id: WorkspaceId,
6005        workspace_id: WorkspaceId,
6006        target_root: OpaqueHostPath,
6007        branch: String,
6008        base: Option<String>,
6009    },
6010    RemoveWorktree {
6011        source_workspace_id: WorkspaceId,
6012        target_root: OpaqueHostPath,
6013    },
6014    Spawn {
6015        workspace_id: WorkspaceId,
6016        provider: AgentId,
6017        mode: SessionMode,
6018        terminal_size: TerminalSize,
6019        initial_prompt: Option<String>,
6020    },
6021    SpawnSpec {
6022        spec: SpawnSpec,
6023    },
6024    SpawnManagedWorktree {
6025        request: ManagedWorktreeSpawnRequest,
6026    },
6027    SpawnManagedWorktreeV2 {
6028        request: ManagedWorktreeSpawnRequestV2,
6029    },
6030    CleanupManagedWorktree {
6031        lease_id: ManagedWorktreeLeaseId,
6032    },
6033    Resume {
6034        session: SessionAddress,
6035        terminal_size: TerminalSize,
6036        initial_prompt: Option<String>,
6037    },
6038    RenameSessionRecord {
6039        record_id: SessionRecordId,
6040        display_name: String,
6041    },
6042    SetSessionTask {
6043        record_id: SessionRecordId,
6044        expected_revision: u64,
6045        target: SessionTaskTargetV1,
6046    },
6047    IndexProviderSession {
6048        workspace_id: WorkspaceId,
6049        provider: AgentId,
6050        identity: ProviderSessionIdentity,
6051        display_name: String,
6052    },
6053    IndexNativeSession {
6054        selection: NativeSessionSelection,
6055        display_name: String,
6056    },
6057    ResumeSessionRecord {
6058        record_id: SessionRecordId,
6059        terminal_size: TerminalSize,
6060        initial_prompt: Option<String>,
6061    },
6062    ForgetSessionRecord {
6063        record_id: SessionRecordId,
6064    },
6065    CatalogNativeSessions {
6066        route: NativeSessionCatalogRoute,
6067        #[serde(deserialize_with = "deserialize_native_session_catalog_limit")]
6068        limit: u16,
6069    },
6070    PageNativeSessions {
6071        route: NativeSessionCatalogRoute,
6072        window: NativeSessionCatalogWindow,
6073        catalog_revision: u64,
6074        recent_cutoff_unix_ms: u64,
6075        #[serde(default, deserialize_with = "deserialize_optional_history_candidate_id")]
6076        after_selection_id: Option<String>,
6077        #[serde(deserialize_with = "deserialize_native_session_catalog_limit")]
6078        limit: u16,
6079    },
6080    PreviewNativeSession {
6081        selection: NativeSessionSelection,
6082        #[serde(deserialize_with = "deserialize_native_session_preview_limit")]
6083        message_limit: u16,
6084    },
6085    PreviewSessionRecord {
6086        record_id: SessionRecordId,
6087        #[serde(deserialize_with = "deserialize_native_session_preview_limit")]
6088        message_limit: u16,
6089    },
6090    DiscoverHistory {
6091        session: SessionAddress,
6092        #[serde(deserialize_with = "deserialize_history_discovery_limit")]
6093        limit: u16,
6094    },
6095    LoadHistory {
6096        session: SessionAddress,
6097        #[serde(deserialize_with = "deserialize_history_candidate_id")]
6098        candidate_id: String,
6099    },
6100    ExportContextPackForSessionRecord {
6101        record_id: SessionRecordId,
6102        session: SessionAddress,
6103    },
6104    ExportContextPack {
6105        session: SessionAddress,
6106    },
6107    ForgetContextPack {
6108        context_id: SpawnContextId,
6109    },
6110    ResolveDurableContextPack {
6111        context_id: SpawnContextId,
6112    },
6113    ReadContextPack {
6114        digest: SpawnContextDigest,
6115    },
6116    Prompt { session: SessionAddress, text: String },
6117    Paste { session: SessionAddress, text: String },
6118    Input { session: SessionAddress, text: String },
6119    TerminalBytes { session: SessionAddress, bytes: Vec<u8> },
6120    TerminalControl { session: SessionAddress, control: TerminalControl },
6121    Resize { session: SessionAddress, size: TerminalSize },
6122    Interrupt { session: SessionAddress },
6123    Stop { session: SessionAddress, force: bool },
6124    Remove { session: SessionAddress },
6125    /// Answers the `correlation_id` an `AgentStreamChunkKindV1::InteractionPrompt`
6126    /// carries (minted by `correlation::interaction_correlation`).
6127    /// `response` must additionally match the interaction's own kind
6128    /// (`ProviderInteractionResponse::validate_for`); that check needs the
6129    /// live interaction and is the routing layer's job, not this wire's.
6130    ResolveInteraction {
6131        session: SessionAddress,
6132        #[serde(deserialize_with = "deserialize_acp_correlation_id")]
6133        correlation_id: String,
6134        response: ProviderInteractionResponse,
6135    },
6136    SetSessionMode {
6137        session: SessionAddress,
6138        #[serde(deserialize_with = "deserialize_acp_control_id")]
6139        mode_id: String,
6140    },
6141    SetSessionConfigOption {
6142        session: SessionAddress,
6143        #[serde(deserialize_with = "deserialize_acp_control_id")]
6144        option_id: String,
6145        #[serde(deserialize_with = "deserialize_acp_config_value_json")]
6146        value_json: String,
6147    },
6148    SetSessionModel {
6149        session: SessionAddress,
6150        #[serde(deserialize_with = "deserialize_acp_control_id")]
6151        model_id: String,
6152    },
6153    Shutdown,
6154}
6155
6156fn deserialize_workspace_file_text<'de, D>(deserializer: D) -> Result<String, D::Error>
6157where
6158    D: Deserializer<'de>,
6159{
6160    let text = String::deserialize(deserializer)?;
6161    if text.len() > MAX_WORKSPACE_FILE_BYTES {
6162        return Err(serde::de::Error::custom("workspace file text exceeds the byte limit"));
6163    }
6164    Ok(text)
6165}
6166
6167fn deserialize_git_history_limit<'de, D>(deserializer: D) -> Result<u16, D::Error>
6168where
6169    D: Deserializer<'de>,
6170{
6171    let limit = u16::deserialize(deserializer)?;
6172    if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
6173        return Err(serde::de::Error::custom("git history limit is invalid"));
6174    }
6175    Ok(limit)
6176}
6177
6178fn deserialize_optional_initial_branch<'de, D>(
6179    deserializer: D,
6180) -> Result<Option<String>, D::Error>
6181where
6182    D: Deserializer<'de>,
6183{
6184    let branch = Option::<String>::deserialize(deserializer)?;
6185    if let Some(branch) = branch.as_deref() {
6186        if branch.is_empty()
6187            || branch.len() > MAX_REPOSITORY_PATH_BYTES
6188            || branch.starts_with('-')
6189            || branch.chars().any(char::is_control)
6190        {
6191            return Err(serde::de::Error::custom(
6192                "initial branch must be bounded, non-empty, option-safe, and free of control characters",
6193            ));
6194        }
6195    }
6196    Ok(branch)
6197}
6198
6199/// A control character outside the three ACP content is allowed to carry
6200/// verbatim (`\n`, `\r`, `\t`) -- mirrors
6201/// `gate4agent_types::control::validate_text`'s own allowance so free text
6202/// round-tripped through `AgentStreamChunkV1` and `SetSessionConfigOption`
6203/// validates against the same rule the provider event stream already
6204/// applies to it.
6205fn contains_unsafe_control_bytes(value: &str) -> bool {
6206    value
6207        .chars()
6208        .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
6209}
6210
6211fn deserialize_acp_correlation_id<'de, D>(deserializer: D) -> Result<String, D::Error>
6212where
6213    D: Deserializer<'de>,
6214{
6215    let value = String::deserialize(deserializer)?;
6216    if value.is_empty()
6217        || value.len() > MAX_ACP_CORRELATION_ID_BYTES
6218        || value.chars().any(char::is_control)
6219    {
6220        return Err(serde::de::Error::custom(
6221            "interaction correlation id must be non-empty, bounded, and free of control characters",
6222        ));
6223    }
6224    Ok(value)
6225}
6226
6227fn deserialize_acp_control_id<'de, D>(deserializer: D) -> Result<String, D::Error>
6228where
6229    D: Deserializer<'de>,
6230{
6231    let value = String::deserialize(deserializer)?;
6232    if value.is_empty()
6233        || value.len() > MAX_ACP_CONTROL_ID_BYTES
6234        || value.chars().any(char::is_control)
6235    {
6236        return Err(serde::de::Error::custom(
6237            "acp control id must be non-empty, bounded, and free of control characters",
6238        ));
6239    }
6240    Ok(value)
6241}
6242
6243fn deserialize_acp_config_value_json<'de, D>(deserializer: D) -> Result<String, D::Error>
6244where
6245    D: Deserializer<'de>,
6246{
6247    let value = String::deserialize(deserializer)?;
6248    if value.is_empty()
6249        || value.len() > MAX_ACP_CONTROL_TEXT_BYTES
6250        || contains_unsafe_control_bytes(&value)
6251    {
6252        return Err(serde::de::Error::custom(
6253            "acp config option value must be non-empty, bounded, and free of unsafe control characters",
6254        ));
6255    }
6256    // The field is named `value_json` and its only consumer parses it into a
6257    // `serde_json::Value` to hand to `session/set_config_option`. A string
6258    // that is merely bounded and printable would cross this wire intact and
6259    // fail deep inside the node, one process and several layers away from the
6260    // sender that could have said what was wrong with it. Refuse it here,
6261    // where the offending input is still in hand.
6262    if let Err(error) = serde_json::from_str::<serde_json::Value>(&value) {
6263        return Err(serde::de::Error::custom(format!(
6264            "acp config option value must be valid JSON: {error}"
6265        )));
6266    }
6267    Ok(value)
6268}
6269
6270impl NodeRequest {
6271    pub fn harness_mcp_contract_is_valid_at(&self, now_unix_ms: u64) -> bool {
6272        match self {
6273            Self::ArmHarnessMcpReservation { expires_at_unix_ms, launch, .. } => {
6274                launch.validate().is_ok()
6275                    && *expires_at_unix_ms > now_unix_ms
6276                    && expires_at_unix_ms.saturating_sub(now_unix_ms)
6277                        <= MAX_HARNESS_MCP_RESERVATION_TTL_MS
6278            }
6279            Self::SpawnSpecWithHarnessMcp { deadline_unix_ms, .. } => {
6280                *deadline_unix_ms > now_unix_ms
6281                    && deadline_unix_ms.saturating_sub(now_unix_ms)
6282                        <= MAX_HARNESS_MCP_SPAWN_RELAY_DEADLINE_MS
6283            }
6284            Self::PutHarnessMcpReplyChunk { offset, chunk_hex, .. } => {
6285                usize::try_from(*offset).ok()
6286                    .and_then(|offset| offset.checked_add(chunk_hex.raw_len()))
6287                    .is_some_and(|end| end <= MAX_HARNESS_MCP_AGGREGATE_REPLY_BYTES)
6288            }
6289            _ => true,
6290        }
6291    }
6292
6293    pub fn native_session_catalog_contract_is_valid(&self) -> bool {
6294        match self {
6295            Self::CatalogNativeSessions { route, limit } => {
6296                route.validate().is_ok()
6297                    && (1..=NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(limit)
6298            }
6299            Self::PageNativeSessions { route, after_selection_id, limit, .. } => {
6300                route.validate().is_ok()
6301                    && (1..=NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(limit)
6302                    && after_selection_id
6303                        .as_deref()
6304                        .map_or(true, |cursor| {
6305                            gate4agent_types::validate_candidate_id(cursor).is_ok()
6306                        })
6307            }
6308            _ => true,
6309        }
6310    }
6311
6312    pub fn native_session_preview_contract_is_valid(&self) -> bool {
6313        match self {
6314            Self::PreviewNativeSession { selection, message_limit } => {
6315                (1..=NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX).contains(message_limit)
6316                    && selection.validate().is_ok()
6317            }
6318            Self::PreviewSessionRecord { message_limit, .. } => {
6319                (1..=NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX).contains(message_limit)
6320            }
6321            _ => true,
6322        }
6323    }
6324
6325    pub fn history_context_pack_contract_is_valid(&self) -> bool {
6326        match self {
6327            Self::DiscoverHistory { limit, .. } => {
6328                (1..=HISTORY_DISCOVERY_LIMIT_MAX).contains(limit)
6329            }
6330            Self::LoadHistory { candidate_id, .. } => {
6331                gate4agent_types::validate_candidate_id(candidate_id).is_ok()
6332            }
6333            Self::Snapshot
6334            | Self::Resync { .. }
6335            | Self::ArmHarnessMcpReservation { .. }
6336            | Self::SpawnSpecWithHarnessMcp { .. }
6337            | Self::ActivateHarnessMcpReservation { .. }
6338            | Self::AbortHarnessMcpReservation { .. }
6339            | Self::PutHarnessMcpReplyChunk { .. }
6340            | Self::RejectHarnessMcpCall { .. }
6341            | Self::BeginDeliveryStage { .. }
6342            | Self::PutDeliveryBlobChunk { .. }
6343            | Self::CommitDeliveryStage { .. }
6344            | Self::AbortDeliveryStage { .. }
6345            | Self::BrowseHostDirectories { .. }
6346            | Self::InspectWorkspace { .. }
6347            | Self::ReadWorkspaceFile { .. }
6348            | Self::WriteWorkspaceFile { .. }
6349            | Self::CreateWorkspaceFile { .. }
6350            | Self::CreateWorkspaceDirectory { .. }
6351            | Self::ReadGitHistory { .. }
6352            | Self::ReadGitDiff { .. }
6353            | Self::AcquireController { .. }
6354            | Self::ReleaseController
6355            | Self::RegisterWorkspace { .. }
6356            | Self::CreateStandaloneWorkspace { .. }
6357            | Self::UnregisterWorkspace { .. }
6358            | Self::CreateWorktree { .. }
6359            | Self::RemoveWorktree { .. }
6360            | Self::Spawn { .. }
6361            | Self::SpawnSpec { .. }
6362            | Self::SpawnManagedWorktree { .. }
6363            | Self::SpawnManagedWorktreeV2 { .. }
6364            | Self::CleanupManagedWorktree { .. }
6365            | Self::Resume { .. }
6366            | Self::RenameSessionRecord { .. }
6367            | Self::SetSessionTask { .. }
6368            | Self::IndexProviderSession { .. }
6369            | Self::IndexNativeSession { .. }
6370            | Self::ResumeSessionRecord { .. }
6371            | Self::ForgetSessionRecord { .. }
6372            | Self::CatalogNativeSessions { .. }
6373            | Self::PageNativeSessions { .. }
6374            | Self::PreviewNativeSession { .. }
6375            | Self::PreviewSessionRecord { .. }
6376            | Self::ExportContextPackForSessionRecord { .. }
6377            | Self::ExportContextPack { .. }
6378            | Self::ForgetContextPack { .. }
6379            | Self::ResolveDurableContextPack { .. }
6380            | Self::ReadContextPack { .. }
6381            | Self::Prompt { .. }
6382            | Self::Paste { .. }
6383            | Self::Input { .. }
6384            | Self::TerminalBytes { .. }
6385            | Self::TerminalControl { .. }
6386            | Self::Resize { .. }
6387            | Self::Interrupt { .. }
6388            | Self::Stop { .. }
6389            | Self::Remove { .. }
6390            | Self::ResolveInteraction { .. }
6391            | Self::SetSessionMode { .. }
6392            | Self::SetSessionConfigOption { .. }
6393            | Self::SetSessionModel { .. }
6394            | Self::Shutdown => true,
6395        }
6396    }
6397
6398    pub fn required_capability(&self) -> Option<&'static str> {
6399        match self {
6400            Self::ArmHarnessMcpReservation { .. }
6401            | Self::SpawnSpecWithHarnessMcp { .. }
6402            | Self::ActivateHarnessMcpReservation { .. }
6403            | Self::AbortHarnessMcpReservation { .. }
6404            | Self::PutHarnessMcpReplyChunk { .. }
6405            | Self::RejectHarnessMcpCall { .. } => {
6406                Some(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
6407            }
6408            Self::BeginDeliveryStage { .. }
6409            | Self::PutDeliveryBlobChunk { .. }
6410            | Self::CommitDeliveryStage { .. }
6411            | Self::AbortDeliveryStage { .. } => {
6412                Some(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
6413            }
6414            Self::BrowseHostDirectories { .. } => Some(CAPABILITY_HOST_DIRECTORY_BROWSE_V1),
6415            Self::CreateStandaloneWorkspace { .. } => {
6416                Some(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
6417            }
6418            Self::ReadWorkspaceFile { .. } => Some(NODE_WORKSPACE_FILE_READ_CAPABILITY),
6419            Self::WriteWorkspaceFile { .. } => Some(NODE_WORKSPACE_FILE_WRITE_CAPABILITY),
6420            Self::CreateWorkspaceFile { .. } | Self::CreateWorkspaceDirectory { .. } => {
6421                Some(NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY)
6422            }
6423            Self::ReadGitHistory { .. } | Self::ReadGitDiff { .. } => {
6424                Some(NODE_GIT_READ_CAPABILITY)
6425            }
6426            Self::IndexProviderSession { .. } => {
6427                Some(NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY)
6428            }
6429            Self::SetSessionTask { .. } => Some(NODE_SESSION_TASK_CORRELATION_CAPABILITY),
6430            Self::IndexNativeSession { .. } => Some(NODE_NATIVE_SESSION_INDEX_CAPABILITY),
6431            Self::CatalogNativeSessions { .. } => Some(NODE_NATIVE_SESSION_CATALOG_CAPABILITY),
6432            Self::PageNativeSessions { .. } => {
6433                Some(NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY)
6434            }
6435            Self::PreviewNativeSession { .. } => Some(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY),
6436            Self::PreviewSessionRecord { .. } => Some(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY),
6437            Self::SpawnSpec { .. } => Some(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY),
6438            Self::SpawnManagedWorktree { .. }
6439            | Self::CleanupManagedWorktree { .. } => {
6440                Some(NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY)
6441            }
6442            Self::SpawnManagedWorktreeV2 { .. } => {
6443                Some(NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY)
6444            }
6445            Self::ExportContextPackForSessionRecord { .. } => {
6446                Some(NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY)
6447            }
6448            Self::DiscoverHistory { .. }
6449            | Self::LoadHistory { .. }
6450            | Self::ExportContextPack { .. }
6451            | Self::ForgetContextPack { .. }
6452            | Self::ResolveDurableContextPack { .. }
6453            | Self::ReadContextPack { .. } => {
6454                Some(NODE_HISTORY_CONTEXT_PACK_CAPABILITY)
6455            }
6456            Self::ResolveInteraction { .. }
6457            | Self::SetSessionMode { .. }
6458            | Self::SetSessionConfigOption { .. }
6459            | Self::SetSessionModel { .. } => Some(NODE_ACP_CONTROL_CAPABILITY),
6460            Self::Snapshot
6461            | Self::Resync { .. }
6462            | Self::InspectWorkspace { .. }
6463            | Self::AcquireController { .. }
6464            | Self::ReleaseController
6465            | Self::RegisterWorkspace { .. }
6466            | Self::UnregisterWorkspace { .. }
6467            | Self::CreateWorktree { .. }
6468            | Self::RemoveWorktree { .. }
6469            | Self::Spawn { .. }
6470            | Self::Resume { .. }
6471            | Self::RenameSessionRecord { .. }
6472            | Self::ResumeSessionRecord { .. }
6473            | Self::ForgetSessionRecord { .. }
6474            | Self::Prompt { .. }
6475            | Self::Paste { .. }
6476            | Self::Input { .. }
6477            | Self::TerminalBytes { .. }
6478            | Self::TerminalControl { .. }
6479            | Self::Resize { .. }
6480            | Self::Interrupt { .. }
6481            | Self::Stop { .. }
6482            | Self::Remove { .. }
6483            | Self::Shutdown => None,
6484        }
6485    }
6486
6487    pub fn requires_worktree_selection_capability(&self) -> bool {
6488        matches!(self, Self::SpawnSpec { spec } if spec.target.worktree_id.is_some())
6489            || matches!(self,
6490                Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6491                | Self::SpawnSpecWithHarnessMcp { spec, .. }
6492                if spec.target.worktree_id.is_some())
6493            || matches!(
6494                self,
6495                Self::SpawnManagedWorktree { .. } | Self::CleanupManagedWorktree { .. }
6496                    | Self::SpawnManagedWorktreeV2 { .. }
6497            )
6498    }
6499
6500    pub fn requires_spawn_spec_defaults_overrides_capability(&self) -> bool {
6501        matches!(
6502            self,
6503            Self::SpawnSpec { .. }
6504                | Self::SpawnManagedWorktree { .. }
6505                | Self::SpawnManagedWorktreeV2 { .. }
6506                | Self::ArmHarnessMcpReservation { .. }
6507                | Self::SpawnSpecWithHarnessMcp { .. }
6508        )
6509    }
6510
6511    pub fn requires_spawn_profile_revision_capability(&self) -> bool {
6512        matches!(
6513            self,
6514            Self::SpawnSpec { .. }
6515                | Self::SpawnManagedWorktree { .. }
6516                | Self::SpawnManagedWorktreeV2 { .. }
6517                | Self::ArmHarnessMcpReservation { .. }
6518                | Self::SpawnSpecWithHarnessMcp { .. }
6519        )
6520    }
6521
6522    pub fn requires_child_environment_profile_capability(&self) -> bool {
6523        let spec = match self {
6524            Self::SpawnSpec { spec } => spec,
6525            Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6526            | Self::SpawnSpecWithHarnessMcp { spec, .. } => spec,
6527            Self::SpawnManagedWorktree { request } => &request.spawn_spec,
6528            Self::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
6529            _ => return false,
6530        };
6531        matches!(
6532            spec.overrides.environment_profile_id,
6533            SpawnOverride::Set { .. }
6534        )
6535    }
6536
6537
6538    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
6539        let spec = match self {
6540            Self::SpawnSpec { spec } => spec,
6541            Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6542            | Self::SpawnSpecWithHarnessMcp { spec, .. } => spec,
6543            Self::SpawnManagedWorktree { request } => &request.spawn_spec,
6544            Self::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
6545            _ => return false,
6546        };
6547        !matches!(spec.overrides.bundle_id, SpawnOverride::Clear)
6548    }
6549
6550    pub fn requires_history_context_pack_capability(&self) -> bool {
6551        match self {
6552            Self::DiscoverHistory { .. }
6553            | Self::LoadHistory { .. }
6554            | Self::ExportContextPackForSessionRecord { .. }
6555            | Self::ExportContextPack { .. }
6556            | Self::ForgetContextPack { .. } => true,
6557            Self::SpawnSpec { spec } => {
6558                !matches!(spec.overrides.context_id, SpawnOverride::Clear)
6559            }
6560            Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6561            | Self::SpawnSpecWithHarnessMcp { spec, .. } => {
6562                !matches!(spec.overrides.context_id, SpawnOverride::Clear)
6563            }
6564            Self::SpawnManagedWorktree { request } => {
6565                !matches!(request.spawn_spec.overrides.context_id, SpawnOverride::Clear)
6566            }
6567            Self::SpawnManagedWorktreeV2 { request } => {
6568                !matches!(request.spawn_spec.overrides.context_id, SpawnOverride::Clear)
6569            }
6570            _ => false,
6571        }
6572    }
6573
6574    /// Whether a caller may resend this exact request after a physical C2
6575    /// reconnect without risking a double application on the node --
6576    /// `gate4agent_c2_client::reconnect::C2ReconnectingHandle::request_until`
6577    /// is the only caller: it never retries a request this returns `false`
6578    /// for, at any budget, no matter how long the link is down.
6579    ///
6580    /// Conservative by construction: `true` only for a request that is
6581    /// either a pure read (nothing on the node changes, however many times
6582    /// it is sent) or otherwise idempotent by design (the same bytes at the
6583    /// same identity always land the same way, and a duplicate lands on an
6584    /// already-settled node state as a clean no-op/mismatch rather than a
6585    /// second effect) -- `PutHarnessMcpReplyChunk`/`RejectHarnessMcpCall`
6586    /// (the harness-MCP read-proxy call's own terminal reply, keyed by
6587    /// `(reservation_id, activation_digest, call_id, offset)`) are the only
6588    /// non-read members of that second group. Every spawn, every
6589    /// reservation/worktree/workspace lifecycle transition, every session
6590    /// mutation, and every delivery-bundle stage transition returns `false`
6591    /// -- including `PutDeliveryBlobChunk`, which looks offset-keyed like
6592    /// `PutHarnessMcpReplyChunk` but backs a bundle commit this method does
6593    /// not have a mandate to reclassify as retry-safe.
6594    ///
6595    /// Deliberately exhaustive with no `_` arm: a newly added `NodeRequest`
6596    /// variant fails to compile here until someone classifies it, rather
6597    /// than silently inheriting a default in either direction.
6598    pub fn is_replay_safe(&self) -> bool {
6599        match self {
6600            Self::Snapshot
6601            | Self::Resync { .. }
6602            | Self::PutHarnessMcpReplyChunk { .. }
6603            | Self::RejectHarnessMcpCall { .. }
6604            | Self::BrowseHostDirectories { .. }
6605            | Self::InspectWorkspace { .. }
6606            | Self::ReadWorkspaceFile { .. }
6607            | Self::ReadGitHistory { .. }
6608            | Self::ReadGitDiff { .. }
6609            | Self::CatalogNativeSessions { .. }
6610            | Self::PageNativeSessions { .. }
6611            | Self::PreviewNativeSession { .. }
6612            | Self::PreviewSessionRecord { .. }
6613            | Self::DiscoverHistory { .. }
6614            | Self::ResolveDurableContextPack { .. }
6615            | Self::ReadContextPack { .. } => true,
6616            Self::ArmHarnessMcpReservation { .. }
6617            | Self::SpawnSpecWithHarnessMcp { .. }
6618            | Self::ActivateHarnessMcpReservation { .. }
6619            | Self::AbortHarnessMcpReservation { .. }
6620            | Self::BeginDeliveryStage { .. }
6621            | Self::PutDeliveryBlobChunk { .. }
6622            | Self::CommitDeliveryStage { .. }
6623            | Self::AbortDeliveryStage { .. }
6624            | Self::WriteWorkspaceFile { .. }
6625            | Self::CreateWorkspaceFile { .. }
6626            | Self::CreateWorkspaceDirectory { .. }
6627            | Self::AcquireController { .. }
6628            | Self::ReleaseController
6629            | Self::RegisterWorkspace { .. }
6630            | Self::CreateStandaloneWorkspace { .. }
6631            | Self::UnregisterWorkspace { .. }
6632            | Self::CreateWorktree { .. }
6633            | Self::RemoveWorktree { .. }
6634            | Self::Spawn { .. }
6635            | Self::SpawnSpec { .. }
6636            | Self::SpawnManagedWorktree { .. }
6637            | Self::SpawnManagedWorktreeV2 { .. }
6638            | Self::CleanupManagedWorktree { .. }
6639            | Self::Resume { .. }
6640            | Self::RenameSessionRecord { .. }
6641            | Self::SetSessionTask { .. }
6642            | Self::IndexProviderSession { .. }
6643            | Self::IndexNativeSession { .. }
6644            | Self::ResumeSessionRecord { .. }
6645            | Self::ForgetSessionRecord { .. }
6646            | Self::LoadHistory { .. }
6647            | Self::ExportContextPackForSessionRecord { .. }
6648            | Self::ExportContextPack { .. }
6649            | Self::ForgetContextPack { .. }
6650            | Self::Prompt { .. }
6651            | Self::Paste { .. }
6652            | Self::Input { .. }
6653            | Self::TerminalBytes { .. }
6654            | Self::TerminalControl { .. }
6655            | Self::Resize { .. }
6656            | Self::Interrupt { .. }
6657            | Self::Stop { .. }
6658            | Self::Remove { .. }
6659            | Self::ResolveInteraction { .. }
6660            | Self::SetSessionMode { .. }
6661            | Self::SetSessionConfigOption { .. }
6662            | Self::SetSessionModel { .. }
6663            | Self::Shutdown => false,
6664        }
6665    }
6666}
6667
6668#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6669pub struct ResponseEnvelope {
6670    pub request_id: u64,
6671    pub result: Result<NodeResponse, NodeFailure>,
6672}
6673
6674#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6675#[serde(tag = "kind", rename_all = "kebab-case")]
6676pub enum NodeResponse {
6677    Snapshot {
6678        event_sequence: u64,
6679        controller: Option<ControllerState>,
6680        snapshot: NodeSnapshot,
6681    },
6682    Resync {
6683        event_sequence: u64,
6684        oldest_available_sequence: u64,
6685        snapshot: NodeSnapshot,
6686        events: Vec<NodeEventEnvelope>,
6687    },
6688    Armed {
6689        reservation_id: HarnessMcpReservationId,
6690        activation_digest: HarnessMcpActivationDigest,
6691        expires_at_unix_ms: u64,
6692    },
6693    Spawned {
6694        reservation_id: HarnessMcpReservationId,
6695        activation_digest: HarnessMcpActivationDigest,
6696        receipt: ResolvedSpawnReceipt,
6697    },
6698    Activated {
6699        reservation_id: HarnessMcpReservationId,
6700        activation_digest: HarnessMcpActivationDigest,
6701        record_id: SessionRecordId,
6702        session: SessionAddress,
6703    },
6704    Aborted {
6705        reservation_id: HarnessMcpReservationId,
6706        activation_digest: HarnessMcpActivationDigest,
6707    },
6708    ReplyChunkAccepted {
6709        reservation_id: HarnessMcpReservationId,
6710        activation_digest: HarnessMcpActivationDigest,
6711        record_id: SessionRecordId,
6712        session: SessionAddress,
6713        call_id: HarnessMcpCallId,
6714        next_offset: u32,
6715        completed: bool,
6716    },
6717    CallRejected {
6718        reservation_id: HarnessMcpReservationId,
6719        activation_digest: HarnessMcpActivationDigest,
6720        record_id: SessionRecordId,
6721        session: SessionAddress,
6722        call_id: HarnessMcpCallId,
6723    },
6724    DeliveryStageBegun {
6725        stage_id: DeliveryStageId,
6726        manifest_digest: DeliveryManifestDigestV2,
6727        #[serde(deserialize_with = "deserialize_delivery_blob_digests")]
6728        missing_blobs: Vec<DeliveryBlobDigestV1>,
6729    },
6730    DeliveryBlobChunkAccepted {
6731        stage_id: DeliveryStageId,
6732        blob_digest: DeliveryBlobDigestV1,
6733        next_offset: u64,
6734    },
6735    DeliveryCommitted {
6736        receipt: DeliveryCommitReceiptV1,
6737    },
6738    DeliveryStageAborted {
6739        stage_id: DeliveryStageId,
6740    },
6741    WorkspaceInspected {
6742        inspection: WorkspaceInspection,
6743    },
6744    HostDirectoriesBrowsed {
6745        listing: HostDirectoryListing,
6746    },
6747    WorkspaceFileRead {
6748        file: WorkspaceFileRead,
6749    },
6750    WorkspaceFileWritten {
6751        file: WorkspaceFileRead,
6752    },
6753    WorkspaceFileCreated {
6754        file: WorkspaceFileRead,
6755    },
6756    WorkspaceDirectoryCreated {
6757        workspace_id: WorkspaceId,
6758        entry: WorkspaceEntry,
6759    },
6760    GitHistoryRead {
6761        workspace_id: WorkspaceId,
6762        page: GitHistoryPage,
6763    },
6764    GitDiffRead {
6765        workspace_id: WorkspaceId,
6766        diff: GitDiff,
6767    },
6768    Controller {
6769        controller: Option<ControllerState>,
6770    },
6771    SpawnAccepted {
6772        session: SessionAddress,
6773    },
6774    SpawnSpecAccepted {
6775        receipt: ResolvedSpawnReceipt,
6776    },
6777    ManagedWorktreeSpawnAccepted {
6778        receipt: ManagedWorktreeSpawnReceipt,
6779    },
6780    ManagedWorktreeCleanup {
6781        lease: ManagedWorktreeLeaseSnapshot,
6782    },
6783    SessionRecordUpdated {
6784        record: ManagedSessionRecord,
6785    },
6786    ProviderSessionIndexed {
6787        record: ManagedSessionRecord,
6788    },
6789    NativeSessionIndexed {
6790        selection: NativeSessionSelection,
6791        record: ManagedSessionRecord,
6792    },
6793    SessionRecordResumed {
6794        record: ManagedSessionRecord,
6795        session: SessionAddress,
6796    },
6797    SessionRecordForgotten {
6798        record_id: SessionRecordId,
6799    },
6800    NativeSessionsCataloged {
6801        route: NativeSessionCatalogRoute,
6802        #[serde(deserialize_with = "deserialize_native_session_catalog_entries")]
6803        entries: Vec<NativeSessionCatalogEntry>,
6804        #[serde(
6805            default,
6806            skip_serializing_if = "Option::is_none",
6807            deserialize_with = "deserialize_optional_native_session_catalog_summary"
6808        )]
6809        summary: Option<NativeSessionCatalogSummary>,
6810    },
6811    NativeSessionsPaged {
6812        route: NativeSessionCatalogRoute,
6813        #[serde(deserialize_with = "deserialize_native_session_catalog_page")]
6814        page: NativeSessionCatalogPage,
6815    },
6816    NativeSessionPreviewed {
6817        selection: NativeSessionSelection,
6818        #[serde(deserialize_with = "deserialize_native_session_preview")]
6819        preview: NativeSessionPreview,
6820    },
6821    SessionRecordPreviewed {
6822        record_id: SessionRecordId,
6823        #[serde(deserialize_with = "deserialize_session_record_preview")]
6824        preview: SessionRecordPreview,
6825    },
6826    HistoryDiscovered {
6827        session: SessionAddress,
6828        #[serde(deserialize_with = "deserialize_history_candidates")]
6829        candidates: Vec<HistoryCandidateSummary>,
6830    },
6831    HistoryLoaded {
6832        session: SessionAddress,
6833        #[serde(deserialize_with = "deserialize_history_session_id")]
6834        session_id: String,
6835        message_count: u64,
6836        #[serde(default, skip_serializing_if = "Option::is_none")]
6837        completed_turn_count: Option<u64>,
6838    },
6839    ContextPackExported {
6840        context: ResolvedContextPackReceipt,
6841    },
6842    ContextPackForSessionRecordExported {
6843        record_id: SessionRecordId,
6844        session: SessionAddress,
6845        context: ResolvedContextPackReceipt,
6846    },
6847    ContextPackForgotten {
6848        context_id: SpawnContextId,
6849    },
6850    DurableContextPackResolved {
6851        context: ResolvedContextPackReceipt,
6852    },
6853    ContextPackBytesRead {
6854        pack: ContextPackBytesRead,
6855    },
6856    WorkspaceRegistered {
6857        workspace: WorkspaceSnapshot,
6858    },
6859    StandaloneWorkspaceCreated {
6860        workspace: WorkspaceSnapshot,
6861    },
6862    WorkspaceUnregistered {
6863        workspace_id: WorkspaceId,
6864    },
6865    WorktreeCreated {
6866        worktree: GitWorktreeSnapshot,
6867        workspace: WorkspaceSnapshot,
6868    },
6869    WorktreeRemoved {
6870        target_root: OpaqueHostPath,
6871        workspace_id: Option<WorkspaceId>,
6872    },
6873    Accepted,
6874    ShuttingDown,
6875}
6876
6877impl NodeResponse {
6878    pub fn requires_session_record_context_export_capability(&self) -> bool {
6879        matches!(self, Self::ContextPackForSessionRecordExported { .. })
6880    }
6881
6882    pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
6883        matches!(self,
6884            Self::Armed { .. }
6885                | Self::Spawned { .. }
6886                | Self::Activated { .. }
6887                | Self::Aborted { .. }
6888                | Self::ReplyChunkAccepted { .. }
6889                | Self::CallRejected { .. })
6890            || matches!(self, Self::SpawnSpecAccepted { receipt }
6891                if receipt.harness_mcp_proxy.is_some())
6892            || matches!(self, Self::ManagedWorktreeSpawnAccepted { receipt }
6893                if receipt.spawn.harness_mcp_proxy.is_some())
6894    }
6895
6896    pub fn native_session_catalog_contract_is_valid(&self) -> bool {
6897        match self {
6898            Self::NativeSessionsCataloged { route, entries, summary: Some(summary) } => {
6899                route.validate().is_ok()
6900                    && validate_native_session_catalog_entries(route, entries).is_ok()
6901                    && summary.validate_initial_entries(entries.len()).is_ok()
6902            }
6903            Self::NativeSessionsCataloged { route, entries, summary: None } => {
6904                route.validate().is_ok()
6905                    && validate_native_session_catalog_entries(route, entries).is_ok()
6906            }
6907            Self::NativeSessionsPaged { route, page } => {
6908                page.validate_for_route(route).is_ok()
6909            }
6910            _ => true,
6911        }
6912    }
6913
6914    pub fn requires_native_session_catalog_capability(&self) -> bool {
6915        matches!(self, Self::NativeSessionsCataloged { .. })
6916    }
6917
6918    pub fn requires_native_session_catalog_paging_capability(&self) -> bool {
6919        matches!(self, Self::NativeSessionsPaged { .. })
6920    }
6921
6922    pub fn requires_native_session_preview_capability(&self) -> bool {
6923        matches!(
6924            self,
6925            Self::NativeSessionPreviewed { .. } | Self::SessionRecordPreviewed { .. }
6926        )
6927    }
6928
6929    pub fn requires_native_session_index_capability(&self) -> bool {
6930        matches!(self, Self::NativeSessionIndexed { .. })
6931    }
6932
6933    pub fn native_session_index_contract_is_valid(&self) -> bool {
6934        match self {
6935            Self::NativeSessionIndexed { selection, record } => {
6936                selection.validate().is_ok()
6937                    && selection.route.scope == NativeSessionCatalogScope::Workspace
6938                    && selection.route.workspace_id.as_ref() == Some(&record.workspace_id)
6939                    && selection.route.provider == record.provider
6940            }
6941            _ => false,
6942        }
6943    }
6944
6945    pub fn native_session_preview_contract_is_valid(&self) -> bool {
6946        match self {
6947            Self::NativeSessionPreviewed { selection, preview } => {
6948                selection.validate().is_ok() && preview.validate().is_ok()
6949            }
6950            Self::SessionRecordPreviewed { preview, .. } => preview.validate().is_ok(),
6951            _ => true,
6952        }
6953    }
6954
6955    pub fn requires_worktree_selection_capability(&self) -> bool {
6956        matches!(self,
6957            Self::SpawnSpecAccepted { receipt }
6958                | Self::Spawned { receipt, .. }
6959            if receipt.target.worktree_id.is_some())
6960            || matches!(
6961                self,
6962                Self::ManagedWorktreeSpawnAccepted { .. }
6963                    | Self::ManagedWorktreeCleanup { .. }
6964            )
6965    }
6966
6967
6968    pub fn requires_spawn_spec_defaults_overrides_capability(&self) -> bool {
6969        matches!(
6970            self,
6971            Self::SpawnSpecAccepted { .. }
6972                | Self::Spawned { .. }
6973                | Self::ManagedWorktreeSpawnAccepted { .. }
6974        )
6975    }
6976
6977    pub fn requires_spawn_profile_revision_capability(&self) -> bool {
6978        matches!(
6979            self,
6980            Self::SpawnSpecAccepted { .. }
6981                | Self::Spawned { .. }
6982                | Self::ManagedWorktreeSpawnAccepted { .. }
6983        )
6984    }
6985
6986    pub fn requires_child_environment_profile_capability(&self) -> bool {
6987        match self {
6988            Self::Snapshot { snapshot, .. } => {
6989                snapshot.requires_child_environment_profile_capability()
6990            }
6991            Self::Resync {
6992                snapshot, events, ..
6993            } => {
6994                snapshot.requires_child_environment_profile_capability()
6995                    || events.iter().any(|event| {
6996                        event.event.requires_child_environment_profile_capability()
6997                    })
6998            }
6999            Self::SpawnSpecAccepted { receipt }
7000            | Self::Spawned { receipt, .. } => receipt.environment_profile.is_some(),
7001            Self::ManagedWorktreeSpawnAccepted { receipt } => {
7002                receipt.spawn.environment_profile.is_some()
7003            }
7004            Self::SessionRecordUpdated { record }
7005            | Self::ProviderSessionIndexed { record }
7006            | Self::NativeSessionIndexed { record, .. }
7007            | Self::SessionRecordResumed { record, .. } => {
7008                record.environment_profile.is_some()
7009            }
7010            Self::Armed { .. }
7011            | Self::Activated { .. }
7012            | Self::Aborted { .. }
7013            | Self::ReplyChunkAccepted { .. }
7014            | Self::CallRejected { .. }
7015            | Self::WorkspaceInspected { .. }
7016            | Self::DeliveryStageBegun { .. }
7017            | Self::DeliveryBlobChunkAccepted { .. }
7018            | Self::DeliveryCommitted { .. }
7019            | Self::DeliveryStageAborted { .. }
7020            | Self::HostDirectoriesBrowsed { .. }
7021            | Self::WorkspaceFileRead { .. }
7022            | Self::WorkspaceFileWritten { .. }
7023            | Self::WorkspaceFileCreated { .. }
7024            | Self::WorkspaceDirectoryCreated { .. }
7025            | Self::GitHistoryRead { .. }
7026            | Self::GitDiffRead { .. }
7027            | Self::Controller { .. }
7028            | Self::SpawnAccepted { .. }
7029            | Self::ManagedWorktreeCleanup { .. }
7030            | Self::SessionRecordForgotten { .. }
7031            | Self::NativeSessionsCataloged { .. }
7032            | Self::NativeSessionsPaged { .. }
7033            | Self::NativeSessionPreviewed { .. }
7034            | Self::SessionRecordPreviewed { .. }
7035            | Self::HistoryDiscovered { .. }
7036            | Self::HistoryLoaded { .. }
7037            | Self::ContextPackForSessionRecordExported { .. }
7038            | Self::ContextPackExported { .. }
7039            | Self::ContextPackForgotten { .. }
7040            | Self::DurableContextPackResolved { .. }
7041            | Self::ContextPackBytesRead { .. }
7042            | Self::WorkspaceRegistered { .. }
7043            | Self::StandaloneWorkspaceCreated { .. }
7044            | Self::WorkspaceUnregistered { .. }
7045            | Self::WorktreeCreated { .. }
7046            | Self::WorktreeRemoved { .. }
7047            | Self::Accepted
7048            | Self::ShuttingDown => false,
7049        }
7050    }
7051
7052    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
7053        match self {
7054            Self::Snapshot { snapshot, .. } => {
7055                snapshot.requires_session_bundle_materialization_capability()
7056            }
7057            Self::Resync {
7058                snapshot, events, ..
7059            } => {
7060                snapshot.requires_session_bundle_materialization_capability()
7061                    || events.iter().any(|event| {
7062                        event.event.requires_session_bundle_materialization_capability()
7063                    })
7064            }
7065            Self::SpawnSpecAccepted { receipt }
7066            | Self::Spawned { receipt, .. } => receipt.bundle.is_some(),
7067            Self::ManagedWorktreeSpawnAccepted { receipt } => receipt.spawn.bundle.is_some(),
7068            Self::SessionRecordUpdated { record }
7069            | Self::ProviderSessionIndexed { record }
7070            | Self::NativeSessionIndexed { record, .. }
7071            | Self::SessionRecordResumed { record, .. } => record.bundle.is_some(),
7072            Self::DurableContextPackResolved { .. } => false,
7073            Self::Armed { .. }
7074            | Self::Activated { .. }
7075            | Self::Aborted { .. }
7076            | Self::ReplyChunkAccepted { .. }
7077            | Self::CallRejected { .. }
7078            | Self::WorkspaceInspected { .. }
7079            | Self::DeliveryStageBegun { .. }
7080            | Self::DeliveryBlobChunkAccepted { .. }
7081            | Self::DeliveryCommitted { .. }
7082            | Self::DeliveryStageAborted { .. }
7083            | Self::HostDirectoriesBrowsed { .. }
7084            | Self::WorkspaceFileRead { .. }
7085            | Self::WorkspaceFileWritten { .. }
7086            | Self::WorkspaceFileCreated { .. }
7087            | Self::WorkspaceDirectoryCreated { .. }
7088            | Self::GitHistoryRead { .. }
7089            | Self::GitDiffRead { .. }
7090            | Self::Controller { .. }
7091            | Self::SpawnAccepted { .. }
7092            | Self::ManagedWorktreeCleanup { .. }
7093            | Self::SessionRecordForgotten { .. }
7094            | Self::NativeSessionsCataloged { .. }
7095            | Self::NativeSessionsPaged { .. }
7096            | Self::NativeSessionPreviewed { .. }
7097            | Self::SessionRecordPreviewed { .. }
7098            | Self::HistoryDiscovered { .. }
7099            | Self::HistoryLoaded { .. }
7100            | Self::ContextPackForSessionRecordExported { .. }
7101            | Self::ContextPackExported { .. }
7102            | Self::ContextPackForgotten { .. }
7103            | Self::ContextPackBytesRead { .. }
7104            | Self::WorkspaceRegistered { .. }
7105            | Self::StandaloneWorkspaceCreated { .. }
7106            | Self::WorkspaceUnregistered { .. }
7107            | Self::WorktreeCreated { .. }
7108            | Self::WorktreeRemoved { .. }
7109            | Self::Accepted
7110            | Self::ShuttingDown => false,
7111        }
7112    }
7113
7114    pub fn requires_history_context_pack_capability(&self) -> bool {
7115        match self {
7116            Self::Snapshot { snapshot, .. } => {
7117                snapshot.requires_history_context_pack_capability()
7118            }
7119            Self::Resync {
7120                snapshot, events, ..
7121            } => {
7122                snapshot.requires_history_context_pack_capability()
7123                    || events.iter().any(|event| {
7124                        event.event.requires_history_context_pack_capability()
7125                    })
7126            }
7127            Self::SpawnSpecAccepted { receipt }
7128            | Self::Spawned { receipt, .. } => {
7129                receipt.context_id.is_some() || receipt.context.is_some()
7130            }
7131            Self::ManagedWorktreeSpawnAccepted { receipt } => {
7132                receipt.spawn.context_id.is_some() || receipt.spawn.context.is_some()
7133            }
7134            Self::SessionRecordUpdated { record }
7135            | Self::ProviderSessionIndexed { record }
7136            | Self::NativeSessionIndexed { record, .. }
7137            | Self::SessionRecordResumed { record, .. } => {
7138                record.context_id.is_some() || record.context.is_some()
7139            }
7140            Self::HistoryDiscovered { .. }
7141            | Self::HistoryLoaded { .. }
7142            | Self::ContextPackForSessionRecordExported { .. }
7143            | Self::ContextPackExported { .. }
7144            | Self::ContextPackForgotten { .. }
7145            | Self::DurableContextPackResolved { .. }
7146            | Self::ContextPackBytesRead { .. } => true,
7147            Self::Armed { .. }
7148            | Self::Activated { .. }
7149            | Self::Aborted { .. }
7150            | Self::ReplyChunkAccepted { .. }
7151            | Self::CallRejected { .. }
7152            | Self::WorkspaceInspected { .. }
7153            | Self::DeliveryStageBegun { .. }
7154            | Self::DeliveryBlobChunkAccepted { .. }
7155            | Self::DeliveryCommitted { .. }
7156            | Self::DeliveryStageAborted { .. }
7157            | Self::HostDirectoriesBrowsed { .. }
7158            | Self::WorkspaceFileRead { .. }
7159            | Self::WorkspaceFileWritten { .. }
7160            | Self::WorkspaceFileCreated { .. }
7161            | Self::WorkspaceDirectoryCreated { .. }
7162            | Self::GitHistoryRead { .. }
7163            | Self::GitDiffRead { .. }
7164            | Self::Controller { .. }
7165            | Self::SpawnAccepted { .. }
7166            | Self::ManagedWorktreeCleanup { .. }
7167            | Self::SessionRecordForgotten { .. }
7168            | Self::NativeSessionsCataloged { .. }
7169            | Self::NativeSessionsPaged { .. }
7170            | Self::NativeSessionPreviewed { .. }
7171            | Self::SessionRecordPreviewed { .. }
7172            | Self::WorkspaceRegistered { .. }
7173            | Self::StandaloneWorkspaceCreated { .. }
7174            | Self::WorkspaceUnregistered { .. }
7175            | Self::WorktreeCreated { .. }
7176            | Self::WorktreeRemoved { .. }
7177            | Self::Accepted
7178            | Self::ShuttingDown => false,
7179        }
7180    }
7181}
7182
7183#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7184pub struct NodeFailure {
7185    pub code: NodeFailureCode,
7186    pub message: String,
7187}
7188
7189#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7190#[serde(rename_all = "kebab-case")]
7191pub enum NodeFailureCode {
7192    InvalidRequest,
7193    UnsupportedCapability,
7194    SpawnProfileRevisionMismatch,
7195    HarnessMcpUnavailable,
7196    ReservationNotFound,
7197    ReservationConflict,
7198    ReservationExpired,
7199    BindingMismatch,
7200    NotActivated,
7201    CallNotFound,
7202    ChunkOutOfOrder,
7203    ResponseTooLarge,
7204    DeliveryManifestInvalid,
7205    UnknownDeliveryStage,
7206    DeliveryStageConflict,
7207    DeliveryBlobUnexpected,
7208    DeliveryChunkOutOfOrder,
7209    DeliveryBlobDigestMismatch,
7210    DeliveryBundleDigestMismatch,
7211    DeliveryStageIncomplete,
7212    DeliveryStageStorageFailed,
7213    Unauthorized,
7214    ObserverReadOnly,
7215    ControllerBusy,
7216    ControllerRequired,
7217    UnknownWorkspace,
7218    HostDirectoryInvalid,
7219    HostDirectoryReadFailed,
7220    HostDirectoryReadTimedOut,
7221    InvalidRepositoryPath,
7222    RepositoryFileNotFound,
7223    RepositoryFileNotRegular,
7224    RepositoryPathUnsafe,
7225    RepositoryFileReadTimedOut,
7226    RepositoryFileReadFailed,
7227    RepositoryFileWriteTimedOut,
7228    RepositoryFileWriteFailed,
7229    RepositoryFileRevisionConflict,
7230    RepositoryEntryAlreadyExists,
7231    RepositoryParentNotFound,
7232    RepositoryParentNotDirectory,
7233    RepositoryEntryCreateTimedOut,
7234    RepositoryEntryCreateFailed,
7235    GitReadTimedOut,
7236    GitReadFailed,
7237    InvalidWorkspaceRoot,
7238    DuplicateWorkspaceId,
7239    DuplicateWorkspaceRoot,
7240    WorkspaceBusy,
7241    LastWorkspace,
7242    NotGitRepository,
7243    WorktreeConflict,
7244    WorktreeProtected,
7245    WorktreeDirty,
7246    WorktreeLocked,
7247    UnknownManagedWorktreeLease,
7248    ManagedWorktreeBusy,
7249    ManagedWorktreeOwnershipConflict,
7250    ManagedWorktreeProfileRevisionMismatch,
7251    ManagedWorktreeRecoveryRequired,
7252    StandaloneWorkspaceRecoveryRequired,
7253    UnknownSpawnProfile,
7254    UnknownBundle,
7255    UnknownContextPack,
7256    ContextPackBusy,
7257    ContextPackMaterializationFailed,
7258    UnknownEnvironmentProfile,
7259    /// Station network allowlist policy id is not registered in this node's
7260    /// empty-default catalog (`NodeShared::network_allowlist_catalog`).
7261    /// Plan `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 /
7262    /// §4 — refuse unknown ids rather than silent ambient. Opaque id only;
7263    /// never credentials on C2.
7264    UnknownNetworkAllowlist,
7265    /// Catalog entry carries a provider-native network mapping this spawn's
7266    /// provider cannot honor (e.g. `codex_network_access` for Claude / Kimi).
7267    /// Plan `dig2browser-station-probe-and-network-permit-set-2026-10-02.md`
7268    /// Track B — refuse rather than silent ambient. Never secrets on C2.
7269    UnsupportedNetworkAllowlistMapping,
7270    /// `browser_profile_id` set while feature `dig2-station-probe` is on, but
7271    /// the cheap station probe cannot run on this platform (dig2browser
7272    /// named-pipe IPC is Windows-first; no unix socket path yet) or the
7273    /// configured pipe suffix is invalid. Plan
7274    /// `dig2browser-station-probe-and-network-permit-set-2026-10-02.md` Track A.
7275    /// Never cookies / OAuth on C2.
7276    BrowserStationProbeUnavailable,
7277    /// `browser_profile_id` set, probe feature on, and the local
7278    /// dig2browser-station named-pipe path is missing or not connectable.
7279    /// Path reachability only — no session import / cookie frames.
7280    BrowserStationUnreachable,
7281    /// Feature `dig2-station-probe`: another live session already holds an
7282    /// exclusive node-local lease on this opaque `browser_profile_id`.
7283    /// Never cookies / ImportSession on C2 — lease is node bookkeeping only.
7284    /// Sketch: `dig2-station-bind-lease-sketch-2026-10-02.md`.
7285    BrowserStationProfileBusy,
7286    BundleBindingMismatch,
7287    EnvironmentProfileBindingMismatch,
7288    BundleMaterializationFailed,
7289    SpawnTargetMismatch,
7290    SpawnIdempotencyConflict,
7291    SpawnIdempotencyCapacity,
7292    SpawnDeadlineExceeded,
7293    UnsupportedSpawnCapability,
7294    /// The requested provider does not declare the requested transport
7295    /// (the kernel's own `UnsupportedTransport` rejection, named rather
7296    /// than folded into `BackendOperationFailed`/`UnsupportedCapability` so
7297    /// a caller several layers up can carry the same "provider + transport"
7298    /// specificity all the way to the operator instead of collapsing it
7299    /// into a generic backend failure or -- if the underlying rejection
7300    /// arrives asynchronously and is caught only by a blind commit-deadline
7301    /// poll -- a `SpawnDeadlineExceeded` that names neither).
7302    UnsupportedTransport,
7303    /// The addressed session already has a provider turn in flight --
7304    /// `TurnStarted` observed with no matching `TurnCompleted` yet -- and a
7305    /// `Prompt`/`Paste` against it was refused by name rather than handed to
7306    /// the agent. Applies to the two transports that admit exactly one turn
7307    /// at a time (ACP, and `Pipe`/inline): what a provider does with an
7308    /// overlapping second prompt is vendor-specific and unobservable here
7309    /// (queue it, drop it, interleave it into the running turn), so the node
7310    /// authors its own refusal instead of gambling on that behaviour. PTY
7311    /// sessions never produce this code -- they are still gated by
7312    /// `ProviderRuntimePolicy`'s PTY-terminal-text-inference flags, which
7313    /// answer `UnsupportedCapability` instead.
7314    TurnInFlight,
7315    UnknownSession,
7316    UnknownSessionRecord,
7317    SessionRecordNotResumable,
7318    SessionRecordBusy,
7319    SessionRecordConflict,
7320    SessionWorkspaceMismatch,
7321    WorkspaceRegistrationRequired,
7322    StaleNativeSessionCatalog,
7323    StaleGeneration,
7324    BackendBusy,
7325    BackendDisconnected,
7326    BackendOperationFailed,
7327    ShuttingDown,
7328}
7329
7330/// One named catalog entry -- a selectable session mode or model on the
7331/// `ModeCatalog`/`ModelCatalog` chunk kinds. `id` is what
7332/// `SetSessionMode`/`SetSessionModel` take back.
7333#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7334#[serde(deny_unknown_fields)]
7335pub struct AgentStreamNamedIdV1 {
7336    pub id: String,
7337    pub name: String,
7338    pub description: Option<String>,
7339}
7340
7341/// One selectable answer to an `InteractionPrompt` chunk -- an ACP
7342/// permission option, named by the provider rather than invented here.
7343#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7344#[serde(deny_unknown_fields)]
7345pub struct AgentStreamInteractionOptionV1 {
7346    pub option_id: String,
7347    pub name: String,
7348    pub kind: String,
7349}
7350
7351/// WHO or WHAT blocked an action -- see `AgentStreamChunkKindV1::Blocked`.
7352///
7353/// The host side of the wire (`HostGate`, `HostPolicy`, `HostDeadline`,
7354/// `Operator`) names the four deciders of a host request the node relays to an
7355/// ACP session; the provider side (`ProviderClassifier` ... `ProviderQuota`) names
7356/// refusals the provider reported through a typed field. `Unknown` means no
7357/// typed field named the blocker -- text may still have filled `reason`/`help`,
7358/// but never this field: authority is never guessed from text.
7359#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7360#[serde(rename_all = "kebab-case")]
7361pub enum BlockAuthorityV1 {
7362    /// A dangerous-command gate forced the outcome ahead of the host's policy.
7363    HostGate,
7364    /// The host's policy decided the request the instant it arrived.
7365    HostPolicy,
7366    /// A deferred request the host's policy decided after no operator
7367    /// answered in time.
7368    HostDeadline,
7369    /// An operator explicitly declined.
7370    Operator,
7371    /// The provider's own auto-mode classifier refused the call.
7372    ProviderClassifier,
7373    /// A provider-side permission rule (an allow/deny list entry) refused
7374    /// the call.
7375    ProviderPermissionRule,
7376    /// The provider's sandbox refused the call.
7377    ProviderSandbox,
7378    /// The provider declined with a `stopReason: "refusal"` or equivalent.
7379    ProviderRefusal,
7380    /// A human declined inside the provider's OWN UI/CLI.
7381    UserRejected,
7382    /// The provider's own account/plan quota, rate limit, or usage cap was
7383    /// exhausted. `reason_kind` carries the provider's own vendor code.
7384    ProviderQuota,
7385    /// No typed field named who blocked it.
7386    Unknown,
7387}
7388
7389/// The kind of a single `AgentStreamChunkV1` -- content the operator needs
7390/// to act on a running ACP session: what the agent is saying, a pending
7391/// interaction it needs answered, and the catalogs the three ACP setter
7392/// verbs (`SetSessionMode`, `SetSessionConfigOption`, `SetSessionModel`)
7393/// operate over. Mirrors `gate4agent_types::ProviderEvent`'s content
7394/// variants deliberately -- see
7395/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
7396/// §3-4.
7397#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7398#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7399pub enum AgentStreamChunkKindV1 {
7400    Text { text: String, is_delta: bool },
7401    Thinking { text: String },
7402    /// The `correlation_id` `correlation::interaction_correlation` mints for
7403    /// the interaction -- the same id a client deriving telemetry from
7404    /// `NodeEvent::Control` computes -- plus the question and option list
7405    /// `NodeRequest::ResolveInteraction` answers blind without.
7406    InteractionPrompt {
7407        correlation_id: String,
7408        interaction_kind: ProviderInteractionKind,
7409        tool_name: String,
7410        title: Option<String>,
7411        prompt: String,
7412        options: Vec<AgentStreamInteractionOptionV1>,
7413    },
7414    ModeCatalog {
7415        current: Option<String>,
7416        available: Vec<AgentStreamNamedIdV1>,
7417    },
7418    /// Mirrors `gate4agent_types::ProviderConfigOption` verbatim rather
7419    /// than inventing a second shape for the same content.
7420    ConfigOptions { options: Vec<ProviderConfigOption> },
7421    ModelCatalog {
7422        current: Option<String>,
7423        available: Vec<AgentStreamNamedIdV1>,
7424    },
7425    /// An action a provider or the host refused, with who refused it
7426    /// (`authority`), why (`reason_kind`/`reason`) and what to do about it
7427    /// (`help`). `BlockAuthorityV1`'s doc comment explains why authority is
7428    /// never guessed. An EVENT, not state: a session that reconnects
7429    /// mid-block does not get this replayed to it -- the same seeded-state-
7430    /// vs-unreplayed-event split the agent stream already draws for `Text`/
7431    /// `Thinking`/`InteractionPrompt`.
7432    Blocked {
7433        correlation_id: Option<String>,
7434        tool_class: String,
7435        authority: BlockAuthorityV1,
7436        reason_kind: Option<String>,
7437        reason: String,
7438        help: Option<String>,
7439    },
7440}
7441
7442impl AgentStreamChunkKindV1 {
7443    pub fn validate(&self) -> Result<(), &'static str> {
7444        match self {
7445            Self::Text { text, .. } | Self::Thinking { text } => {
7446                if text.len() > MAX_ACP_CONTROL_TEXT_BYTES || contains_unsafe_control_bytes(text) {
7447                    return Err("agent stream text chunk is invalid");
7448                }
7449                Ok(())
7450            }
7451            Self::InteractionPrompt {
7452                correlation_id,
7453                tool_name,
7454                title,
7455                prompt,
7456                options,
7457                ..
7458            } => {
7459                if correlation_id.is_empty()
7460                    || correlation_id.len() > MAX_ACP_CORRELATION_ID_BYTES
7461                    || correlation_id.chars().any(char::is_control)
7462                {
7463                    return Err("agent stream interaction prompt correlation id is invalid");
7464                }
7465                if tool_name.is_empty()
7466                    || tool_name.len() > MAX_ACP_CONTROL_ID_BYTES
7467                    || tool_name.chars().any(char::is_control)
7468                {
7469                    return Err("agent stream interaction prompt tool name is invalid");
7470                }
7471                if let Some(title) = title {
7472                    if title.len() > MAX_ACP_CONTROL_TEXT_BYTES
7473                        || contains_unsafe_control_bytes(title)
7474                    {
7475                        return Err("agent stream interaction prompt title is invalid");
7476                    }
7477                }
7478                if prompt.len() > MAX_ACP_CONTROL_TEXT_BYTES || contains_unsafe_control_bytes(prompt)
7479                {
7480                    return Err("agent stream interaction prompt text is invalid");
7481                }
7482                if options.len() > MAX_ACP_INTERACTION_OPTIONS {
7483                    return Err("agent stream interaction prompt has too many options");
7484                }
7485                for option in options {
7486                    if option.option_id.is_empty()
7487                        || option.option_id.len() > MAX_ACP_CONTROL_ID_BYTES
7488                        || option.option_id.chars().any(char::is_control)
7489                    {
7490                        return Err("agent stream interaction prompt option id is invalid");
7491                    }
7492                    if option.name.is_empty()
7493                        || option.name.len() > MAX_ACP_CONTROL_ID_BYTES
7494                        || option.name.chars().any(char::is_control)
7495                    {
7496                        return Err("agent stream interaction prompt option name is invalid");
7497                    }
7498                    if option.kind.is_empty()
7499                        || option.kind.len() > MAX_ACP_CONTROL_ID_BYTES
7500                        || option.kind.chars().any(char::is_control)
7501                    {
7502                        return Err("agent stream interaction prompt option kind is invalid");
7503                    }
7504                }
7505                Ok(())
7506            }
7507            Self::ModeCatalog { current, available } | Self::ModelCatalog { current, available } => {
7508                if let Some(current) = current {
7509                    if current.is_empty()
7510                        || current.len() > MAX_ACP_CONTROL_ID_BYTES
7511                        || current.chars().any(char::is_control)
7512                    {
7513                        return Err("agent stream catalog current id is invalid");
7514                    }
7515                }
7516                if available.len() > MAX_ACP_CATALOG_ENTRIES {
7517                    return Err("agent stream catalog has too many entries");
7518                }
7519                for entry in available {
7520                    if entry.id.is_empty()
7521                        || entry.id.len() > MAX_ACP_CONTROL_ID_BYTES
7522                        || entry.id.chars().any(char::is_control)
7523                    {
7524                        return Err("agent stream catalog entry id is invalid");
7525                    }
7526                    if entry.name.is_empty()
7527                        || entry.name.len() > MAX_ACP_CONTROL_ID_BYTES
7528                        || entry.name.chars().any(char::is_control)
7529                    {
7530                        return Err("agent stream catalog entry name is invalid");
7531                    }
7532                    if let Some(description) = &entry.description {
7533                        if description.len() > MAX_ACP_CONTROL_TEXT_BYTES
7534                            || contains_unsafe_control_bytes(description)
7535                        {
7536                            return Err("agent stream catalog entry description is invalid");
7537                        }
7538                    }
7539                }
7540                Ok(())
7541            }
7542            Self::ConfigOptions { options } => {
7543                if options.len() > MAX_ACP_CATALOG_ENTRIES {
7544                    return Err("agent stream config options exceed the catalog entry limit");
7545                }
7546                for option in options {
7547                    if option.id.is_empty()
7548                        || option.id.len() > MAX_ACP_CONTROL_ID_BYTES
7549                        || option.id.chars().any(char::is_control)
7550                    {
7551                        return Err("agent stream config option id is invalid");
7552                    }
7553                    if option.name.is_empty()
7554                        || option.name.len() > MAX_ACP_CONTROL_ID_BYTES
7555                        || option.name.chars().any(char::is_control)
7556                    {
7557                        return Err("agent stream config option name is invalid");
7558                    }
7559                    if let Some(description) = &option.description {
7560                        if description.len() > MAX_ACP_CONTROL_TEXT_BYTES
7561                            || contains_unsafe_control_bytes(description)
7562                        {
7563                            return Err("agent stream config option description is invalid");
7564                        }
7565                    }
7566                    if let Some(category) = &option.category {
7567                        if category.len() > MAX_ACP_CONTROL_ID_BYTES
7568                            || category.chars().any(char::is_control)
7569                        {
7570                            return Err("agent stream config option category is invalid");
7571                        }
7572                    }
7573                    if option.value_json.len() > MAX_ACP_CONTROL_TEXT_BYTES
7574                        || contains_unsafe_control_bytes(&option.value_json)
7575                    {
7576                        return Err("agent stream config option value is invalid");
7577                    }
7578                    if option.choices.len() > MAX_ACP_INTERACTION_OPTIONS {
7579                        return Err("agent stream config option has too many choices");
7580                    }
7581                    for choice in &option.choices {
7582                        if choice.value_json.len() > MAX_ACP_CONTROL_TEXT_BYTES
7583                            || contains_unsafe_control_bytes(&choice.value_json)
7584                        {
7585                            return Err("agent stream config option choice value is invalid");
7586                        }
7587                        if let Some(label) = &choice.label {
7588                            if label.len() > MAX_ACP_CONTROL_TEXT_BYTES
7589                                || contains_unsafe_control_bytes(label)
7590                            {
7591                                return Err("agent stream config option choice label is invalid");
7592                            }
7593                        }
7594                    }
7595                }
7596                Ok(())
7597            }
7598            Self::Blocked {
7599                correlation_id,
7600                tool_class,
7601                reason_kind,
7602                reason,
7603                help,
7604                ..
7605            } => {
7606                if let Some(correlation_id) = correlation_id {
7607                    if correlation_id.is_empty()
7608                        || correlation_id.len() > MAX_ACP_CORRELATION_ID_BYTES
7609                        || correlation_id.chars().any(char::is_control)
7610                    {
7611                        return Err("agent stream blocked chunk correlation id is invalid");
7612                    }
7613                }
7614                if tool_class.is_empty()
7615                    || tool_class.len() > MAX_ACP_CORRELATION_ID_BYTES
7616                    || tool_class.chars().any(char::is_control)
7617                {
7618                    return Err("agent stream blocked chunk tool class is invalid");
7619                }
7620                if let Some(reason_kind) = reason_kind {
7621                    if reason_kind.is_empty()
7622                        || reason_kind.len() > MAX_ACP_CORRELATION_ID_BYTES
7623                        || reason_kind.chars().any(char::is_control)
7624                    {
7625                        return Err("agent stream blocked chunk reason kind is invalid");
7626                    }
7627                }
7628                if reason.is_empty()
7629                    || reason.len() > MAX_ACP_BLOCKED_REASON_BYTES
7630                    || contains_unsafe_control_bytes(reason)
7631                {
7632                    return Err("agent stream blocked chunk reason is invalid");
7633                }
7634                if let Some(help) = help {
7635                    if help.is_empty()
7636                        || help.len() > MAX_ACP_BLOCKED_HELP_BYTES
7637                        || contains_unsafe_control_bytes(help)
7638                    {
7639                        return Err("agent stream blocked chunk help is invalid");
7640                    }
7641                }
7642                Ok(())
7643            }
7644        }
7645    }
7646}
7647
7648/// One chunk of the outbound agent content stream -- the
7649/// `agent-stream-events-v1` capability's push channel, mirroring
7650/// `NodeEvent::TerminalFrame` exactly: its own subscription, its own type,
7651/// no resync promise. `source_sequence` orders chunks within one provider
7652/// source, the same number the provider event carries.
7653#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7654#[serde(deny_unknown_fields)]
7655pub struct AgentStreamChunkV1 {
7656    pub source_sequence: u64,
7657    pub kind: AgentStreamChunkKindV1,
7658}
7659
7660impl AgentStreamChunkV1 {
7661    pub fn validate(&self) -> Result<(), &'static str> {
7662        self.kind.validate()
7663    }
7664}
7665
7666#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7667pub struct NodeEventEnvelope {
7668    pub sequence: u64,
7669    pub event: NodeEvent,
7670}
7671
7672#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7673#[serde(tag = "kind", rename_all = "kebab-case")]
7674pub enum NodeEvent {
7675    HarnessMcpReadCall {
7676        reservation_id: HarnessMcpReservationId,
7677        activation_digest: HarnessMcpActivationDigest,
7678        record_id: SessionRecordId,
7679        session: SessionAddress,
7680        call_id: HarnessMcpCallId,
7681        request: HarnessMcpOpaquePayloadV1,
7682        deadline_unix_ms: u64,
7683    },
7684    Control { address: SessionAddress, event: ControlEvent },
7685    /// Aggregate facts about one session record's native history, published
7686    /// when a client previews the record. Counts only -- never messages -- so
7687    /// it is safe to fan out to every subscriber.
7688    SessionRecordHistorySummarized { record_id: SessionRecordId, summary: SessionHistorySummaryV1 },
7689    TerminalFrame { address: SessionAddress, frame: TerminalFrame },
7690    AgentStream { address: SessionAddress, chunk: AgentStreamChunkV1 },
7691    ControllerChanged { controller: Option<ControllerState> },
7692    WorkspaceAdded { workspace: WorkspaceSnapshot },
7693    WorkspaceRemoved { workspace_id: WorkspaceId },
7694    SessionRecordUpserted { record: ManagedSessionRecord },
7695    SessionRecordRemoved { record_id: SessionRecordId },
7696    ManagedWorktreeUpserted { lease: ManagedWorktreeLeaseSnapshot },
7697    ManagedWorktreeRemoved { lease_id: ManagedWorktreeLeaseId },
7698    ResyncRequired { oldest_available_sequence: u64 },
7699}
7700
7701impl NodeEvent {
7702    pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
7703        matches!(self, Self::HarnessMcpReadCall { .. })
7704    }
7705
7706    pub fn harness_mcp_contract_is_valid_at(&self, now_unix_ms: u64) -> bool {
7707        match self {
7708            Self::HarnessMcpReadCall { request, deadline_unix_ms, .. } => {
7709                serde_json::to_vec(request)
7710                    .is_ok_and(|wire| wire.len() <= MAX_HARNESS_MCP_LOCAL_REQUEST_BYTES)
7711                    && *deadline_unix_ms > now_unix_ms
7712                    && deadline_unix_ms.saturating_sub(now_unix_ms)
7713                        <= MAX_HARNESS_MCP_CALL_DEADLINE_MS
7714            }
7715            _ => true,
7716        }
7717    }
7718
7719    pub fn requires_child_environment_profile_capability(&self) -> bool {
7720        matches!(self, Self::SessionRecordUpserted { record }
7721            if record.environment_profile.is_some())
7722    }
7723
7724
7725    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
7726        matches!(self, Self::SessionRecordUpserted { record }
7727            if record.bundle.is_some())
7728    }
7729
7730    pub fn requires_history_context_pack_capability(&self) -> bool {
7731        matches!(self, Self::SessionRecordUpserted { record }
7732            if record.context_id.is_some() || record.context.is_some())
7733    }
7734}
7735
7736/// Aggregate facts about one session record's native history: how many messages
7737/// and completed turns it holds and what it cost, without any of its content.
7738#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7739#[serde(deny_unknown_fields)]
7740pub struct SessionHistorySummaryV1 {
7741    pub message_count: u64,
7742    pub message_count_exact: bool,
7743    pub completed_turn_count: Option<u64>,
7744    pub total_tokens: Option<u64>,
7745    pub modified_at_unix_ms: Option<u64>,
7746}
7747
7748impl From<&SessionRecordPreview> for SessionHistorySummaryV1 {
7749    fn from(preview: &SessionRecordPreview) -> Self {
7750        Self {
7751            message_count: preview.message_count,
7752            message_count_exact: preview.message_count_exact,
7753            completed_turn_count: preview.completed_turn_count,
7754            total_tokens: preview.total_tokens,
7755            modified_at_unix_ms: preview.modified_at_unix_ms,
7756        }
7757    }
7758}
7759
7760/// The first frame on a CALL-HOME connection, and the only frame this
7761/// protocol adds for it: the node announcing which node it is.
7762///
7763/// Every other connection on this wire is dialled by the operator, which
7764/// therefore already knows which node it reached and which per-node access
7765/// token to prove against. A call-home connection inverts who opens the
7766/// socket -- a node with no reachable address connects out to the relay
7767/// instead of waiting to be connected to -- and the relay, holding a fresh
7768/// accepted socket, has no idea whose it is. It cannot even verify the
7769/// server proof, because that proof is computed from the token belonging
7770/// to a specific node.
7771///
7772/// So the node says its name first. Note what this frame is NOT: it is not
7773/// authentication and it grants nothing. It only SELECTS which configured
7774/// node's token the relay will use for the handshake that follows, and
7775/// that handshake is the same mutual challenge-response every dialled
7776/// connection runs (`ServerChallenge`/`ClientAuthentication`, both sides
7777/// proving over both nonces). A caller announcing a node id it does not
7778/// hold the token for fails at the very next frame, exactly as an
7779/// impostor on a dialled connection would.
7780///
7781/// Deliberately not a variant of [`ClientFrame`]: it travels before the
7782/// wire's own handshake begins, in one direction only, and adding it to
7783/// the frame enum would make it look like something a client may send at
7784/// any point in a session. It is a preface, not a frame of the protocol.
7785#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7786pub struct NodeCallHomeAnnounce {
7787    pub build_stamp: String,
7788    pub node_id: String,
7789}
7790
7791impl NodeCallHomeAnnounce {
7792    pub fn new(node_id: impl Into<String>) -> Self {
7793        Self { build_stamp: BUILD_STAMP.to_owned(), node_id: node_id.into() }
7794    }
7795}
7796
7797#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7798#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
7799pub enum ClientFrame {
7800    Hello(ClientHello),
7801    Authenticate(ClientAuthentication),
7802    Request(RequestEnvelope),
7803}
7804
7805#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7806#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
7807pub enum ServerFrame {
7808    Challenge(ServerChallenge),
7809    Hello(NodeHello),
7810    Reply(ResponseEnvelope),
7811    Event(NodeEventEnvelope),
7812}
7813
7814pub async fn read_json_frame<R, T>(reader: &mut R) -> Result<T, FrameError>
7815where
7816    R: AsyncRead + Unpin,
7817    T: DeserializeOwned,
7818{
7819    read_json_frame_limited(reader, MAX_NODE_FRAME_BYTES).await
7820}
7821
7822pub async fn read_json_frame_limited<R, T>(reader: &mut R, max_bytes: usize) -> Result<T, FrameError>
7823where
7824    R: AsyncRead + Unpin,
7825    T: DeserializeOwned,
7826{
7827    let length = reader.read_u32_le().await? as usize;
7828    if length == 0 || length > max_bytes {
7829        return Err(FrameError::InvalidLength {
7830            length,
7831            max: max_bytes,
7832        });
7833    }
7834    let mut payload = vec![0; length];
7835    reader.read_exact(&mut payload).await?;
7836    Ok(serde_json::from_slice(&payload)?)
7837}
7838
7839pub async fn read_json_frame_limited_body_timeout<R, T>(
7840    reader: &mut R,
7841    max_bytes: usize,
7842    body_timeout: Duration,
7843) -> Result<T, FrameError>
7844where
7845    R: AsyncRead + Unpin,
7846    T: DeserializeOwned,
7847{
7848    let mut length_prefix = [0_u8; std::mem::size_of::<u32>()];
7849    reader.read_exact(&mut length_prefix[..1]).await?;
7850    timeout(body_timeout, reader.read_exact(&mut length_prefix[1..]))
7851        .await
7852        .map_err(|_| FrameError::PrefixTimedOut)??;
7853    let length = u32::from_le_bytes(length_prefix) as usize;
7854    if length == 0 || length > max_bytes {
7855        return Err(FrameError::InvalidLength {
7856            length,
7857            max: max_bytes,
7858        });
7859    }
7860    let mut payload = vec![0; length];
7861    timeout(body_timeout, reader.read_exact(&mut payload))
7862        .await
7863        .map_err(|_| FrameError::BodyTimedOut { length })??;
7864    Ok(serde_json::from_slice(&payload)?)
7865}
7866
7867pub async fn write_json_frame<W, T>(writer: &mut W, value: &T) -> Result<(), FrameError>
7868where
7869    W: AsyncWrite + Unpin,
7870    T: Serialize,
7871{
7872    write_json_frame_limited(writer, value, MAX_NODE_FRAME_BYTES).await
7873}
7874
7875pub async fn write_json_frame_limited<W, T>(
7876    writer: &mut W,
7877    value: &T,
7878    max_bytes: usize,
7879) -> Result<(), FrameError>
7880where
7881    W: AsyncWrite + Unpin,
7882    T: Serialize,
7883{
7884    let payload = serde_json::to_vec(value)?;
7885    if payload.is_empty() || payload.len() > max_bytes {
7886        return Err(FrameError::InvalidLength {
7887            length: payload.len(),
7888            max: max_bytes,
7889        });
7890    }
7891    writer.write_u32_le(payload.len() as u32).await?;
7892    writer.write_all(&payload).await?;
7893    writer.flush().await?;
7894    Ok(())
7895}
7896
7897#[derive(Debug, Error)]
7898pub enum FrameError {
7899    #[error("node frame I/O failed: {0}")]
7900    Io(#[from] io::Error),
7901    #[error("node frame JSON failed: {0}")]
7902    Json(#[from] serde_json::Error),
7903    #[error("node frame length {length} is outside 1..={max}")]
7904    InvalidLength { length: usize, max: usize },
7905    #[error("node frame body of {length} bytes was not received before the bounded deadline")]
7906    BodyTimedOut { length: usize },
7907    #[error("node frame length prefix was not completed before the bounded deadline")]
7908    PrefixTimedOut,
7909}
7910
7911#[cfg(test)]
7912mod tests {
7913    use super::*;
7914
7915    /// A blocked-action chunk carries all six fields and
7916    /// serialises additively (`"kind":"blocked"` alongside `Text`/
7917    /// `Thinking`/`InteractionPrompt`/`ModeCatalog`/`ConfigOptions`/
7918    /// `ModelCatalog`, never replacing any of them), and round-trips exactly.
7919    #[test]
7920    fn agent_stream_blocked_chunk_serialises_additively_with_all_six_fields() {
7921        let chunk = AgentStreamChunkV1 {
7922            source_sequence: 7,
7923            kind: AgentStreamChunkKindV1::Blocked {
7924                correlation_id: Some("tool-deadbeefcafebabe".to_owned()),
7925                tool_class: "Shell".to_owned(),
7926                authority: BlockAuthorityV1::HostGate,
7927                reason_kind: Some("gate-rule".to_owned()),
7928                reason: "blocked by dangerous-command gate: rule=filesystem-wipe".to_owned(),
7929                help: Some("add a Bash permission rule".to_owned()),
7930            },
7931        };
7932        chunk.validate().unwrap();
7933        let json = serde_json::to_string(&chunk).unwrap();
7934        assert_eq!(
7935            json,
7936            r#"{"source_sequence":7,"kind":{"kind":"blocked","correlation_id":"tool-deadbeefcafebabe","tool_class":"Shell","authority":"host-gate","reason_kind":"gate-rule","reason":"blocked by dangerous-command gate: rule=filesystem-wipe","help":"add a Bash permission rule"}}"#,
7937        );
7938        assert_eq!(serde_json::from_str::<AgentStreamChunkV1>(&json).unwrap(), chunk);
7939
7940        // `correlation_id`/`reason_kind`/`help` are `None` for the harness's
7941        // own denials today (see `gate4agent-node`'s `host_request_denied_
7942        // block`) -- confirm the wire keeps them as explicit JSON `null`,
7943        // not an omitted field, matching every other `Option` field this
7944        // enum already carries (`ModeCatalog::current`, `title`).
7945        let minimal = AgentStreamChunkV1 {
7946            source_sequence: 8,
7947            kind: AgentStreamChunkKindV1::Blocked {
7948                correlation_id: None,
7949                tool_class: "Write".to_owned(),
7950                authority: BlockAuthorityV1::Unknown,
7951                reason_kind: None,
7952                reason: "blocked; no reason was reported".to_owned(),
7953                help: None,
7954            },
7955        };
7956        minimal.validate().unwrap();
7957        let minimal_json = serde_json::to_string(&minimal).unwrap();
7958        assert_eq!(
7959            minimal_json,
7960            r#"{"source_sequence":8,"kind":{"kind":"blocked","correlation_id":null,"tool_class":"Write","authority":"unknown","reason_kind":null,"reason":"blocked; no reason was reported","help":null}}"#,
7961        );
7962        assert_eq!(
7963            serde_json::from_str::<AgentStreamChunkV1>(&minimal_json).unwrap(),
7964            minimal,
7965        );
7966    }
7967
7968    #[test]
7969    fn harness_mcp_protocol_serde_bounds_and_privacy() {
7970        let reservation_id = HarnessMcpReservationId::new(format!(
7971            "hmcpres_{}", "a".repeat(24),
7972        )).unwrap();
7973        let call_id = HarnessMcpCallId::new(format!("hmcpcall_{}", "b".repeat(24))).unwrap();
7974        let digest = HarnessMcpActivationDigest::new(format!(
7975            "sha256:{}", "c".repeat(64),
7976        )).unwrap();
7977        let token = HarnessMcpLocalToken::new(format!("g4ah3_{}", "d".repeat(64))).unwrap();
7978        assert!(!format!("{token:?}").contains(token.expose()));
7979        assert!(HarnessMcpReservationId::new(format!("hmcpres_{}", "A".repeat(24))).is_err());
7980        assert!(HarnessMcpCallId::new(format!("hmcpcall_{}", "b".repeat(23))).is_err());
7981        assert!(HarnessMcpActivationDigest::new(format!("sha256:{}", "g".repeat(64))).is_err());
7982
7983        let local = HarnessMcpLocalRequestV1 {
7984            version: 1,
7985            token,
7986            request: HarnessMcpOpaquePayloadV1 {
7987                content_type: HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
7988                body: br#"{"kind":"context-get"}"#.to_vec(),
7989            },
7990        };
7991        local.validate().unwrap();
7992        let mut unknown = serde_json::to_value(&local).unwrap();
7993        unknown["endpoint"] = serde_json::Value::String("forbidden".to_owned());
7994        assert!(serde_json::from_value::<HarnessMcpLocalRequestV1>(unknown).is_err());
7995        assert!(HarnessMcpReplyChunkHexV1::new(
7996            "00".repeat(MAX_HARNESS_MCP_REPLY_CHUNK_RAW_BYTES),
7997        ).is_ok());
7998        assert!(HarnessMcpReplyChunkHexV1::new(
7999            "00".repeat(MAX_HARNESS_MCP_REPLY_CHUNK_RAW_BYTES + 1),
8000        ).is_err());
8001
8002        let event = NodeEvent::HarnessMcpReadCall {
8003            reservation_id: reservation_id.clone(),
8004            activation_digest: digest.clone(),
8005            record_id: SessionRecordId::new("record-a").unwrap(),
8006            session: session_address("primary", 1),
8007            call_id,
8008            request: HarnessMcpOpaquePayloadV1 {
8009                content_type: HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
8010                body: br#"{"kind":"context-get"}"#.to_vec(),
8011            },
8012            deadline_unix_ms: 4_000,
8013        };
8014        assert!(event.harness_mcp_contract_is_valid_at(1_000));
8015        assert!(!event.harness_mcp_contract_is_valid_at(999));
8016        let request = NodeRequest::AbortHarnessMcpReservation {
8017            reservation_id,
8018            activation_digest: digest,
8019        };
8020        assert_eq!(request.required_capability(), Some(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY));
8021        let json = serde_json::to_string(&event).unwrap();
8022        assert!(!json.contains("g4ah3_"));
8023        assert!(!json.contains("endpoint"));
8024        assert!(!json.contains("path"));
8025    }
8026
8027    #[test]
8028    fn task_id_is_fixed_opaque_and_bounded() {
8029        let task_id = TaskId::from_nonce([
8030            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xff,
8031        ]);
8032        assert_eq!(task_id.as_str(), "task-00112233445566778899aaff");
8033        assert_eq!(task_id.as_str().len(), 29);
8034        assert_eq!(task_id.as_str().parse::<TaskId>().unwrap(), task_id);
8035        assert!("task-00112233445566778899aaf".parse::<TaskId>().is_err());
8036        assert!("task-00112233445566778899aaff00".parse::<TaskId>().is_err());
8037        assert!("task-00112233445566778899aaFF".parse::<TaskId>().is_err());
8038        assert!("work-00112233445566778899aaff".parse::<TaskId>().is_err());
8039        assert!(serde_json::from_str::<TaskId>(
8040            r#""task-00112233445566778899aaff-extra""#,
8041        )
8042        .is_err());
8043    }
8044
8045    fn agent(value: &str) -> AgentId {
8046        AgentId::new(value).unwrap()
8047    }
8048
8049    fn host_path(value: &str) -> OpaqueHostPath {
8050        OpaqueHostPath::utf8(value.to_owned()).unwrap()
8051    }
8052
8053    fn repository_path(value: &str) -> RepositoryPath {
8054        RepositoryPath::utf8(value.to_owned()).unwrap()
8055    }
8056
8057    fn session_address(workspace_id: &str, instance_id: u64) -> SessionAddress {
8058        SessionAddress {
8059            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
8060            session: SessionKey {
8061                instance_id: AgentInstanceId(instance_id),
8062                generation: SessionGeneration(1),
8063            },
8064        }
8065    }
8066
8067    fn context_receipt(
8068        id: &str,
8069        source_session: SessionAddress,
8070    ) -> ResolvedContextPackReceipt {
8071        ResolvedContextPackReceipt {
8072            id: SpawnContextId::new(id).unwrap(),
8073            digest: SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).unwrap(),
8074            lineage: ContextPackLineageReceipt {
8075                source_node_id: NodeId::new("node-a").unwrap(),
8076                source_session,
8077                source_provider: agent("claude"),
8078            },
8079            source_message_count: 3,
8080            retained_message_count: 2,
8081            byte_len: 32,
8082            truncated: true,
8083        }
8084    }
8085
8086    fn portable_node_support() -> NodeCompatibilitySupport {
8087        NodeCompatibilitySupport {
8088            build_stamp: BUILD_STAMP.to_owned(),
8089            capabilities: vec![
8090                CapabilityId::new("workspace.inspect").unwrap(),
8091                CapabilityId::new("session.spawn").unwrap(),
8092            ],
8093            host: HostDescriptor {
8094                operating_system: OperatingSystemId::new("windows").unwrap(),
8095                architecture: ArchitectureId::new("x86_64").unwrap(),
8096            },
8097            path_semantics: PathSemantics {
8098                style: PathStyle::Windows,
8099                encoding: PathEncoding::Utf8,
8100            },
8101            local_transport: LocalTransportKind::WindowsNamedPipe,
8102            state_schema: StateSchemaSupport {
8103                versions: ProtocolRange::new(3, 5).unwrap(),
8104            },
8105            provider_contracts: vec![ProviderContractSupport {
8106                provider: agent("codex"),
8107                revision: ProviderContractRevision::new("codex.2026-08").unwrap(),
8108            }],
8109            provider_adapter_contracts: vec![ProviderAdapterContractSupport {
8110                provider: agent("codex"),
8111                family: AdapterFamily::PtySemantic,
8112                adapter_id: AdapterId::new("codex-cli").unwrap(),
8113                revision: AdapterContractRevision::new("pty-semantic-v1").unwrap(),
8114            }],
8115        }
8116    }
8117
8118    #[test]
8119    fn legacy_hello_json_carries_the_build_stamp() {
8120        let client = ClientHello::new(ClientRole::Observer, [0; NODE_AUTH_NONCE_BYTES]);
8121        assert_eq!(
8122            serde_json::to_string(&client).unwrap(),
8123            format!(
8124                r#"{{"build_stamp":"{BUILD_STAMP}","role":"observer","client_nonce":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}}"#,
8125            ),
8126        );
8127
8128        let challenge = ServerChallenge {
8129            build_stamp: BUILD_STAMP.to_owned(),
8130            server_nonce: [0; NODE_AUTH_NONCE_BYTES],
8131            server_proof: [0; NODE_AUTH_PROOF_BYTES],
8132            compatibility: None,
8133        };
8134        let json = serde_json::to_string(&challenge).unwrap();
8135        assert!(!json.contains("compatibility"));
8136        assert_eq!(serde_json::from_str::<ServerChallenge>(&json).unwrap(), challenge);
8137    }
8138
8139    #[test]
8140    fn legacy_hello_omits_compatibility_instead_of_synthesizing_a_selection() {
8141        let hello = ClientHello::new(ClientRole::Observer, [0; NODE_AUTH_NONCE_BYTES]);
8142        assert_eq!(hello.compatibility, None);
8143    }
8144
8145    #[test]
8146    fn legacy_spawn_json_remains_exact_after_spawn_spec() {
8147        let request = NodeRequest::Spawn {
8148            workspace_id: WorkspaceId::new("primary").unwrap(),
8149            provider: agent("claude"),
8150            mode: SessionMode::Pty,
8151            terminal_size: TerminalSize {
8152                rows: 24,
8153                columns: 80,
8154            },
8155            initial_prompt: None,
8156        };
8157        assert_eq!(
8158            serde_json::to_string(&request).unwrap(),
8159            r#"{"kind":"spawn","workspace_id":"primary","provider":"claude","mode":"pty","terminal_size":{"rows":24,"columns":80},"initial_prompt":null}"#,
8160        );
8161        assert_eq!(request.required_capability(), None);
8162
8163        let response = NodeResponse::SpawnAccepted {
8164            session: SessionAddress {
8165                workspace_id: WorkspaceId::new("primary").unwrap(),
8166                session: SessionKey {
8167                    instance_id: AgentInstanceId(7),
8168                    generation: SessionGeneration(1),
8169                },
8170            },
8171        };
8172        assert_eq!(
8173            serde_json::to_string(&response).unwrap(),
8174            r#"{"kind":"spawn-accepted","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}}}"#,
8175        );
8176    }
8177
8178    #[test]
8179    fn history_loaded_completed_turn_count_is_optional_wire_metadata() {
8180        let legacy_json = r#"{"kind":"history-loaded","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}},"session_id":"session-7","message_count":12}"#;
8181        let legacy = serde_json::from_str::<NodeResponse>(legacy_json).unwrap();
8182        assert_eq!(serde_json::to_string(&legacy).unwrap(), legacy_json);
8183        assert!(matches!(
8184            legacy,
8185            NodeResponse::HistoryLoaded {
8186                completed_turn_count: None,
8187                ..
8188            }
8189        ));
8190
8191        let current = NodeResponse::HistoryLoaded {
8192            session: SessionAddress {
8193                workspace_id: WorkspaceId::new("primary").unwrap(),
8194                session: SessionKey {
8195                    instance_id: AgentInstanceId(7),
8196                    generation: SessionGeneration(1),
8197                },
8198            },
8199            session_id: "session-7".to_owned(),
8200            message_count: 12,
8201            completed_turn_count: Some(5),
8202        };
8203        assert_eq!(
8204            serde_json::to_string(&current).unwrap(),
8205            r#"{"kind":"history-loaded","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}},"session_id":"session-7","message_count":12,"completed_turn_count":5}"#,
8206        );
8207    }
8208
8209    #[test]
8210    fn spawn_spec_defaults_overrides_are_deterministic() {
8211        let profile_id = SpawnProfileId::new("review-default").unwrap();
8212        let defaults = SpawnProfileDefaults {
8213            profile_id: profile_id.clone(),
8214            revision: SpawnProfileRevision::new("review-default.r3").unwrap(),
8215            provider: agent("claude"),
8216            mode: SessionMode::Pty,
8217            terminal_size: TerminalSize {
8218                rows: 24,
8219                columns: 80,
8220            },
8221            prompt: Some(SpawnPrompt::new("profile prompt").unwrap()),
8222            bundle_id: Some(SpawnBundleId::new("review-bundle").unwrap()),
8223            context_id: Some(SpawnContextId::new("repo-context").unwrap()),
8224            environment_profile_id: Some(
8225                SpawnEnvironmentProfileId::new("local-default").unwrap(),
8226            ),
8227        };
8228        let spec = SpawnSpec {
8229            target: SpawnTarget {
8230                node_id: NodeId::new("node-a").unwrap(),
8231                workspace_id: WorkspaceId::new("primary").unwrap(),
8232                worktree_id: Some(WorkspaceId::new("review-tree").unwrap()),
8233            },
8234            profile_id,
8235            expected_profile_revision: defaults.revision.clone(),
8236            overrides: SpawnOverrides {
8237                provider: SpawnOverride::Inherit,
8238                mode: SpawnOverride::Set {
8239                    value: SessionMode::Inline,
8240                },
8241                terminal_size: SpawnOverride::Set {
8242                    value: TerminalSize {
8243                        rows: 31,
8244                        columns: 97,
8245                    },
8246                },
8247                prompt: SpawnOverride::Set {
8248                    value: SpawnPrompt::new("private prompt text").unwrap(),
8249                },
8250                bundle_id: SpawnOverride::Clear,
8251                context_id: SpawnOverride::Inherit,
8252                environment_profile_id: SpawnOverride::Clear,
8253                approval_level: None,
8254                network_allowlist: None,
8255                browser_profile_id: None,
8256            },
8257            deadline_ms: SpawnDeadlineMs::new(30_000).unwrap(),
8258            idempotency_key: SpawnIdempotencyKey::new("request-0001").unwrap(),
8259            required_capabilities: SpawnRequiredCapabilities::new([
8260                CapabilityId::new(SPAWN_RUNTIME_STRUCTURED_PROMPT).unwrap(),
8261                CapabilityId::new(SPAWN_RUNTIME_RAW_PTY_LIFECYCLE).unwrap(),
8262            ])
8263            .unwrap(),
8264        };
8265
8266        let first = spec.resolve(&defaults).unwrap();
8267        let second = spec.resolve(&defaults).unwrap();
8268        assert_eq!(first, second);
8269        assert_eq!(first.provider, agent("claude"));
8270        assert_eq!(first.mode, SessionMode::Inline);
8271        assert_eq!(first.terminal_size.rows, 31);
8272        assert_eq!(first.prompt.as_ref().unwrap().as_str(), "private prompt text");
8273        assert_eq!(first.bundle_id, None);
8274        assert_eq!(
8275            first.context_id.as_ref().map(SpawnContextId::as_str),
8276            Some("repo-context"),
8277        );
8278        assert_eq!(first.environment_profile_id, None);
8279        assert_eq!(first.network_allowlist, None);
8280        assert_eq!(first.browser_profile_id, None);
8281        assert_eq!(first.provenance.provider, SpawnFieldProvenance::Profile);
8282        assert_eq!(first.provenance.mode, SpawnFieldProvenance::Override);
8283        assert_eq!(first.provenance.prompt, SpawnFieldProvenance::Override);
8284        assert_eq!(first.provenance.bundle_id, SpawnFieldProvenance::Cleared);
8285        assert_eq!(first.provenance.context_id, SpawnFieldProvenance::Profile);
8286        assert_eq!(
8287            first.required_capabilities.as_slice(),
8288            &[
8289                CapabilityId::new(SPAWN_RUNTIME_RAW_PTY_LIFECYCLE).unwrap(),
8290                CapabilityId::new(SPAWN_RUNTIME_STRUCTURED_PROMPT).unwrap(),
8291            ],
8292        );
8293
8294        let source_session = session_address("primary", 7);
8295        let receipt = first.receipt_with_materialization(
8296            NodeIncarnationId::from_bytes([9; NODE_INCARNATION_ID_BYTES]),
8297            session_address("review-tree", 11),
8298            None,
8299            None,
8300            Some(context_receipt("repo-context", source_session)),
8301        );
8302        assert_eq!(receipt.prompt, SpawnPromptMetadata {
8303            present: true,
8304            byte_len: 19,
8305        });
8306        let receipt_json = serde_json::to_string(&receipt).unwrap();
8307        assert_eq!(
8308            receipt_json,
8309            r#"{"incarnation_id":"09090909090909090909090909090909","session":{"workspace_id":"review-tree","session":{"instance_id":11,"generation":1}},"target":{"node_id":"node-a","workspace_id":"primary","worktree_id":"review-tree"},"profile_id":"review-default","profile_revision":"review-default.r3","provider":"claude","mode":"inline","terminal_size":{"rows":31,"columns":97},"prompt":{"present":true,"byte_len":19},"bundle_id":null,"context_id":"repo-context","context":{"id":"repo-context","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","lineage":{"source_node_id":"node-a","source_session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}},"source_provider":"claude"},"source_message_count":3,"retained_message_count":2,"byte_len":32,"truncated":true},"environment_profile_id":null,"deadline_ms":30000,"idempotency_key":"request-0001","required_capabilities":["raw-pty-lifecycle","structured-prompt"],"provenance":{"provider":"profile","mode":"override","terminal_size":"override","prompt":"override","bundle_id":"cleared","context_id":"profile","environment_profile_id":"cleared"}}"#,
8310        );
8311        assert_eq!(
8312            serde_json::from_str::<ResolvedSpawnReceipt>(&receipt_json).unwrap(),
8313            receipt,
8314        );
8315        assert!(!receipt_json.contains("private prompt text"));
8316        assert!(!receipt_json.contains("profile prompt"));
8317
8318        let environment_profile = ResolvedEnvironmentProfileReceipt {
8319            profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8320            profile_revision: SpawnEnvironmentProfileRevision::new(
8321                "local-default.2026-08",
8322            )
8323            .unwrap(),
8324            network_allowlist: None,
8325            browser_profile_id: None,
8326        };
8327        let environment_receipt = first.receipt_with_materialization(
8328            NodeIncarnationId::from_bytes([9; NODE_INCARNATION_ID_BYTES]),
8329            receipt.session.clone(),
8330            Some(environment_profile.clone()),
8331            None,
8332            receipt.context.clone(),
8333        );
8334        let environment_json = serde_json::to_string(&environment_receipt).unwrap();
8335        assert!(environment_json.contains(
8336            r#""environment_profile_id":{"profile_id":"local-default","profile_revision":"local-default.2026-08"}"#,
8337        ));
8338        assert_eq!(
8339            serde_json::from_str::<ResolvedSpawnReceipt>(&environment_json)
8340                .unwrap()
8341                .environment_profile,
8342            Some(environment_profile),
8343        );
8344        assert!(serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8345            r#"{"profile_id":"local-default","profile_revision":"r1","value":"secret"}"#,
8346        )
8347        .is_err());
8348        assert!(SpawnEnvironmentProfileRevision::new(
8349            "r".repeat(MAX_SPAWN_ENVIRONMENT_PROFILE_REVISION_BYTES),
8350        )
8351        .is_ok());
8352        assert!(SpawnEnvironmentProfileRevision::new(
8353            "r".repeat(MAX_SPAWN_ENVIRONMENT_PROFILE_REVISION_BYTES + 1),
8354        )
8355        .is_err());
8356
8357        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
8358            .requires_child_environment_profile_capability());
8359        let mut explicit_environment = spec.clone();
8360        explicit_environment.overrides.environment_profile_id = SpawnOverride::Set {
8361            value: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8362        };
8363        assert!(NodeRequest::SpawnSpec {
8364            spec: explicit_environment,
8365        }
8366        .requires_child_environment_profile_capability());
8367        let mut inherited_environment = spec.clone();
8368        inherited_environment.overrides.environment_profile_id = SpawnOverride::Inherit;
8369        assert!(!NodeRequest::SpawnSpec {
8370            spec: inherited_environment,
8371        }
8372        .requires_child_environment_profile_capability());
8373
8374        let mut clear_required = spec.clone();
8375        clear_required.overrides.provider = SpawnOverride::Clear;
8376        assert!(matches!(
8377            clear_required.resolve(&defaults),
8378            Err(SpawnSpecResolveError::RequiredFieldCleared { field: "provider" }),
8379        ));
8380
8381        let mut stale_revision = spec.clone();
8382        stale_revision.expected_profile_revision =
8383            SpawnProfileRevision::new("review-default.r2").unwrap();
8384        assert!(matches!(
8385            stale_revision.resolve(&defaults),
8386            Err(SpawnSpecResolveError::ProfileRevisionMismatch {
8387                expected,
8388                loaded,
8389            }) if expected.as_str() == "review-default.r2"
8390                && loaded.as_str() == "review-default.r3",
8391        ));
8392
8393        let missing_revision_json = r#"{"target":{"node_id":"node-a","workspace_id":"primary"},"profile_id":"review-default","deadline_ms":1,"idempotency_key":"request-0002"}"#;
8394        assert!(serde_json::from_str::<SpawnSpec>(missing_revision_json).is_err());
8395        let minimal_json = r#"{"target":{"node_id":"node-a","workspace_id":"primary"},"profile_id":"review-default","expected_profile_revision":"review-default.r3","deadline_ms":1,"idempotency_key":"request-0002"}"#;
8396        let minimal = serde_json::from_str::<SpawnSpec>(minimal_json).unwrap();
8397        assert_eq!(minimal.overrides, SpawnOverrides::default());
8398        assert!(minimal.required_capabilities.is_empty());
8399        let mut unknown_field = serde_json::to_value(&minimal).unwrap();
8400        unknown_field["profile_revision"] = serde_json::json!("review-default.r3");
8401        assert!(serde_json::from_value::<SpawnSpec>(unknown_field).is_err());
8402        assert!(SpawnDeadlineMs::new(MAX_SPAWN_DEADLINE_MS + 1).is_err());
8403        assert!(SpawnProfileId::new("unsafe/profile").is_err());
8404        assert!(serde_json::from_str::<SpawnOverride<AgentId>>(
8405            r#"{"kind":"set","value":"claude","typo":true}"#,
8406        )
8407        .is_err());
8408    }
8409
8410    /// Station knobs slice (plan
8411    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §4): optional
8412    /// `network_allowlist` + `browser_profile_id` on `SpawnOverrides` and
8413    /// `ResolvedEnvironmentProfileReceipt` round-trip as opaque ids only.
8414    /// Omitted when None; secret-looking unknown fields are refused.
8415    /// Empty/whitespace ids refuse. Dig2browser-station bind / reachability
8416    /// refuse is node-side (stubbed until a cheap local probe exists).
8417    #[test]
8418    fn station_network_and_browser_profile_knobs_serde_ids_only() {
8419        let allowlist = SpawnNetworkAllowlistId::new("egress-default").unwrap();
8420        let browser = SpawnBrowserProfileId::new("station-profile-a").unwrap();
8421        assert_eq!(allowlist.as_str(), "egress-default");
8422        assert_eq!(browser.as_str(), "station-profile-a");
8423        assert!(SpawnNetworkAllowlistId::new("").is_err());
8424        assert!(SpawnBrowserProfileId::new("").is_err());
8425        assert!(SpawnNetworkAllowlistId::new(" ").is_err());
8426        assert!(SpawnBrowserProfileId::new("\t").is_err());
8427        assert!(SpawnNetworkAllowlistId::new("\n").is_err());
8428        assert!(SpawnBrowserProfileId::new("bad/id").is_err());
8429        assert!(SpawnNetworkAllowlistId::new(
8430            "x".repeat(MAX_SPAWN_RESOURCE_ID_BYTES + 1),
8431        )
8432        .is_err());
8433
8434        let overrides = SpawnOverrides {
8435            network_allowlist: Some(allowlist.clone()),
8436            browser_profile_id: Some(browser.clone()),
8437            ..SpawnOverrides::default()
8438        };
8439        let overrides_json = serde_json::to_string(&overrides).unwrap();
8440        assert_eq!(
8441            overrides_json,
8442            r#"{"provider":{"kind":"inherit"},"mode":{"kind":"inherit"},"terminal_size":{"kind":"inherit"},"prompt":{"kind":"inherit"},"bundle_id":{"kind":"inherit"},"context_id":{"kind":"inherit"},"environment_profile_id":{"kind":"inherit"},"network_allowlist":"egress-default","browser_profile_id":"station-profile-a"}"#,
8443        );
8444        assert!(!overrides_json.contains("cookie"));
8445        assert!(!overrides_json.contains("oauth"));
8446        assert!(!overrides_json.contains("password"));
8447        assert_eq!(
8448            serde_json::from_str::<SpawnOverrides>(&overrides_json).unwrap(),
8449            overrides,
8450        );
8451
8452        let omitted = serde_json::to_string(&SpawnOverrides::default()).unwrap();
8453        assert!(!omitted.contains("network_allowlist"));
8454        assert!(!omitted.contains("browser_profile_id"));
8455        assert_eq!(
8456            serde_json::from_str::<SpawnOverrides>(r#"{}"#).unwrap(),
8457            SpawnOverrides::default(),
8458        );
8459
8460        let mut unknown = serde_json::to_value(&overrides).unwrap();
8461        unknown["proxy_password"] = serde_json::json!("nope");
8462        assert!(serde_json::from_value::<SpawnOverrides>(unknown).is_err());
8463        let mut cookie_field = serde_json::to_value(&overrides).unwrap();
8464        cookie_field["cookie_bytes"] = serde_json::json!("deadbeef");
8465        assert!(serde_json::from_value::<SpawnOverrides>(cookie_field).is_err());
8466
8467        let receipt = ResolvedEnvironmentProfileReceipt {
8468            profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8469            profile_revision: SpawnEnvironmentProfileRevision::new("r1").unwrap(),
8470            network_allowlist: Some(allowlist),
8471            browser_profile_id: Some(browser),
8472        };
8473        let receipt_json = serde_json::to_string(&receipt).unwrap();
8474        assert_eq!(
8475            receipt_json,
8476            r#"{"profile_id":"local-default","profile_revision":"r1","network_allowlist":"egress-default","browser_profile_id":"station-profile-a"}"#,
8477        );
8478        assert_eq!(
8479            serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(&receipt_json).unwrap(),
8480            receipt,
8481        );
8482
8483        let bare = ResolvedEnvironmentProfileReceipt {
8484            profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8485            profile_revision: SpawnEnvironmentProfileRevision::new("r1").unwrap(),
8486            network_allowlist: None,
8487            browser_profile_id: None,
8488        };
8489        assert_eq!(
8490            serde_json::to_string(&bare).unwrap(),
8491            r#"{"profile_id":"local-default","profile_revision":"r1"}"#,
8492        );
8493        assert_eq!(
8494            serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8495                r#"{"profile_id":"local-default","profile_revision":"r1"}"#,
8496            )
8497            .unwrap(),
8498            bare,
8499        );
8500        assert!(serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8501            r#"{"profile_id":"local-default","profile_revision":"r1","cookie":"x"}"#,
8502        )
8503        .is_err());
8504        assert!(serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8505            r#"{"profile_id":"local-default","profile_revision":"r1","oauth_token":"x"}"#,
8506        )
8507        .is_err());
8508        // Empty / whitespace ids refuse at type + serde (not silent ambient).
8509        assert!(serde_json::from_str::<SpawnOverrides>(
8510            r#"{"network_allowlist":""}"#,
8511        )
8512        .is_err());
8513        assert!(serde_json::from_str::<SpawnOverrides>(
8514            r#"{"browser_profile_id":" "}"#,
8515        )
8516        .is_err());
8517    }
8518
8519    /// `SpawnSpec::resolve` copies station knobs onto `ResolvedSpawnSpec` so
8520    /// node resolve can echo them onto `ResolvedEnvironmentProfileReceipt`.
8521    /// Empty/whitespace already refused at id construction; no dig2browser
8522    /// station probe in this crate.
8523    #[test]
8524    fn spawn_spec_resolve_echoes_station_network_and_browser_profile_knobs() {
8525        let profile_id = SpawnProfileId::new("review-default").unwrap();
8526        let defaults = SpawnProfileDefaults {
8527            profile_id: profile_id.clone(),
8528            revision: SpawnProfileRevision::new("review-default.r3").unwrap(),
8529            provider: agent("claude"),
8530            mode: SessionMode::Pty,
8531            terminal_size: TerminalSize {
8532                rows: 24,
8533                columns: 80,
8534            },
8535            prompt: None,
8536            bundle_id: None,
8537            context_id: None,
8538            environment_profile_id: Some(
8539                SpawnEnvironmentProfileId::new("local-default").unwrap(),
8540            ),
8541        };
8542        let allowlist = SpawnNetworkAllowlistId::new("egress-default").unwrap();
8543        let browser = SpawnBrowserProfileId::new("station-profile-a").unwrap();
8544        let spec = SpawnSpec {
8545            target: SpawnTarget {
8546                node_id: NodeId::new("node-a").unwrap(),
8547                workspace_id: WorkspaceId::new("primary").unwrap(),
8548                worktree_id: None,
8549            },
8550            profile_id,
8551            expected_profile_revision: defaults.revision.clone(),
8552            overrides: SpawnOverrides {
8553                network_allowlist: Some(allowlist.clone()),
8554                browser_profile_id: Some(browser.clone()),
8555                ..SpawnOverrides::default()
8556            },
8557            deadline_ms: SpawnDeadlineMs::new(30_000).unwrap(),
8558            idempotency_key: SpawnIdempotencyKey::new("request-station-knobs").unwrap(),
8559            required_capabilities: SpawnRequiredCapabilities::default(),
8560        };
8561        let resolved = spec.resolve(&defaults).unwrap();
8562        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
8563        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
8564        assert_eq!(
8565            resolved
8566                .environment_profile_id
8567                .as_ref()
8568                .map(SpawnEnvironmentProfileId::as_str),
8569            Some("local-default"),
8570        );
8571
8572        let omitted = SpawnSpec {
8573            overrides: SpawnOverrides::default(),
8574            idempotency_key: SpawnIdempotencyKey::new("request-station-knobs-omit").unwrap(),
8575            ..spec.clone()
8576        };
8577        let resolved_omit = omitted.resolve(&defaults).unwrap();
8578        assert_eq!(resolved_omit.network_allowlist, None);
8579        assert_eq!(resolved_omit.browser_profile_id, None);
8580    }
8581
8582    #[test]
8583    fn session_bundle_materialization_contract_is_bounded_exact_and_dual_gated() {
8584        assert_eq!(NODE_STATE_SCHEMA_V7, 7);
8585        assert_eq!(NODE_STATE_SCHEMA_V8, 8);
8586        assert_eq!(
8587            NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
8588            "session-bundle-materialization-v1",
8589        );
8590        let capability =
8591            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
8592        assert!(production_node_client_compatibility_offer()
8593            .capabilities
8594            .contains(&capability));
8595        let mut support = portable_node_support();
8596        support.capabilities = vec![capability.clone()];
8597        let offer = ClientCompatibilityOffer {
8598            build_stamp: BUILD_STAMP.to_owned(),
8599            capabilities: vec![capability.clone()],
8600            state_schema: None,
8601        };
8602        let selected = support
8603            .negotiate(&offer)
8604            .unwrap();
8605        assert_eq!(selected.capabilities, vec![capability]);
8606        let bound = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8607        assert!(bound
8608            .windows(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.len())
8609            .any(|window| {
8610                window == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.as_bytes()
8611            }));
8612        assert!(SpawnBundleRevision::new(
8613            "r".repeat(MAX_SPAWN_BUNDLE_REVISION_BYTES),
8614        )
8615        .is_ok());
8616        assert!(SpawnBundleRevision::new(
8617            "r".repeat(MAX_SPAWN_BUNDLE_REVISION_BYTES + 1),
8618        )
8619        .is_err());
8620
8621        let digest = format!("sha256:{}", "a".repeat(64));
8622        let receipt = ResolvedBundleReceipt {
8623            id: SpawnBundleId::new("review-bundle").unwrap(),
8624            revision: SpawnBundleRevision::new("review-bundle.r1").unwrap(),
8625            digest: SpawnBundleDigest::new(&digest).unwrap(),
8626        };
8627        assert_eq!(
8628            serde_json::to_string(&receipt).unwrap(),
8629            format!(
8630                r#"{{"id":"review-bundle","revision":"review-bundle.r1","digest":"{digest}"}}"#,
8631            ),
8632        );
8633        for invalid in [
8634            format!("sha256:{}", "a".repeat(63)),
8635            format!("sha256:{}", "A".repeat(64)),
8636            "sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
8637                .to_owned(),
8638        ] {
8639            assert!(SpawnBundleDigest::new(invalid).is_err());
8640        }
8641
8642        let minimal_json = r#"{"target":{"node_id":"node-a","workspace_id":"primary"},"profile_id":"review-default","expected_profile_revision":"review-default.r3","deadline_ms":1,"idempotency_key":"request-bundle"}"#;
8643        let inherited = serde_json::from_str::<SpawnSpec>(minimal_json).unwrap();
8644        assert!(NodeRequest::SpawnSpec {
8645            spec: inherited.clone(),
8646        }
8647        .requires_session_bundle_materialization_capability());
8648
8649        let mut explicit = inherited.clone();
8650        explicit.overrides.bundle_id = SpawnOverride::Set {
8651            value: SpawnBundleId::new("review-bundle").unwrap(),
8652        };
8653        assert!(NodeRequest::SpawnSpec { spec: explicit }
8654            .requires_session_bundle_materialization_capability());
8655
8656        let mut cleared = inherited;
8657        cleared.overrides.bundle_id = SpawnOverride::Clear;
8658        assert!(!NodeRequest::SpawnSpec { spec: cleared }
8659            .requires_session_bundle_materialization_capability());
8660    }
8661
8662    #[test]
8663    fn provider_ids_preserve_legacy_json_and_accept_open_values() {
8664        for (provider, expected) in [
8665            (agent("claude"), r#""claude""#),
8666            (agent("codex"), r#""codex""#),
8667            (agent("kimi"), r#""kimi""#),
8668        ] {
8669            assert!(provider_id_is_legacy(&provider));
8670            assert_eq!(serde_json::to_string(&provider).unwrap(), expected);
8671            assert_eq!(serde_json::from_str::<AgentId>(expected).unwrap(), provider);
8672        }
8673
8674        let open = agent("third-party-agent");
8675        assert!(!provider_id_is_legacy(&open));
8676        assert_eq!(serde_json::to_string(&open).unwrap(), r#""third-party-agent""#);
8677    }
8678
8679    #[test]
8680    fn provider_runtime_wire_is_exact_bounded_and_consistent() {
8681        let statuses = ProviderRuntimeStatuses::new([
8682            ProviderRuntimeStatus::raw_passthrough(
8683                agent("claude"),
8684                Some(ProviderRuntimeVersion::new("2.1.220").unwrap()),
8685            ),
8686            ProviderRuntimeStatus::verified_semantic(
8687                agent("codex"),
8688                ProviderRuntimeVersion::new("0.147.0").unwrap(),
8689                ProviderRuntimeContractId::new("codex.windows-x86_64.0.147.0").unwrap(),
8690            ),
8691            ProviderRuntimeStatus::unavailable(agent("kimi")),
8692        ])
8693        .unwrap();
8694        let encoded = serde_json::to_string(&statuses).unwrap();
8695        assert_eq!(
8696            encoded,
8697            r#"[{"provider":"claude","mode":"raw-passthrough","version":"2.1.220"},{"provider":"codex","mode":"verified-semantic","version":"0.147.0","contract_id":"codex.windows-x86_64.0.147.0"},{"provider":"kimi","mode":"unavailable"}]"#,
8698        );
8699        assert_eq!(
8700            serde_json::from_str::<ProviderRuntimeStatuses>(&encoded).unwrap(),
8701            statuses,
8702        );
8703
8704        let duplicate = r#"[{"provider":"codex","mode":"unavailable"},{"provider":"codex","mode":"unavailable"}]"#;
8705        assert!(serde_json::from_str::<ProviderRuntimeStatuses>(duplicate).is_err());
8706        let too_many = (0..=MAX_PROVIDER_RUNTIME_STATUSES)
8707            .map(|index| ProviderRuntimeStatus::unavailable(agent(&format!("provider-{index}"))))
8708            .collect::<Vec<_>>();
8709        assert!(matches!(
8710            ProviderRuntimeStatuses::new(too_many),
8711            Err(ProviderRuntimeStatusError::TooMany {
8712                max: MAX_PROVIDER_RUNTIME_STATUSES,
8713            }),
8714        ));
8715        let inconsistent = r#"[{"provider":"codex","mode":"verified-semantic","version":"0.147.0"}]"#;
8716        assert!(serde_json::from_str::<ProviderRuntimeStatuses>(inconsistent).is_err());
8717        for arbitrary in ["secret-token", "raw.stdout", "1.2", "01.2.3", "1.2.3.4"] {
8718            assert!(ProviderRuntimeVersion::new(arbitrary).is_err(), "accepted {arbitrary}");
8719        }
8720        let overflow = format!(
8721            r#"[{{"provider":"codex","mode":"raw-passthrough","version":"{}"}}]"#,
8722            "1".repeat(MAX_PROVIDER_RUNTIME_VERSION_BYTES + 1),
8723        );
8724        assert!(serde_json::from_str::<ProviderRuntimeStatuses>(&overflow).is_err());
8725    }
8726
8727    #[test]
8728    fn legacy_node_snapshot_defaults_runtime_status_to_unreported() {
8729        let legacy = r#"{"node_id":"legacy-node","enabled_providers":["codex"],"workspaces":[]}"#;
8730        let snapshot = serde_json::from_str::<NodeSnapshot>(legacy).unwrap();
8731        assert!(snapshot.provider_runtime_statuses.is_empty());
8732        let reencoded = serde_json::to_string(&snapshot).unwrap();
8733        assert!(!reencoded.contains("provider_runtime"));
8734    }
8735
8736    #[test]
8737    fn compatibility_negotiation_keeps_the_build_stamp_and_selects_highest_state_schema() {
8738        let offer = ClientCompatibilityOffer {
8739            build_stamp: BUILD_STAMP.to_owned(),
8740            capabilities: vec![
8741                CapabilityId::new("session.spawn").unwrap(),
8742                CapabilityId::new("unknown.future").unwrap(),
8743            ],
8744            state_schema: Some(StateSchemaSupport {
8745                versions: ProtocolRange::new(4, 6).unwrap(),
8746            }),
8747        };
8748        let hello = ClientHello::negotiating(
8749            ClientRole::Operator,
8750            [1; NODE_AUTH_NONCE_BYTES],
8751            offer.clone(),
8752        );
8753        assert_eq!(hello.compatibility, Some(offer.clone()));
8754
8755        let support = portable_node_support();
8756        let negotiated = support.negotiate(&offer).unwrap();
8757        assert_eq!(negotiated.build_stamp, BUILD_STAMP);
8758        assert_eq!(negotiated.state_schema_version, Some(5));
8759        assert_eq!(
8760            negotiated.capabilities,
8761            vec![CapabilityId::new("session.spawn").unwrap()],
8762        );
8763        assert!(negotiated.provider_contracts.is_empty());
8764        assert!(negotiated.provider_adapter_contracts.is_empty());
8765    }
8766
8767    #[test]
8768    fn negotiate_rejects_a_foreign_build_stamp_naming_both_values() {
8769        let foreign_stamp = "f".repeat(40);
8770        let offer = ClientCompatibilityOffer {
8771            build_stamp: foreign_stamp.clone(),
8772            capabilities: Vec::new(),
8773            state_schema: None,
8774        };
8775        let error = portable_node_support().negotiate(&offer).unwrap_err();
8776        assert!(matches!(
8777            &error,
8778            ProtocolNegotiationError::BuildStampMismatch { local, remote }
8779                if local == BUILD_STAMP && remote == &foreign_stamp,
8780        ));
8781        assert_eq!(
8782            error.to_string(),
8783            format!("build stamp mismatch: local={BUILD_STAMP} remote={foreign_stamp}"),
8784        );
8785    }
8786
8787    #[test]
8788    fn compatibility_auth_binding_has_an_exact_bounded_encoding() {
8789        let offer = ClientCompatibilityOffer {
8790            build_stamp: BUILD_STAMP.to_owned(),
8791            capabilities: vec![CapabilityId::new("session.spawn").unwrap()],
8792            state_schema: Some(StateSchemaSupport {
8793                versions: ProtocolRange::new(4, 6).unwrap(),
8794            }),
8795        };
8796        let support = portable_node_support();
8797        let selected = support
8798            .negotiate(&offer)
8799            .unwrap();
8800        let encoded = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8801        assert_eq!(
8802            String::from_utf8(encoded).unwrap(),
8803            format!(
8804                r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["session.spawn"],"state_schema":{{"versions":{{"minimum":4,"maximum":6}}}}}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["session.spawn"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","state_schema_version":5,"provider_contracts":[]}}}}"#,
8805            ),
8806        );
8807    }
8808
8809    #[test]
8810    fn provider_contract_manifest_is_capability_gated_and_auth_bound_exactly() {
8811        let manifest_capability =
8812            CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap();
8813        let mut support = portable_node_support();
8814        support.capabilities.push(manifest_capability.clone());
8815        let offer = ClientCompatibilityOffer {
8816            build_stamp: BUILD_STAMP.to_owned(),
8817            capabilities: vec![manifest_capability.clone()],
8818            state_schema: None,
8819        };
8820        let selected = support
8821            .negotiate(&offer)
8822            .unwrap();
8823        assert_eq!(selected.capabilities, vec![manifest_capability]);
8824        assert_eq!(selected.provider_contracts, support.provider_contracts);
8825        assert_eq!(
8826            selected.provider_adapter_contracts,
8827            support.provider_adapter_contracts,
8828        );
8829        let encoded = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8830        assert_eq!(
8831            String::from_utf8(encoded).unwrap(),
8832            format!(
8833                r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["provider-contract-manifest-v1"]}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["provider-contract-manifest-v1"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","provider_contracts":[{{"provider":"codex","revision":"codex.2026-08"}}],"provider_adapter_contracts":[{{"provider":"codex","family":"pty-semantic","adapter_id":"codex-cli","revision":"pty-semantic-v1"}}]}}}}"#,
8834            ),
8835        );
8836    }
8837
8838    #[test]
8839    fn open_provider_capability_and_manifest_are_auth_bound_exactly() {
8840        let manifest_capability =
8841            CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap();
8842        let open_capability = CapabilityId::new(NODE_PROVIDER_ID_OPEN_CAPABILITY).unwrap();
8843        let mut support = portable_node_support();
8844        support.capabilities = vec![manifest_capability.clone(), open_capability.clone()];
8845        support.provider_contracts = vec![ProviderContractSupport {
8846            provider: agent("third-party-agent"),
8847            revision: ProviderContractRevision::new("third-party.2026-08").unwrap(),
8848        }];
8849        support.provider_adapter_contracts.clear();
8850        let offer = ClientCompatibilityOffer {
8851            build_stamp: BUILD_STAMP.to_owned(),
8852            capabilities: vec![manifest_capability, open_capability],
8853            state_schema: None,
8854        };
8855        let selected = support
8856            .negotiate(&offer)
8857            .unwrap();
8858        let encoded = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8859        assert_eq!(
8860            String::from_utf8(encoded).unwrap(),
8861            format!(
8862                r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["provider-contract-manifest-v1","provider-id.open-v1"]}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["provider-contract-manifest-v1","provider-id.open-v1"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","provider_contracts":[{{"provider":"third-party-agent","revision":"third-party.2026-08"}}]}}}}"#,
8863            ),
8864        );
8865    }
8866
8867    #[test]
8868    fn n_minus_one_selected_json_round_trips_without_manifest_fields() {
8869        let json = r#"{"build_stamp":"n-minus-one-stamp","capabilities":["compatibility.metadata"],"host":{"operating_system":"windows","architecture":"x86_64"},"path_semantics":{"style":"windows","encoding":"utf8"},"local_transport":"windows-named-pipe","state_schema_version":1,"provider_contracts":[]}"#;
8870        let selected: NegotiatedNodeCompatibility = serde_json::from_str(json).unwrap();
8871        assert!(selected.provider_contracts.is_empty());
8872        assert!(selected.provider_adapter_contracts.is_empty());
8873        assert_eq!(serde_json::to_string(&selected).unwrap(), json);
8874    }
8875
8876    #[test]
8877    fn provider_contract_manifest_rejects_bounds_duplicates_and_unlinked_entries() {
8878        let provider = ProviderContractSupport {
8879            provider: agent("codex"),
8880            revision: ProviderContractRevision::new("codex.2026-08").unwrap(),
8881        };
8882        let adapter = ProviderAdapterContractSupport {
8883            provider: agent("codex"),
8884            family: AdapterFamily::PtySemantic,
8885            adapter_id: AdapterId::new("codex-cli").unwrap(),
8886            revision: AdapterContractRevision::new("pty-semantic-v1").unwrap(),
8887        };
8888        assert!(matches!(
8889            validate_provider_contract_manifest(
8890                &vec![provider.clone(); MAX_PROVIDER_CONTRACTS + 1],
8891                &[],
8892            ),
8893            Err(ProviderContractManifestError::TooManyProviders { .. }),
8894        ));
8895        assert!(matches!(
8896            validate_provider_contract_manifest(
8897                &[provider.clone()],
8898                &vec![adapter.clone(); MAX_PROVIDER_ADAPTER_CONTRACTS + 1],
8899            ),
8900            Err(ProviderContractManifestError::TooManyAdapterContracts { .. }),
8901        ));
8902        assert!(matches!(
8903            validate_provider_contract_manifest(
8904                &[provider.clone()],
8905                &vec![adapter.clone(); MAX_PROVIDER_ADAPTER_CONTRACTS],
8906            ),
8907            Err(ProviderContractManifestError::DuplicateProviderFamily { .. }),
8908        ));
8909        assert!(matches!(
8910            validate_provider_contract_manifest(&[provider.clone(), provider.clone()], &[]),
8911            Err(ProviderContractManifestError::DuplicateProvider { provider })
8912                if provider == agent("codex"),
8913        ));
8914        let mut unlinked = adapter.clone();
8915        unlinked.provider = agent("claude");
8916        assert!(matches!(
8917            validate_provider_contract_manifest(&[provider.clone()], &[unlinked]),
8918            Err(ProviderContractManifestError::UnlinkedAdapterProvider { provider })
8919                if provider == agent("claude"),
8920        ));
8921        let mut duplicate_family = adapter.clone();
8922        duplicate_family.adapter_id = AdapterId::new("codex-cli-next").unwrap();
8923        assert!(matches!(
8924            validate_provider_contract_manifest(
8925                &[provider.clone()],
8926                &[adapter.clone(), duplicate_family],
8927            ),
8928            Err(ProviderContractManifestError::DuplicateProviderFamily {
8929                provider,
8930                family: AdapterFamily::PtySemantic,
8931            }) if provider == agent("codex"),
8932        ));
8933        let mut shared_adapter_id = adapter.clone();
8934        shared_adapter_id.family = AdapterFamily::History;
8935        assert!(validate_provider_contract_manifest(
8936            &[provider],
8937            &[adapter, shared_adapter_id],
8938        )
8939        .is_ok());
8940    }
8941
8942    #[test]
8943    fn provider_contract_manifest_negotiates_all_current_provider_family_tuples() {
8944        let providers = [
8945            (agent("claude"), "claude-code", "claude.2026-08"),
8946            (agent("codex"), "codex-cli", "codex.2026-08"),
8947            (agent("kimi"), "kimi-cli", "kimi.2026-08"),
8948        ];
8949        let families = [
8950            AdapterFamily::PtySemantic,
8951            AdapterFamily::Pipe,
8952            AdapterFamily::OneShot,
8953            AdapterFamily::Acp,
8954            AdapterFamily::Hook,
8955            AdapterFamily::ManagedHook,
8956            AdapterFamily::History,
8957            AdapterFamily::Resume,
8958            AdapterFamily::SessionOptions,
8959            AdapterFamily::CapabilityProbe,
8960        ];
8961        let provider_contracts = providers
8962            .iter()
8963            .map(|(provider, _, revision)| ProviderContractSupport {
8964                provider: provider.clone(),
8965                revision: ProviderContractRevision::new(*revision).unwrap(),
8966            })
8967            .collect::<Vec<_>>();
8968        let provider_adapter_contracts = providers
8969            .iter()
8970            .flat_map(|(provider, adapter_id, _)| {
8971                families
8972                    .iter()
8973                    .map(move |family| ProviderAdapterContractSupport {
8974                        provider: provider.clone(),
8975                        family: *family,
8976                        adapter_id: AdapterId::new(*adapter_id).unwrap(),
8977                        revision: AdapterContractRevision::new("adapter-contract-v1").unwrap(),
8978                    })
8979            })
8980            .collect::<Vec<_>>();
8981        assert_eq!(provider_contracts.len(), 3);
8982        assert_eq!(provider_adapter_contracts.len(), 30);
8983        assert!(provider_adapter_contracts.len() <= MAX_PROVIDER_ADAPTER_CONTRACTS);
8984
8985        let manifest_capability =
8986            CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap();
8987        let mut support = portable_node_support();
8988        support.capabilities.push(manifest_capability.clone());
8989        support.provider_contracts = provider_contracts;
8990        support.provider_adapter_contracts = provider_adapter_contracts;
8991        let selected = support
8992            .negotiate(&ClientCompatibilityOffer {
8993                build_stamp: BUILD_STAMP.to_owned(),
8994                capabilities: vec![manifest_capability],
8995                state_schema: None,
8996            })
8997            .unwrap();
8998        assert_eq!(selected.provider_contracts.len(), 3);
8999        assert_eq!(selected.provider_adapter_contracts.len(), 30);
9000    }
9001
9002    #[test]
9003    fn sixteen_provider_manifest_headroom_fits_the_authenticated_handshake() {
9004        assert_eq!(MAX_PROVIDER_IDENTITIES, 16);
9005        let providers = (0..MAX_PROVIDER_IDENTITIES)
9006            .map(|index| agent(&format!("provider-{index}")))
9007            .collect::<Vec<_>>();
9008        let mut support = portable_node_support();
9009        support.capabilities.extend([
9010            CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap(),
9011            CapabilityId::new(NODE_PROVIDER_ID_OPEN_CAPABILITY).unwrap(),
9012        ]);
9013        support.provider_contracts = providers
9014            .iter()
9015            .map(|provider| ProviderContractSupport {
9016                provider: provider.clone(),
9017                revision: ProviderContractRevision::new(format!(
9018                    "{}.2026-08",
9019                    provider.as_str(),
9020                ))
9021                .unwrap(),
9022            })
9023            .collect();
9024        support.provider_adapter_contracts = providers
9025            .iter()
9026            .map(|provider| ProviderAdapterContractSupport {
9027                provider: provider.clone(),
9028                family: AdapterFamily::PtySemantic,
9029                adapter_id: AdapterId::new(format!("{}-cli", provider.as_str())).unwrap(),
9030                revision: AdapterContractRevision::new("pty-semantic-v1").unwrap(),
9031            })
9032            .collect();
9033
9034        validate_node_negotiated_handshake_capacity(&support).unwrap();
9035    }
9036
9037    #[test]
9038    fn adapter_contract_revision_is_bounded_printable_ascii() {
9039        assert!(AdapterContractRevision::new("history-jsonl-v1").is_ok());
9040        assert!(AdapterContractRevision::new("").is_err());
9041        assert!(AdapterContractRevision::new("revision with spaces").is_err());
9042        assert!(AdapterContractRevision::new(
9043            "x".repeat(MAX_ADAPTER_CONTRACT_REVISION_BYTES + 1),
9044        )
9045        .is_err());
9046    }
9047
9048    #[test]
9049    fn protocol_ranges_reject_invalid_and_disjoint_inputs() {
9050        assert!(matches!(
9051            ProtocolRange::new(0, 8),
9052            Err(ProtocolNegotiationError::InvalidRange { minimum: 0, maximum: 8 }),
9053        ));
9054        assert!(matches!(
9055            ProtocolRange::new(9, 8),
9056            Err(ProtocolNegotiationError::InvalidRange { minimum: 9, maximum: 8 }),
9057        ));
9058        let error = ProtocolRange::new(7, 8)
9059            .unwrap()
9060            .highest_common(ProtocolRange::new(9, 10).unwrap())
9061            .unwrap_err();
9062        assert!(matches!(error, ProtocolNegotiationError::Disjoint { .. }));
9063        assert!(serde_json::from_str::<ProtocolRange>(
9064            r#"{"minimum":10,"maximum":9}"#,
9065        )
9066        .is_err());
9067        assert!(ProviderContractRevision::new(
9068            "gate4agent-inline/codex-cli-0.144/v1",
9069        )
9070        .is_ok());
9071        assert!(ProviderContractRevision::new(
9072            "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42",
9073        )
9074        .is_ok());
9075    }
9076
9077    #[test]
9078    fn foreign_path_metadata_round_trips_without_normalization() {
9079        #[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
9080        struct ForeignPath {
9081            raw: String,
9082            semantics: PathSemantics,
9083        }
9084
9085        let foreign = ForeignPath {
9086            raw: r"C:\Users\operator\repo\src\lib.rs".to_owned(),
9087            semantics: portable_node_support().path_semantics,
9088        };
9089        let json = serde_json::to_string(&foreign).unwrap();
9090        assert!(json.contains(r#""raw":"C:\\Users\\operator\\repo\\src\\lib.rs""#));
9091        assert_eq!(serde_json::from_str::<ForeignPath>(&json).unwrap(), foreign);
9092    }
9093
9094    #[test]
9095    fn opaque_host_path_utf8_preserves_legacy_json_string_shape() {
9096        let path = host_path(r"C:\Users\operator\repo");
9097        assert_eq!(path.byte_len(), 22);
9098        assert_eq!(path.as_utf8(), Some(r"C:\Users\operator\repo"));
9099        assert_eq!(path.as_unix_bytes(), None);
9100        assert_eq!(path.display_text(), r"C:\Users\operator\repo");
9101
9102        let json = serde_json::to_string(&path).unwrap();
9103        assert_eq!(json, r#""C:\\Users\\operator\\repo""#);
9104        assert_eq!(serde_json::from_str::<OpaqueHostPath>(&json).unwrap(), path);
9105    }
9106
9107    #[test]
9108    fn opaque_host_path_unix_bytes_has_strict_bounded_tagged_wire_shape() {
9109        let raw = vec![b'/', b'r', b'e', b'p', b'o', b'/', 0xff];
9110        let path = OpaqueHostPath::unix_bytes(raw.clone()).unwrap();
9111        assert_eq!(path.byte_len(), raw.len());
9112        assert_eq!(path.as_utf8(), None);
9113        assert_eq!(path.as_unix_bytes(), Some(raw.as_slice()));
9114
9115        let json = serde_json::to_string(&path).unwrap();
9116        assert_eq!(
9117            json,
9118            r#"{"kind":"unix-bytes","bytes":[47,114,101,112,111,47,255]}"#,
9119        );
9120        assert_eq!(serde_json::from_str::<OpaqueHostPath>(&json).unwrap(), path);
9121
9122        for invalid in [
9123            r#"{"kind":"future","bytes":[47]}"#,
9124            r#"{"kind":"unix-bytes","bytes":[47],"extra":true}"#,
9125            r#"{"kind":"unix-bytes"}"#,
9126            r#"{"bytes":[47]}"#,
9127            r#"{"kind":"unix-bytes","bytes":[]}"#,
9128            r#"{"kind":"unix-bytes","bytes":[47,0]}"#,
9129        ] {
9130            assert!(serde_json::from_str::<OpaqueHostPath>(invalid).is_err(), "{invalid}");
9131        }
9132        assert!(OpaqueHostPath::utf8(String::new()).is_err());
9133        assert!(OpaqueHostPath::utf8("a\0b".to_owned()).is_err());
9134        assert!(OpaqueHostPath::utf8("x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1)).is_err());
9135        assert!(OpaqueHostPath::unix_bytes(vec![b'x'; MAX_WORKSPACE_ROOT_BYTES + 1]).is_err());
9136    }
9137
9138    #[test]
9139    fn repository_path_utf8_preserves_legacy_json_string_shape_and_typed_components() {
9140        let path = repository_path(r"src\literal-name/lib.rs");
9141        assert_eq!(path.byte_len(), 23);
9142        assert_eq!(path.as_utf8(), Some(r"src\literal-name/lib.rs"));
9143        assert_eq!(path.as_unix_bytes(), None);
9144        assert_eq!(path.display_text(), r"src\literal-name/lib.rs");
9145        assert_eq!(path.component_count(), 2);
9146        assert_eq!(path.depth(), 1);
9147        assert_eq!(path.file_name_bytes(), b"lib.rs");
9148        assert_eq!(path.file_name_display_text(), "lib.rs");
9149        assert!(path.is_descendant_of(&repository_path(r"src\literal-name")));
9150        assert!(!path.is_descendant_of(&repository_path("src")));
9151
9152        let json = serde_json::to_string(&path).unwrap();
9153        assert_eq!(json, r#""src\\literal-name/lib.rs""#);
9154        assert_eq!(serde_json::from_str::<RepositoryPath>(&json).unwrap(), path);
9155    }
9156
9157    #[test]
9158    fn repository_path_unix_bytes_has_strict_bounded_tagged_wire_shape() {
9159        let raw = vec![b's', b'r', b'c', b'/', 0xff, b'/', b'f'];
9160        let path = RepositoryPath::unix_bytes(raw.clone()).unwrap();
9161        assert_eq!(path.byte_len(), raw.len());
9162        assert_eq!(path.as_bytes(), raw);
9163        assert_eq!(path.as_utf8(), None);
9164        assert_eq!(path.as_unix_bytes(), Some(raw.as_slice()));
9165        assert_eq!(path.component_count(), 3);
9166        assert_eq!(path.depth(), 2);
9167        assert_eq!(path.file_name_bytes(), b"f");
9168        assert!(path.is_descendant_of(
9169            &RepositoryPath::unix_bytes(vec![b's', b'r', b'c', b'/', 0xff]).unwrap(),
9170        ));
9171
9172        let json = serde_json::to_string(&path).unwrap();
9173        assert_eq!(
9174            json,
9175            r#"{"kind":"unix-bytes","bytes":[115,114,99,47,255,47,102]}"#,
9176        );
9177        assert_eq!(serde_json::from_str::<RepositoryPath>(&json).unwrap(), path);
9178    }
9179
9180    #[test]
9181    fn repository_path_physical_identity_ignores_wire_representation() {
9182        use std::collections::{BTreeSet, HashSet};
9183
9184        let utf8 = repository_path("src/main.rs");
9185        let tagged = RepositoryPath::unix_bytes(b"src/main.rs".to_vec()).unwrap();
9186        assert_eq!(utf8, tagged);
9187        assert_eq!(utf8.cmp(&tagged), Ordering::Equal);
9188        assert_eq!(utf8.as_unix_bytes(), None);
9189        assert_eq!(tagged.as_unix_bytes(), Some(b"src/main.rs".as_slice()));
9190        assert_eq!(serde_json::to_string(&utf8).unwrap(), r#""src/main.rs""#);
9191        assert_eq!(
9192            serde_json::to_string(&tagged).unwrap(),
9193            r#"{"kind":"unix-bytes","bytes":[115,114,99,47,109,97,105,110,46,114,115]}"#,
9194        );
9195
9196        let mut ordered = BTreeSet::new();
9197        ordered.insert(utf8.clone());
9198        ordered.insert(tagged.clone());
9199        assert_eq!(ordered.len(), 1);
9200
9201        let mut hashed = HashSet::new();
9202        hashed.insert(utf8);
9203        hashed.insert(tagged);
9204        assert_eq!(hashed.len(), 1);
9205
9206        let backslash = repository_path(r"src\main.rs");
9207        let slash = repository_path("src/main.rs");
9208        assert_ne!(backslash, slash);
9209        assert_ne!(backslash.cmp(&slash), Ordering::Equal);
9210    }
9211
9212    #[test]
9213    fn repository_path_rejects_non_canonical_or_unbounded_components() {
9214        for invalid in [
9215            "",
9216            "/",
9217            "/src",
9218            "src/",
9219            "src//lib.rs",
9220            ".",
9221            "..",
9222            "./src",
9223            "src/.",
9224            "../src",
9225            "src/..",
9226            "src\0lib.rs",
9227        ] {
9228            assert!(RepositoryPath::utf8(invalid.to_owned()).is_err(), "{invalid:?}");
9229        }
9230        assert!(RepositoryPath::utf8("x".repeat(MAX_REPOSITORY_PATH_BYTES + 1)).is_err());
9231        assert!(RepositoryPath::unix_bytes(vec![b'x'; MAX_REPOSITORY_PATH_BYTES + 1]).is_err());
9232
9233        for invalid in [
9234            r#"{"kind":"future","bytes":[115]}"#,
9235            r#"{"kind":"unix-bytes","bytes":[115],"extra":true}"#,
9236            r#"{"kind":"unix-bytes"}"#,
9237            r#"{"bytes":[115]}"#,
9238            r#"{"kind":"unix-bytes","bytes":[]}"#,
9239            r#"{"kind":"unix-bytes","bytes":[47,115]}"#,
9240            r#"{"kind":"unix-bytes","bytes":[115,47,46,46]}"#,
9241        ] {
9242            assert!(serde_json::from_str::<RepositoryPath>(invalid).is_err(), "{invalid}");
9243        }
9244    }
9245
9246    #[test]
9247    fn repository_path_fields_preserve_legacy_utf8_wire_bytes_and_default_rename_source() {
9248        let entry = WorkspaceEntry {
9249            relative_path: repository_path("src/lib.rs"),
9250            kind: WorkspaceEntryKind::File,
9251        };
9252        assert_eq!(
9253            serde_json::to_string(&entry).unwrap(),
9254            r#"{"relative_path":"src/lib.rs","kind":"file"}"#,
9255        );
9256
9257        let legacy_status =
9258            r#"{"index_status":"R","worktree_status":" ","path":"src/new.rs"}"#;
9259        let status = serde_json::from_str::<GitStatusEntry>(legacy_status).unwrap();
9260        assert_eq!(status.path, repository_path("src/new.rs"));
9261        assert_eq!(status.previous_path, None);
9262        assert_eq!(serde_json::to_string(&status).unwrap(), legacy_status);
9263
9264        let renamed = GitStatusEntry {
9265            previous_path: Some(repository_path("src/old.rs")),
9266            ..status
9267        };
9268        assert_eq!(
9269            serde_json::to_string(&renamed).unwrap(),
9270            r#"{"index_status":"R","worktree_status":" ","path":"src/new.rs","previous_path":"src/old.rs"}"#,
9271        );
9272    }
9273
9274    #[tokio::test]
9275    async fn json_frame_round_trips_a_client_hello_without_the_access_token() {
9276        let expected = ClientFrame::Hello(ClientHello::new(ClientRole::Operator, [7; NODE_AUTH_NONCE_BYTES]));
9277        let mut wire = Vec::new();
9278        write_json_frame(&mut wire, &expected).await.unwrap();
9279
9280        assert!(!String::from_utf8_lossy(&wire).contains("local-token"));
9281
9282        let actual: ClientFrame = read_json_frame(&mut wire.as_slice()).await.unwrap();
9283        assert_eq!(actual, expected);
9284    }
9285
9286    #[tokio::test]
9287    async fn reader_rejects_zero_length_before_allocating() {
9288        let bytes = 0_u32.to_le_bytes();
9289        let mut wire = bytes.as_slice();
9290        let error = read_json_frame::<_, ClientFrame>(&mut wire).await.unwrap_err();
9291        assert!(matches!(
9292            error,
9293            FrameError::InvalidLength { length: 0, max: MAX_NODE_FRAME_BYTES }
9294        ));
9295    }
9296
9297    #[tokio::test]
9298    async fn hello_reader_rejects_an_oversized_frame_before_allocating() {
9299        let declared = (MAX_NODE_HELLO_FRAME_BYTES + 1) as u32;
9300        let bytes = declared.to_le_bytes();
9301        let mut wire = bytes.as_slice();
9302        let error = read_json_frame_limited::<_, ClientFrame>(
9303            &mut wire,
9304            MAX_NODE_HELLO_FRAME_BYTES,
9305        )
9306        .await
9307        .unwrap_err();
9308        assert!(matches!(
9309            error,
9310            FrameError::InvalidLength {
9311                length,
9312                max: MAX_NODE_HELLO_FRAME_BYTES
9313            } if length == MAX_NODE_HELLO_FRAME_BYTES + 1
9314        ));
9315    }
9316
9317    #[tokio::test]
9318    async fn client_writer_enforces_the_smaller_request_limit() {
9319        let oversized = ClientFrame::Request(RequestEnvelope {
9320            request_id: 1,
9321            request: NodeRequest::Input {
9322                session: SessionAddress {
9323                    workspace_id: WorkspaceId::new("primary").unwrap(),
9324                    session: SessionKey {
9325                        instance_id: AgentInstanceId(1),
9326                        generation: SessionGeneration(1),
9327                    },
9328                },
9329                text: "x".repeat(MAX_NODE_CLIENT_FRAME_BYTES),
9330            },
9331        });
9332        let mut wire = Vec::new();
9333        let error = write_json_frame_limited(
9334            &mut wire,
9335            &oversized,
9336            MAX_NODE_CLIENT_FRAME_BYTES,
9337        )
9338        .await
9339        .unwrap_err();
9340        assert!(matches!(
9341            error,
9342            FrameError::InvalidLength {
9343                length,
9344                max: MAX_NODE_CLIENT_FRAME_BYTES
9345            } if length > MAX_NODE_CLIENT_FRAME_BYTES
9346        ));
9347        assert!(wire.is_empty());
9348    }
9349
9350    #[tokio::test]
9351    async fn maximum_node_text_fits_the_client_frame_under_worst_case_json_escaping() {
9352        let frame = ClientFrame::Request(RequestEnvelope {
9353            request_id: 2,
9354            request: NodeRequest::Paste {
9355                session: SessionAddress {
9356                    workspace_id: WorkspaceId::new("primary").unwrap(),
9357                    session: SessionKey {
9358                        instance_id: AgentInstanceId(1),
9359                        generation: SessionGeneration(1),
9360                    },
9361                },
9362                text: "\0".repeat(MAX_NODE_TEXT_BYTES),
9363            },
9364        });
9365        let mut wire = Vec::new();
9366        write_json_frame_limited(&mut wire, &frame, MAX_NODE_CLIENT_FRAME_BYTES)
9367            .await
9368            .unwrap();
9369        assert!(wire.len() <= MAX_NODE_CLIENT_FRAME_BYTES + std::mem::size_of::<u32>());
9370    }
9371
9372    #[tokio::test]
9373    async fn body_timeout_starts_after_the_bounded_length_prefix() {
9374        let (mut writer, mut reader) = tokio::io::duplex(64);
9375        writer.write_u32_le(32).await.unwrap();
9376        writer.write_all(b"{").await.unwrap();
9377        let error = read_json_frame_limited_body_timeout::<_, ClientFrame>(
9378            &mut reader,
9379            MAX_NODE_CLIENT_FRAME_BYTES,
9380            Duration::from_millis(10),
9381        )
9382        .await
9383        .unwrap_err();
9384        assert!(matches!(error, FrameError::BodyTimedOut { length: 32 }));
9385    }
9386
9387    #[tokio::test]
9388    async fn partial_length_prefix_cannot_pin_a_connection_slot() {
9389        let (mut writer, mut reader) = tokio::io::duplex(64);
9390        writer.write_all(&[32]).await.unwrap();
9391        let error = read_json_frame_limited_body_timeout::<_, ClientFrame>(
9392            &mut reader,
9393            MAX_NODE_CLIENT_FRAME_BYTES,
9394            Duration::from_millis(10),
9395        )
9396        .await
9397        .unwrap_err();
9398        assert!(matches!(error, FrameError::PrefixTimedOut));
9399    }
9400
9401    #[test]
9402    fn resume_wire_does_not_accept_a_replacement_working_directory() {
9403        let frame = ClientFrame::Request(RequestEnvelope {
9404            request_id: 9,
9405            request: NodeRequest::Resume {
9406                session: SessionAddress {
9407                    workspace_id: WorkspaceId::new("primary").unwrap(),
9408                    session: SessionKey {
9409                        instance_id: AgentInstanceId(3),
9410                        generation: SessionGeneration(2),
9411                    },
9412                },
9413                terminal_size: TerminalSize { rows: 24, columns: 80 },
9414                initial_prompt: Some("continue".to_owned()),
9415            },
9416        });
9417        let json = serde_json::to_string(&frame).unwrap();
9418        assert!(!json.contains("working_directory"));
9419
9420        let mut malicious = serde_json::to_value(match frame {
9421            ClientFrame::Request(envelope) => envelope.request,
9422            _ => unreachable!("constructed request frame"),
9423        })
9424        .unwrap();
9425        malicious
9426            .as_object_mut()
9427            .unwrap()
9428            .insert(
9429                "working_directory".to_owned(),
9430                serde_json::Value::String(r"C:\attacker-selected-root".to_owned()),
9431            );
9432        let error = serde_json::from_value::<NodeRequest>(malicious).unwrap_err();
9433        assert!(error.to_string().contains("unknown field `working_directory`"));
9434    }
9435
9436    #[test]
9437    fn history_context_pack_wire_is_bounded_path_free_and_auth_bound() {
9438        assert_eq!(NODE_HISTORY_CONTEXT_PACK_CAPABILITY, "history-context-pack-v1");
9439        let capability = CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
9440        assert!(production_node_client_compatibility_offer()
9441            .capabilities
9442            .contains(&capability));
9443        let mut support = portable_node_support();
9444        support.capabilities = vec![capability.clone()];
9445        let offer = ClientCompatibilityOffer {
9446            build_stamp: BUILD_STAMP.to_owned(),
9447            capabilities: vec![capability.clone()],
9448            state_schema: None,
9449        };
9450        let selected = support.negotiate(&offer).unwrap();
9451        assert_eq!(selected.capabilities, vec![capability]);
9452        let binding = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
9453        assert!(binding
9454            .windows(NODE_HISTORY_CONTEXT_PACK_CAPABILITY.len())
9455            .any(|window| window == NODE_HISTORY_CONTEXT_PACK_CAPABILITY.as_bytes()));
9456
9457        let request = NodeRequest::DiscoverHistory {
9458            session: session_address("primary", 3),
9459            limit: HISTORY_DISCOVERY_LIMIT_MAX,
9460        };
9461        assert_eq!(
9462            request.required_capability(),
9463            Some(NODE_HISTORY_CONTEXT_PACK_CAPABILITY),
9464        );
9465        assert!(request.history_context_pack_contract_is_valid());
9466        let json = serde_json::to_string(&request).unwrap();
9467        assert!(!json.contains("working_directory"));
9468        assert!(!json.contains("path"));
9469        assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
9470
9471        let mut invalid = serde_json::to_value(&request).unwrap();
9472        invalid["limit"] = serde_json::Value::from(0);
9473        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9474        assert!(!NodeRequest::DiscoverHistory {
9475            session: session_address("primary", 3),
9476            limit: 0,
9477        }
9478        .history_context_pack_contract_is_valid());
9479        assert!(!NodeRequest::LoadHistory {
9480            session: session_address("primary", 3),
9481            candidate_id: String::new(),
9482        }
9483        .history_context_pack_contract_is_valid());
9484        let mut invalid = serde_json::to_value(&request).unwrap();
9485        invalid["limit"] = serde_json::Value::from(u64::from(HISTORY_DISCOVERY_LIMIT_MAX) + 1);
9486        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9487        let mut invalid = serde_json::to_value(&request).unwrap();
9488        invalid["working_directory"] =
9489            serde_json::Value::String(r"C:\attacker-selected-root".to_owned());
9490        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9491
9492        let response = NodeResponse::HistoryDiscovered {
9493            session: session_address("primary", 3),
9494            candidates: vec![HistoryCandidateSummary {
9495                id: "candidate-1".to_owned(),
9496                session_id_hint: "session-1".to_owned(),
9497                modified_at_unix_ms: Some(1),
9498            }],
9499        };
9500        assert!(response.requires_history_context_pack_capability());
9501        let response_json = serde_json::to_string(&response).unwrap();
9502        assert!(!response_json.contains("messages"));
9503        assert!(!response_json.contains("working_directory"));
9504        assert!(!response_json.contains("path"));
9505        assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
9506    }
9507
9508    #[test]
9509    fn session_record_context_export_wire_is_exact_private_and_capability_bound() {
9510        assert_eq!(
9511            NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
9512            "session-record-context-export-v1",
9513        );
9514        let capability =
9515            CapabilityId::new(NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).unwrap();
9516        assert!(production_node_client_compatibility_offer()
9517            .capabilities
9518            .contains(&capability));
9519        let session = session_address("primary", 19);
9520        let record_id = SessionRecordId::new("record-19").unwrap();
9521        let request = NodeRequest::ExportContextPackForSessionRecord {
9522            record_id: record_id.clone(),
9523            session: session.clone(),
9524        };
9525        assert_eq!(
9526            request.required_capability(),
9527            Some(NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY),
9528        );
9529        assert!(request.requires_history_context_pack_capability());
9530        let request_json = serde_json::to_string(&request).unwrap();
9531        assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
9532        for private in [
9533            "candidate_id",
9534            "session_id_hint",
9535            "provider_session",
9536            "working_directory",
9537            "path",
9538            "model",
9539            "messages",
9540        ] {
9541            assert!(!request_json.contains(private), "request leaked {private}");
9542        }
9543
9544        let response = NodeResponse::ContextPackForSessionRecordExported {
9545            record_id,
9546            session: session.clone(),
9547            context: context_receipt("context-record-19", session),
9548        };
9549        assert!(response.requires_history_context_pack_capability());
9550        assert!(response.requires_session_record_context_export_capability());
9551        let response_json = serde_json::to_string(&response).unwrap();
9552        assert_eq!(
9553            serde_json::from_str::<NodeResponse>(&response_json).unwrap(),
9554            response,
9555        );
9556        for private in [
9557            "candidate_id",
9558            "session_id_hint",
9559            "provider_session",
9560            "working_directory",
9561            "path",
9562            "model",
9563            "messages",
9564        ] {
9565            assert!(!response_json.contains(private), "response leaked {private}");
9566        }
9567    }
9568
9569    #[test]
9570    fn native_session_catalog_wire_is_bounded_metadata_only() {
9571        assert_eq!(NODE_NATIVE_SESSION_CATALOG_CAPABILITY, "native-session-catalog-v2");
9572        let route = NativeSessionCatalogRoute::workspace(
9573            WorkspaceId::new("primary").unwrap(),
9574            agent("codex"),
9575        );
9576        let request = NodeRequest::CatalogNativeSessions {
9577            route: route.clone(),
9578            limit: NATIVE_SESSION_CATALOG_LIMIT_MAX,
9579        };
9580        assert_eq!(
9581            request.required_capability(),
9582            Some(NODE_NATIVE_SESSION_CATALOG_CAPABILITY)
9583        );
9584        assert!(request.native_session_catalog_contract_is_valid());
9585        let mut invalid = serde_json::to_value(&request).unwrap();
9586        invalid["limit"] = serde_json::Value::from(65);
9587        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9588
9589        let response = NodeResponse::NativeSessionsCataloged {
9590            route: route.clone(),
9591            entries: vec![NativeSessionCatalogEntry {
9592                selection_id: "hist_selection_1".to_owned(),
9593                title: Some("Review".to_owned()),
9594                modified_at_unix_ms: Some(9),
9595                model: Some("model-1".to_owned()),
9596                message_count: 4,
9597                completed_turn_count: Some(2),
9598                external_group: None,
9599                record_id: Some(SessionRecordId::new("record-1").unwrap()),
9600            }],
9601            summary: Some(NativeSessionCatalogSummary {
9602                catalog_revision: 7,
9603                recent_cutoff_unix_ms: 8,
9604                recent_total_count: 1,
9605                older_total_count: 2,
9606                recent_next_after_selection_id: None,
9607                recent_has_more: false,
9608            }),
9609        };
9610        assert!(response.requires_native_session_catalog_capability());
9611        assert!(response.native_session_catalog_contract_is_valid());
9612        let json = serde_json::to_string(&response).unwrap();
9613        for forbidden in ["session_id", "stable-session", "cwd", "candidate", "path", "messages", "tokens", "documents", "raw"] {
9614            assert!(!json.contains(forbidden));
9615        }
9616        assert_eq!(serde_json::from_str::<NodeResponse>(&json).unwrap(), response);
9617        assert!(serde_json::from_str::<NodeResponse>(
9618            r#"{"kind":"native-sessions-cataloged","workspace_id":"primary","provider":"codex","entries":[]}"#,
9619        )
9620        .is_err());
9621        let mut injected = serde_json::to_value(&response).unwrap();
9622        injected["entries"][0]["title"] = serde_json::Value::String("safe\nunsafe".to_owned());
9623        assert!(serde_json::from_value::<NodeResponse>(injected).is_err());
9624
9625        let external = NodeResponse::NativeSessionsCataloged {
9626            route: NativeSessionCatalogRoute::unregistered(agent("codex")),
9627            entries: vec![NativeSessionCatalogEntry {
9628                selection_id: "external_selection_1".to_owned(),
9629                title: None,
9630                modified_at_unix_ms: Some(9),
9631                model: None,
9632                message_count: 0,
9633                completed_turn_count: None,
9634                external_group: Some(NativeSessionExternalGroup {
9635                    group_id: "external-0001".to_owned(),
9636                    kind: gate4agent_types::NativeSessionExternalGroupKind::Project,
9637                    display_name: "shared".to_owned(),
9638                }),
9639                record_id: None,
9640            }],
9641            summary: Some(NativeSessionCatalogSummary {
9642                catalog_revision: 9,
9643                recent_cutoff_unix_ms: 8,
9644                recent_total_count: 1,
9645                older_total_count: 0,
9646                recent_next_after_selection_id: None,
9647                recent_has_more: false,
9648            }),
9649        };
9650        assert!(external.native_session_catalog_contract_is_valid());
9651        let external_json = serde_json::to_string(&external).unwrap();
9652        assert!(!external_json.contains("project-"));
9653        for hostile in [r"C:\private", "/srv/private", "..", "nested/path"] {
9654            let mut injected = serde_json::to_value(&external).unwrap();
9655            injected["entries"][0]["external_group"]["display_name"] =
9656                serde_json::Value::String(hostile.to_owned());
9657            assert!(serde_json::from_value::<NodeResponse>(injected).is_err());
9658        }
9659
9660        let page_request = NodeRequest::PageNativeSessions {
9661            route: route.clone(),
9662            window: NativeSessionCatalogWindow::Older,
9663            catalog_revision: 7,
9664            recent_cutoff_unix_ms: 8,
9665            after_selection_id: Some("hist_selection_1".to_owned()),
9666            limit: 1,
9667        };
9668        assert_eq!(
9669            page_request.required_capability(),
9670            Some(NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY),
9671        );
9672        let paged = NodeResponse::NativeSessionsPaged {
9673            route,
9674            page: NativeSessionCatalogPage {
9675                window: NativeSessionCatalogWindow::Older,
9676                revision: 7,
9677                entries: Vec::new(),
9678                next_after_selection_id: None,
9679                remaining_count: 0,
9680                has_more: false,
9681            },
9682        };
9683        assert!(paged.requires_native_session_catalog_paging_capability());
9684        assert!(paged.native_session_catalog_contract_is_valid());
9685    }
9686
9687    #[test]
9688    fn native_session_preview_wire_is_bounded_and_record_projection_is_redacted() {
9689        assert_eq!(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY, "native-session-preview-v2");
9690        let selection = NativeSessionSelection {
9691            route: NativeSessionCatalogRoute::workspace(
9692                WorkspaceId::new("primary").unwrap(),
9693                agent("claude"),
9694            ),
9695            catalog_revision: 7,
9696            recent_cutoff_unix_ms: 8,
9697            selection_id: "hist_selection_1".to_owned(),
9698        };
9699        let request = NodeRequest::PreviewNativeSession {
9700            selection: selection.clone(),
9701            message_limit: NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX,
9702        };
9703        assert_eq!(
9704            request.required_capability(),
9705            Some(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY)
9706        );
9707        assert!(request.native_session_preview_contract_is_valid());
9708        let mut invalid = serde_json::to_value(&request).unwrap();
9709        invalid["message_limit"] = serde_json::Value::from(25);
9710        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9711
9712        let preview = NativeSessionPreview {
9713            title: Some("Review".to_owned()),
9714            modified_at_unix_ms: Some(9),
9715            model: Some("model-1".to_owned()),
9716            message_count: 8,
9717            message_count_exact: true,
9718            completed_turn_count: Some(4),
9719            total_tokens: None,
9720            truncated: true,
9721            messages: vec![gate4agent_types::NativeSessionPreviewMessage {
9722                role: gate4agent_types::HistoryMessageRole::Assistant,
9723                text: "visible answer".to_owned(),
9724            }],
9725        };
9726        let response = NodeResponse::NativeSessionPreviewed {
9727            selection,
9728            preview,
9729        };
9730        assert!(response.requires_native_session_preview_capability());
9731        let json = serde_json::to_string(&response).unwrap();
9732        for forbidden in ["native-secret-id", "cwd", "path", "tokens", "tool_result", "thinking"] {
9733            assert!(!json.contains(forbidden));
9734        }
9735        assert_eq!(serde_json::from_str::<NodeResponse>(&json).unwrap(), response);
9736    }
9737
9738    #[test]
9739    fn context_receipts_are_nonempty_and_strictly_correlated() {
9740        assert!(SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).is_ok());
9741        assert!(SpawnContextDigest::new(format!("sha256:{}", "A".repeat(64))).is_err());
9742        let context = context_receipt("context-1", session_address("primary", 7));
9743        let record = ManagedSessionRecord {
9744            record_id: SessionRecordId::new("session-1").unwrap(),
9745            display_name: "review".to_owned(),
9746            provider: agent("claude"),
9747            mode: SessionMode::Pty,
9748            state: ManagedSessionState::Dormant,
9749            workspace_id: WorkspaceId::new("primary").unwrap(),
9750            canonical_root: host_path(r"C:\repo"),
9751            provider_session: None,
9752            active_session: None,
9753            environment_profile: None,
9754            bundle: None,
9755            context_id: Some(context.id.clone()),
9756            context: Some(context.clone()),
9757            exported_context: None,
9758            task_binding: None,
9759            created_at_unix_ms: 1,
9760            updated_at_unix_ms: 2,
9761            last_error: None,
9762        };
9763        assert!(record.context_binding_is_valid());
9764        let json = serde_json::to_value(&record).unwrap();
9765        assert_eq!(serde_json::from_value::<ManagedSessionRecord>(json.clone()).unwrap(), record);
9766
9767        let mut invalid_task_revision = json.clone();
9768        invalid_task_revision["task_binding"] = serde_json::json!({
9769            "revision": 0,
9770            "task_id": "task-00112233445566778899aaff",
9771            "changed_at_unix_ms": 1,
9772        });
9773        assert!(serde_json::from_value::<ManagedSessionRecord>(invalid_task_revision).is_err());
9774        let mut invalid_task_timestamp = json.clone();
9775        invalid_task_timestamp["task_binding"] = serde_json::json!({
9776            "revision": 1,
9777            "task_id": "task-00112233445566778899aaff",
9778            "changed_at_unix_ms": 3,
9779        });
9780        assert!(serde_json::from_value::<ManagedSessionRecord>(invalid_task_timestamp).is_err());
9781
9782        let mut metadata_without_id = json.clone();
9783        metadata_without_id["context_id"] = serde_json::Value::Null;
9784        assert!(serde_json::from_value::<ManagedSessionRecord>(metadata_without_id).is_err());
9785        let mut id_without_metadata = json.clone();
9786        id_without_metadata["context"] = serde_json::Value::Null;
9787        assert!(serde_json::from_value::<ManagedSessionRecord>(id_without_metadata).is_err());
9788        let mut mismatched = json.clone();
9789        mismatched["context_id"] = serde_json::Value::String("context-2".to_owned());
9790        assert!(serde_json::from_value::<ManagedSessionRecord>(mismatched).is_err());
9791        let mut missing_truncation = json.clone();
9792        missing_truncation["context"]["truncated"] = serde_json::Value::Bool(false);
9793        assert!(serde_json::from_value::<ManagedSessionRecord>(missing_truncation).is_err());
9794        let mut false_truncation = json.clone();
9795        false_truncation["context"]["retained_message_count"] =
9796            false_truncation["context"]["source_message_count"].clone();
9797        assert!(serde_json::from_value::<ManagedSessionRecord>(false_truncation).is_err());
9798        let mut empty = json;
9799        empty["context"]["retained_message_count"] = serde_json::Value::from(0);
9800        assert!(serde_json::from_value::<ManagedSessionRecord>(empty).is_err());
9801
9802        let mut with_exported_context = record.clone();
9803        with_exported_context.exported_context = Some(context.clone());
9804        assert!(with_exported_context.exported_context_is_valid());
9805        let exported_json = serde_json::to_value(&with_exported_context).unwrap();
9806        assert_eq!(
9807            serde_json::from_value::<ManagedSessionRecord>(exported_json.clone()).unwrap(),
9808            with_exported_context,
9809        );
9810        let mut mismatched_provider = exported_json;
9811        mismatched_provider["exported_context"]["lineage"]["source_provider"] =
9812            serde_json::Value::String("codex".to_owned());
9813        assert!(serde_json::from_value::<ManagedSessionRecord>(mismatched_provider).is_err());
9814
9815        let receipt = ResolvedSpawnReceipt {
9816            incarnation_id: NodeIncarnationId::from_bytes([9; NODE_INCARNATION_ID_BYTES]),
9817            session: session_address("primary", 8),
9818            target: SpawnTarget {
9819                node_id: NodeId::new("node-a").unwrap(),
9820                workspace_id: WorkspaceId::new("primary").unwrap(),
9821                worktree_id: None,
9822            },
9823            profile_id: SpawnProfileId::new("default").unwrap(),
9824            profile_revision: SpawnProfileRevision::new("default.r1").unwrap(),
9825            provider: agent("claude"),
9826            mode: SessionMode::Pty,
9827            terminal_size: TerminalSize { rows: 24, columns: 80 },
9828            prompt: SpawnPromptMetadata { present: false, byte_len: 0 },
9829            bundle_id: None,
9830            bundle: None,
9831            context_id: Some(context.id.clone()),
9832            context: Some(context),
9833            environment_profile: None,
9834            deadline_ms: SpawnDeadlineMs::new(5_000).unwrap(),
9835            idempotency_key: SpawnIdempotencyKey::new("spawn-1").unwrap(),
9836            required_capabilities: SpawnRequiredCapabilities::default(),
9837            provenance: SpawnResolutionProvenance {
9838                provider: SpawnFieldProvenance::Profile,
9839                mode: SpawnFieldProvenance::Profile,
9840                terminal_size: SpawnFieldProvenance::Profile,
9841                prompt: SpawnFieldProvenance::Profile,
9842                bundle_id: SpawnFieldProvenance::Profile,
9843                context_id: SpawnFieldProvenance::Profile,
9844                environment_profile_id: SpawnFieldProvenance::Profile,
9845            },
9846            harness_mcp_proxy: None,
9847        };
9848        assert!(receipt.context_binding_is_valid());
9849        let mut mismatched = serde_json::to_value(&receipt).unwrap();
9850        mismatched["context_id"] = serde_json::Value::String("context-2".to_owned());
9851        assert!(serde_json::from_value::<ResolvedSpawnReceipt>(mismatched).is_err());
9852    }
9853
9854    #[test]
9855    fn protocol_v9_workspace_and_worktree_mutations_have_exact_bounded_wire_shapes() {
9856        assert_eq!(MAX_WORKSPACE_ROOT_BYTES, gate4agent_types::WORKING_DIRECTORY_MAX_BYTES);
9857
9858        let register = NodeRequest::RegisterWorkspace {
9859            workspace_id: WorkspaceId::new("repo-2").unwrap(),
9860            root: host_path(r"C:\repo-2"),
9861        };
9862        let register_json = serde_json::to_string(&register).unwrap();
9863        assert_eq!(
9864            register_json,
9865            r#"{"kind":"register-workspace","workspace_id":"repo-2","root":"C:\\repo-2"}"#,
9866        );
9867        assert_eq!(serde_json::from_str::<NodeRequest>(&register_json).unwrap(), register);
9868
9869        let standalone = NodeRequest::CreateStandaloneWorkspace {
9870            workspace_id: WorkspaceId::new("independent").unwrap(),
9871            root: host_path(r"C:\independent"),
9872            initial_branch: Some("main".to_owned()),
9873        };
9874        let standalone_json = serde_json::to_string(&standalone).unwrap();
9875        assert_eq!(
9876            standalone_json,
9877            r#"{"kind":"create-standalone-workspace","workspace_id":"independent","root":"C:\\independent","initial_branch":"main"}"#,
9878        );
9879        assert_eq!(
9880            serde_json::from_str::<NodeRequest>(&standalone_json).unwrap(),
9881            standalone,
9882        );
9883        assert_eq!(
9884            standalone.required_capability(),
9885            Some(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY),
9886        );
9887        let oversized_branch = format!(
9888            r#"{{"kind":"create-standalone-workspace","workspace_id":"independent","root":"C:\\independent","initial_branch":"{}"}}"#,
9889            "x".repeat(MAX_REPOSITORY_PATH_BYTES + 1),
9890        );
9891        assert!(serde_json::from_str::<NodeRequest>(&oversized_branch).is_err());
9892
9893        let unregister = NodeRequest::UnregisterWorkspace {
9894            workspace_id: WorkspaceId::new("repo-2").unwrap(),
9895        };
9896        let unregister_json = serde_json::to_string(&unregister).unwrap();
9897        assert_eq!(
9898            unregister_json,
9899            r#"{"kind":"unregister-workspace","workspace_id":"repo-2"}"#,
9900        );
9901        assert_eq!(serde_json::from_str::<NodeRequest>(&unregister_json).unwrap(), unregister);
9902
9903        let create = NodeRequest::CreateWorktree {
9904            source_workspace_id: WorkspaceId::new("primary").unwrap(),
9905            workspace_id: WorkspaceId::new("topic-one").unwrap(),
9906            target_root: host_path(r"C:\trees\topic-one"),
9907            branch: "codex/topic-one".to_owned(),
9908            base: Some("main".to_owned()),
9909        };
9910        let create_json = serde_json::to_string(&create).unwrap();
9911        assert_eq!(
9912            create_json,
9913            r#"{"kind":"create-worktree","source_workspace_id":"primary","workspace_id":"topic-one","target_root":"C:\\trees\\topic-one","branch":"codex/topic-one","base":"main"}"#,
9914        );
9915        assert_eq!(serde_json::from_str::<NodeRequest>(&create_json).unwrap(), create);
9916
9917        let remove = NodeRequest::RemoveWorktree {
9918            source_workspace_id: WorkspaceId::new("primary").unwrap(),
9919            target_root: host_path(r"C:\trees\topic-one"),
9920        };
9921        let remove_json = serde_json::to_string(&remove).unwrap();
9922        assert_eq!(
9923            remove_json,
9924            r#"{"kind":"remove-worktree","source_workspace_id":"primary","target_root":"C:\\trees\\topic-one"}"#,
9925        );
9926        assert_eq!(serde_json::from_str::<NodeRequest>(&remove_json).unwrap(), remove);
9927    }
9928
9929    #[test]
9930    fn incarnation_id_and_cursor_have_exact_lowercase_hex_wire_shapes() {
9931        let incarnation_id = NodeIncarnationId::from_bytes([
9932            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
9933            0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
9934        ]);
9935        assert_eq!(
9936            incarnation_id.to_string(),
9937            "00112233445566778899aabbccddeeff",
9938        );
9939        let json = serde_json::to_string(&incarnation_id).unwrap();
9940        assert_eq!(json, r#""00112233445566778899aabbccddeeff""#);
9941        assert_eq!(
9942            serde_json::from_str::<NodeIncarnationId>(&json).unwrap(),
9943            incarnation_id,
9944        );
9945        assert!("00112233445566778899AABBCCDDEEFF"
9946            .parse::<NodeIncarnationId>()
9947            .is_err());
9948        assert!("00112233445566778899aabbccddeef"
9949            .parse::<NodeIncarnationId>()
9950            .is_err());
9951        assert!("00112233445566778899aabbccddeefg"
9952            .parse::<NodeIncarnationId>()
9953            .is_err());
9954
9955        let cursor = NodeCursor {
9956            incarnation_id,
9957            sequence: 17,
9958        };
9959        let cursor_json = serde_json::to_string(&cursor).unwrap();
9960        assert_eq!(
9961            cursor_json,
9962            r#"{"incarnation_id":"00112233445566778899aabbccddeeff","sequence":17}"#,
9963        );
9964        assert_eq!(serde_json::from_str::<NodeCursor>(&cursor_json).unwrap(), cursor);
9965    }
9966
9967    #[test]
9968    fn node_hello_carries_the_incarnation_sequence_domain_with_the_build_stamp() {
9969        let hello = NodeHello {
9970            build_stamp: BUILD_STAMP.to_owned(),
9971            incarnation_id: NodeIncarnationId::from_bytes([0; NODE_INCARNATION_ID_BYTES]),
9972            connection_id: 42,
9973            role: ClientRole::Observer,
9974            event_sequence: 9,
9975            controller: None,
9976            snapshot: NodeSnapshot {
9977                node_id: NodeId::new("fixture-node").unwrap(),
9978                enabled_providers: Vec::new(),
9979                provider_runtime_statuses: ProviderRuntimeStatuses::default(),
9980                workspaces: Vec::new(),
9981                session_records: Vec::new(),
9982                managed_worktrees: Vec::new(),
9983                launch_inventory: None,
9984                agent_progress: Vec::new(),
9985            },
9986            compatibility: None,
9987        };
9988        let json = serde_json::to_string(&hello).unwrap();
9989        assert_eq!(
9990            json,
9991            format!(
9992                r#"{{"build_stamp":"{BUILD_STAMP}","incarnation_id":"00000000000000000000000000000000","connection_id":42,"role":"observer","event_sequence":9,"controller":null,"snapshot":{{"node_id":"fixture-node","enabled_providers":[],"workspaces":[],"session_records":[]}}}}"#,
9993            ),
9994        );
9995        assert_eq!(serde_json::from_str::<NodeHello>(&json).unwrap(), hello);
9996    }
9997
9998    #[test]
9999    fn agent_progress_v1_rejects_oversize_and_controls() {
10000        let address = serde_json::json!({
10001            "workspace_id": "primary",
10002            "session": { "instance_id": 7, "generation": 3 }
10003        });
10004        let valid_progress = serde_json::json!({
10005            "provider_sequence": 11,
10006            "activity": "working",
10007            "completed_turns": 2,
10008            "usage": {
10009                "input_tokens": 10,
10010                "output_tokens": 20,
10011                "cache_read_tokens": 30,
10012                "cache_write_tokens": 40,
10013                "reasoning_tokens": 50
10014            },
10015            "current": "working",
10016            "active_tool_labels": ["Read"],
10017            "active_tool_count": 1,
10018            "attention": null,
10019            "subagent_count": 0,
10020            "last_event_kind": "tool-started",
10021            "gap_count": 0,
10022            "stale": false,
10023            "truncated": false
10024        });
10025        let mut controlled = valid_progress.clone();
10026        controlled["active_tool_labels"] = serde_json::json!(["unsafe\u{0000}tool"]);
10027        assert!(serde_json::from_value::<AgentProgressV1>(controlled.clone()).is_err());
10028
10029        let mut path_like = valid_progress.clone();
10030        path_like["active_tool_labels"] = serde_json::json!([r"Read C:\private\secret"]);
10031        assert!(serde_json::from_value::<AgentProgressV1>(path_like).is_err());
10032
10033        let mut oversized_label = valid_progress.clone();
10034        oversized_label["active_tool_labels"] =
10035            serde_json::json!(["x".repeat(MAX_AGENT_PROGRESS_TOOL_LABEL_BYTES + 1)]);
10036        assert!(serde_json::from_value::<AgentProgressV1>(oversized_label.clone()).is_err());
10037
10038        let oversized_entry = serde_json::json!({
10039            "address": address.clone(),
10040            "progress": valid_progress,
10041            "padding": "x".repeat(MAX_AGENT_PROGRESS_ENTRY_BYTES)
10042        });
10043        assert!(serde_json::to_vec(&oversized_entry).unwrap().len()
10044            > MAX_AGENT_PROGRESS_ENTRY_BYTES);
10045        let snapshot = serde_json::json!({
10046            "node_id": "fixture-node",
10047            "enabled_providers": [],
10048            "workspaces": [],
10049            "session_records": [],
10050            "agent_progress": [
10051                { "address": address.clone(), "progress": controlled },
10052                { "address": address, "progress": oversized_label },
10053                oversized_entry
10054            ]
10055        });
10056        let decoded = serde_json::from_value::<NodeSnapshot>(snapshot).unwrap();
10057        assert!(decoded.agent_progress.is_empty());
10058    }
10059
10060    #[test]
10061    fn launch_inventory_preserves_legacy_absence_and_authoritative_empty() {
10062        let legacy = serde_json::from_str::<NodeSnapshot>(
10063            r#"{"node_id":"fixture-node","enabled_providers":[],"workspaces":[],"session_records":[]}"#,
10064        )
10065        .unwrap();
10066        assert!(legacy.launch_inventory.is_none());
10067        assert!(serde_json::from_str::<LaunchInventory>(r#"{}"#).is_err());
10068
10069        let current = LaunchInventory {
10070            spawn_profiles: Some(Vec::new()),
10071            bundles: Some(Vec::new()),
10072            network_allowlists: None,
10073        };
10074        assert_eq!(
10075            serde_json::to_string(&current).unwrap(),
10076            r#"{"spawn_profiles":[],"bundles":[]}"#,
10077        );
10078        assert_eq!(
10079            serde_json::from_str::<LaunchInventory>(
10080                r#"{"spawn_profiles":[],"bundles":[]}"#,
10081            )
10082            .unwrap(),
10083            current,
10084        );
10085
10086        let legacy_workspace = serde_json::from_str::<WorkspaceSnapshot>(
10087            r#"{"workspace_id":"repo","canonical_root":"fixture-root","sessions":[]}"#,
10088        )
10089        .unwrap();
10090        assert!(legacy_workspace.managed_worktree_profiles.is_none());
10091        let current_workspace = WorkspaceSnapshot {
10092            managed_worktree_profiles: Some(WorktreeProfileInventory {
10093                profiles: Vec::new(),
10094            }),
10095            ..legacy_workspace
10096        };
10097        assert!(serde_json::to_string(&current_workspace)
10098            .unwrap()
10099            .contains(r#""managed_worktree_profiles":[]"#));
10100    }
10101
10102    #[test]
10103    fn spawn_profile_environment_authority_is_optional_safe_and_capability_bound() {
10104        let environment_profile = ResolvedEnvironmentProfileReceipt {
10105            profile_id: SpawnEnvironmentProfileId::new("local-claude").unwrap(),
10106            profile_revision: SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap(),
10107            network_allowlist: None,
10108            browser_profile_id: None,
10109        };
10110        let summary = SpawnProfileSummary {
10111            id: SpawnProfileId::new("default").unwrap(),
10112            revision: SpawnProfileRevision::new("v1").unwrap(),
10113            environment_profile: Some(environment_profile.clone()),
10114        };
10115        let encoded = serde_json::to_string(&summary).unwrap();
10116        assert_eq!(
10117            encoded,
10118            r#"{"id":"default","revision":"v1","environment_profile":{"profile_id":"local-claude","profile_revision":"local-claude-r1"}}"#,
10119        );
10120        assert!(!encoded.contains("provider"));
10121        assert!(!encoded.contains("mode"));
10122        let legacy = serde_json::from_str::<SpawnProfileSummary>(
10123            r#"{"id":"default","revision":"v1"}"#,
10124        )
10125        .unwrap();
10126        assert!(legacy.environment_profile.is_none());
10127
10128        let snapshot = serde_json::from_value::<NodeSnapshot>(serde_json::json!({
10129            "node_id": "fixture-node",
10130            "enabled_providers": [],
10131            "workspaces": [],
10132            "launch_inventory": {
10133                "spawn_profiles": [summary],
10134            },
10135        }))
10136        .unwrap();
10137        assert!(snapshot.requires_child_environment_profile_capability());
10138        assert_eq!(
10139            snapshot.launch_inventory.unwrap().spawn_profiles.unwrap()[0]
10140                .environment_profile,
10141            Some(environment_profile),
10142        );
10143    }
10144
10145    #[test]
10146    fn launch_inventory_rejects_duplicate_and_overflow_identities() {
10147        let duplicate_profiles = r#"{"spawn_profiles":[{"id":"default","revision":"v1"},{"id":"default","revision":"v2"}]}"#;
10148        assert!(serde_json::from_str::<LaunchInventory>(duplicate_profiles).is_err());
10149
10150        let digest = format!("sha256:{}", "0".repeat(64));
10151        let duplicate_bundles = serde_json::json!({
10152            "bundles": [
10153                { "id": "review", "revision": "v1", "digest": digest },
10154                { "id": "review", "revision": "v2", "digest": format!("sha256:{}", "1".repeat(64)) },
10155            ],
10156        });
10157        assert!(serde_json::from_value::<LaunchInventory>(duplicate_bundles).is_err());
10158
10159        let duplicate_worktrees = r#"[{"id":"default","revision":"v1","retention":"retain"},{"id":"default","revision":"v2","retention":"remove-when-released"}]"#;
10160        assert!(serde_json::from_str::<WorktreeProfileInventory>(duplicate_worktrees).is_err());
10161
10162        let profiles = (0..=MAX_SPAWN_PROFILES)
10163            .map(|index| serde_json::json!({
10164                "id": format!("profile-{index}"),
10165                "revision": "v1",
10166            }))
10167            .collect::<Vec<_>>();
10168        let overflow = serde_json::json!({ "spawn_profiles": profiles });
10169        assert!(serde_json::from_value::<LaunchInventory>(overflow).is_err());
10170
10171        let bundles = (0..=MAX_LAUNCH_BUNDLES)
10172            .map(|index| serde_json::json!({
10173                "id": format!("bundle-{index}"),
10174                "revision": "v1",
10175                "digest": format!("sha256:{}", "2".repeat(64)),
10176            }))
10177            .collect::<Vec<_>>();
10178        assert!(serde_json::from_value::<LaunchInventory>(
10179            serde_json::json!({ "bundles": bundles }),
10180        )
10181        .is_err());
10182
10183        let worktrees = (0..=MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE)
10184            .map(|index| serde_json::json!({
10185                "id": format!("profile-{index}"),
10186                "revision": "v1",
10187                "retention": "retain",
10188            }))
10189            .collect::<Vec<_>>();
10190        assert!(serde_json::from_value::<WorktreeProfileInventory>(
10191            serde_json::Value::Array(worktrees),
10192        )
10193        .is_err());
10194    }
10195
10196    #[test]
10197    fn managed_worktree_contract_is_bounded_dual_gated_and_path_free() {
10198        assert_eq!(
10199            NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
10200            "managed-worktree-lifecycle-v1",
10201        );
10202        assert_eq!(NODE_STATE_SCHEMA_V4, 4);
10203        assert_eq!(NODE_STATE_SCHEMA_V5, 5);
10204        assert_eq!(NODE_STATE_SCHEMA_V6, 6);
10205        assert_eq!(NODE_STATE_SCHEMA_V10, 10);
10206        assert!(WorktreeProfileId::new("p".repeat(MAX_WORKTREE_PROFILE_ID_BYTES)).is_ok());
10207        assert!(WorktreeProfileId::new("p".repeat(MAX_WORKTREE_PROFILE_ID_BYTES + 1)).is_err());
10208        assert!(WorktreeProfileRevision::new(
10209            "r".repeat(MAX_WORKTREE_PROFILE_REVISION_BYTES),
10210        )
10211        .is_ok());
10212        assert!(ManagedWorktreeLeaseId::new(
10213            "l".repeat(MAX_MANAGED_WORKTREE_LEASE_ID_BYTES + 1),
10214        )
10215        .is_err());
10216
10217        let request = NodeRequest::SpawnManagedWorktree {
10218            request: ManagedWorktreeSpawnRequest {
10219                spawn_spec: SpawnSpec {
10220                    target: SpawnTarget {
10221                        node_id: NodeId::new("node-a").unwrap(),
10222                        workspace_id: WorkspaceId::new("primary").unwrap(),
10223                        worktree_id: None,
10224                    },
10225                    profile_id: SpawnProfileId::new("default").unwrap(),
10226                    expected_profile_revision: SpawnProfileRevision::new("default.r1").unwrap(),
10227                    overrides: SpawnOverrides::default(),
10228                    deadline_ms: SpawnDeadlineMs::new(30_000).unwrap(),
10229                    idempotency_key: SpawnIdempotencyKey::new("managed-1").unwrap(),
10230                    required_capabilities: SpawnRequiredCapabilities::default(),
10231                },
10232                worktree_profile_id: WorktreeProfileId::new("review").unwrap(),
10233            },
10234        };
10235        assert_eq!(
10236            request.required_capability(),
10237            Some(NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY),
10238        );
10239        assert!(request.requires_worktree_selection_capability());
10240        let json = serde_json::to_string(&request).unwrap();
10241        for forbidden in ["canonical", "target_root", "gitdir", "branch", "base_commit", "diagnostic"] {
10242            assert!(!json.contains(forbidden), "managed request leaked {forbidden}");
10243        }
10244
10245        let legacy = match request {
10246            NodeRequest::SpawnManagedWorktree { request } => request,
10247            _ => unreachable!(),
10248        };
10249        let legacy_json = serde_json::to_string(&legacy).unwrap();
10250        assert!(serde_json::from_str::<ManagedWorktreeSpawnRequest>(&legacy_json).is_ok());
10251        assert!(serde_json::from_str::<ManagedWorktreeSpawnRequestV2>(&legacy_json).is_err());
10252        let v2 = NodeRequest::SpawnManagedWorktreeV2 {
10253            request: ManagedWorktreeSpawnRequestV2 {
10254                spawn_spec: legacy.spawn_spec,
10255                worktree_profile_id: legacy.worktree_profile_id,
10256                expected_profile_revision: WorktreeProfileRevision::new("review.r1").unwrap(),
10257            },
10258        };
10259        assert_eq!(
10260            v2.required_capability(),
10261            Some(NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY),
10262        );
10263        assert!(v2.requires_worktree_selection_capability());
10264    }
10265
10266    #[test]
10267    fn managed_worktree_snapshot_rejects_invalid_time_duplicate_identity_and_overflow() {
10268        fn lease(lease_id: &str, workspace_id: &str) -> serde_json::Value {
10269            serde_json::json!({
10270                "lease_id": lease_id,
10271                "source_workspace_id": "primary",
10272                "workspace_id": workspace_id,
10273                "profile_id": "review",
10274                "profile_revision": "review.r1",
10275                "retention": "remove-when-released",
10276                "state": "ready",
10277                "active_session_count": 0,
10278                "managed_record_count": 0,
10279                "cleanup_failure": null,
10280                "created_at_unix_ms": 1,
10281                "updated_at_unix_ms": 2
10282            })
10283        }
10284        fn snapshot(leases: Vec<serde_json::Value>) -> serde_json::Value {
10285            serde_json::json!({
10286                "node_id": "node-a",
10287                "enabled_providers": [],
10288                "workspaces": [],
10289                "session_records": [],
10290                "managed_worktrees": leases
10291            })
10292        }
10293
10294        let mut reversed = lease("lease-a", "managed-a");
10295        reversed["updated_at_unix_ms"] = serde_json::json!(0);
10296        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![reversed])).is_err());
10297        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![lease(
10298            "lease-a",
10299            "primary",
10300        )]))
10301        .is_err());
10302
10303        for (state, active, records, failure) in [
10304            ("ready", 1, 0, serde_json::Value::Null),
10305            ("in-use", 0, 0, serde_json::Value::Null),
10306            ("in-use", 1, 0, serde_json::json!("busy")),
10307            ("cleanup-blocked", 0, 0, serde_json::Value::Null),
10308            ("cleanup-blocked", 1, 0, serde_json::json!("busy")),
10309            ("recovery-required", 0, 0, serde_json::Value::Null),
10310            ("removed", 0, 1, serde_json::Value::Null),
10311        ] {
10312            let mut malformed = lease("lease-a", "managed-a");
10313            malformed["state"] = serde_json::json!(state);
10314            malformed["active_session_count"] = serde_json::json!(active);
10315            malformed["managed_record_count"] = serde_json::json!(records);
10316            malformed["cleanup_failure"] = failure;
10317            assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![malformed])).is_err());
10318        }
10319        let mut recovery_with_holder = lease("lease-a", "managed-a");
10320        recovery_with_holder["state"] = serde_json::json!("recovery-required");
10321        recovery_with_holder["managed_record_count"] = serde_json::json!(1);
10322        recovery_with_holder["cleanup_failure"] = serde_json::json!("ownership-conflict");
10323        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![recovery_with_holder])).is_ok());
10324
10325        let mut spawn_lease = lease("lease-a", "managed-a");
10326        spawn_lease["state"] = serde_json::json!("in-use");
10327        spawn_lease["active_session_count"] = serde_json::json!(1);
10328        spawn_lease["managed_record_count"] = serde_json::json!(1);
10329        let spawn = serde_json::json!({
10330            "incarnation_id": "00000000000000000000000000000000",
10331            "session": {
10332                "workspace_id": "managed-a",
10333                "session": { "instance_id": 1, "generation": 1 }
10334            },
10335            "target": {
10336                "node_id": "node-a",
10337                "workspace_id": "primary",
10338                "worktree_id": "managed-a"
10339            },
10340            "profile_id": "default",
10341            "profile_revision": "default.r1",
10342            "provider": "claude",
10343            "mode": "pty",
10344            "terminal_size": { "rows": 24, "columns": 80 },
10345            "prompt": { "present": false, "byte_len": 0 },
10346            "bundle_id": null,
10347            "context_id": null,
10348            "environment_profile_id": null,
10349            "deadline_ms": 5000,
10350            "idempotency_key": "managed-1",
10351            "required_capabilities": [],
10352            "provenance": {
10353                "provider": "profile",
10354                "mode": "profile",
10355                "terminal_size": "profile",
10356                "prompt": "profile",
10357                "bundle_id": "profile",
10358                "context_id": "profile",
10359                "environment_profile_id": "profile"
10360            }
10361        });
10362        let valid_receipt = serde_json::json!({ "spawn": spawn, "lease": spawn_lease });
10363        assert!(serde_json::from_value::<ManagedWorktreeSpawnReceipt>(valid_receipt.clone()).is_ok());
10364        let mut wrong_source = valid_receipt.clone();
10365        wrong_source["lease"]["source_workspace_id"] = serde_json::json!("other");
10366        assert!(serde_json::from_value::<ManagedWorktreeSpawnReceipt>(wrong_source).is_err());
10367        let mut wrong_session = valid_receipt;
10368        wrong_session["spawn"]["session"]["workspace_id"] = serde_json::json!("other");
10369        assert!(serde_json::from_value::<ManagedWorktreeSpawnReceipt>(wrong_session).is_err());
10370
10371        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![
10372            lease("lease-a", "managed-a"),
10373            lease("lease-a", "managed-b"),
10374        ]))
10375        .is_err());
10376        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![
10377            lease("lease-a", "managed-a"),
10378            lease("lease-b", "managed-a"),
10379        ]))
10380        .is_err());
10381
10382        let leases = (0..=MAX_MANAGED_WORKTREE_LEASES)
10383            .map(|index| lease(&format!("lease-{index}"), &format!("managed-{index}")))
10384            .collect();
10385        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(leases)).is_err());
10386    }
10387
10388    #[test]
10389    fn terminal_bytes_round_trip_as_an_exact_byte_array() {
10390        let request = NodeRequest::TerminalBytes {
10391            session: SessionAddress {
10392                workspace_id: WorkspaceId::new("primary").unwrap(),
10393                session: SessionKey {
10394                    instance_id: AgentInstanceId(7),
10395                    generation: SessionGeneration(3),
10396                },
10397            },
10398            bytes: b"\x1b[1;5D".to_vec(),
10399        };
10400        let json = serde_json::to_string(&request).unwrap();
10401        assert_eq!(
10402            json,
10403            r#"{"kind":"terminal-bytes","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":3}},"bytes":[27,91,49,59,53,68]}"#,
10404        );
10405        assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
10406    }
10407
10408    #[test]
10409    fn terminal_frame_event_wire_contract_is_exact() {
10410        let event = NodeEvent::TerminalFrame {
10411            address: SessionAddress {
10412                workspace_id: WorkspaceId::new("primary").unwrap(),
10413                session: SessionKey {
10414                    instance_id: AgentInstanceId(7),
10415                    generation: SessionGeneration(3),
10416                },
10417            },
10418            frame: TerminalFrame {
10419                sequence: 11,
10420                size: TerminalSize { rows: 24, columns: 80 },
10421                cursor_row: 2,
10422                cursor_column: 4,
10423                contents: "ready".to_owned(),
10424                formatted: b"ready".to_vec(),
10425                scrollback_formatted: vec![b"previous".to_vec()],
10426                alternate_screen: false,
10427                mouse_protocol_enabled: false,
10428                mouse_protocol_encoding:
10429                    gate4agent_types::TerminalMouseProtocolEncoding::Default,
10430                produced_at_unix_ms: 0,
10431                screen_state: gate4agent_types::PtyScreenState::default(),
10432                bracketed_paste: None,
10433            },
10434        };
10435        let json = serde_json::to_string(&event).unwrap();
10436        assert_eq!(
10437            json,
10438            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"}}}"#,
10439        );
10440        assert_eq!(serde_json::from_str::<NodeEvent>(&json).unwrap(), event);
10441    }
10442
10443    #[test]
10444    fn terminal_frame_events_capability_is_optional_and_auth_bound_exactly() {
10445        assert_eq!(
10446            NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
10447            "terminal-frame-events-v1",
10448        );
10449        assert!(production_node_client_compatibility_offer()
10450            .capabilities
10451            .iter()
10452            .any(|capability| capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY));
10453
10454        let capability = CapabilityId::new(NODE_TERMINAL_FRAME_EVENTS_CAPABILITY).unwrap();
10455        let mut support = portable_node_support();
10456        support.capabilities = vec![capability.clone()];
10457        let legacy = ClientCompatibilityOffer::local();
10458        assert!(support
10459            .negotiate(&legacy)
10460            .unwrap()
10461            .capabilities
10462            .is_empty());
10463
10464        let offer = ClientCompatibilityOffer {
10465            build_stamp: BUILD_STAMP.to_owned(),
10466            capabilities: vec![capability.clone()],
10467            state_schema: None,
10468        };
10469        let selected = support
10470            .negotiate(&offer)
10471            .unwrap();
10472        assert_eq!(selected.capabilities, vec![capability]);
10473        assert_eq!(
10474            String::from_utf8(
10475                encode_node_compatibility_auth_binding(&offer, &selected).unwrap(),
10476            )
10477            .unwrap(),
10478            format!(
10479                r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["terminal-frame-events-v1"]}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["terminal-frame-events-v1"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","provider_contracts":[]}}}}"#,
10480            ),
10481        );
10482    }
10483
10484    #[test]
10485    fn child_environment_profile_capability_is_optional_and_auth_bound_exactly() {
10486        assert_eq!(
10487            NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
10488            "child-environment-profile-v1",
10489        );
10490        assert!(production_node_client_compatibility_offer()
10491            .capabilities
10492            .iter()
10493            .any(|capability| {
10494                capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
10495            }));
10496
10497        let capability =
10498            CapabilityId::new(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).unwrap();
10499        let mut support = portable_node_support();
10500        support.capabilities = vec![capability.clone()];
10501        let legacy = ClientCompatibilityOffer::local();
10502        assert!(support
10503            .negotiate(&legacy)
10504            .unwrap()
10505            .capabilities
10506            .is_empty());
10507
10508        let offer = ClientCompatibilityOffer {
10509            build_stamp: BUILD_STAMP.to_owned(),
10510            capabilities: vec![capability.clone()],
10511            state_schema: None,
10512        };
10513        let selected = support
10514            .negotiate(&offer)
10515            .unwrap();
10516        assert_eq!(selected.capabilities, vec![capability]);
10517        let bound = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
10518        assert!(bound
10519            .windows(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.len())
10520            .any(|window| {
10521                window == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.as_bytes()
10522            }));
10523    }
10524
10525    #[test]
10526    fn worktree_selection_capability_is_optional_and_auth_bound_exactly() {
10527        assert_eq!(NODE_WORKTREE_SELECTION_CAPABILITY, "worktree-selection-v1");
10528        assert!(production_node_client_compatibility_offer()
10529            .capabilities
10530            .iter()
10531            .any(|capability| capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY));
10532
10533        let capability = CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
10534        let mut support = portable_node_support();
10535        support.capabilities = vec![capability.clone()];
10536        let legacy = ClientCompatibilityOffer::local();
10537        assert!(support
10538            .negotiate(&legacy)
10539            .unwrap()
10540            .capabilities
10541            .is_empty());
10542
10543        let offer = ClientCompatibilityOffer {
10544            build_stamp: BUILD_STAMP.to_owned(),
10545            capabilities: vec![capability.clone()],
10546            state_schema: None,
10547        };
10548        let selected = support.negotiate(&offer).unwrap();
10549        assert_eq!(selected.capabilities, vec![capability]);
10550        assert_eq!(
10551            String::from_utf8(
10552                encode_node_compatibility_auth_binding(&offer, &selected).unwrap(),
10553            )
10554            .unwrap(),
10555            format!(
10556                r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["worktree-selection-v1"]}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["worktree-selection-v1"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","provider_contracts":[]}}}}"#,
10557            ),
10558        );
10559    }
10560
10561    #[test]
10562    fn legacy_node_event_bytes_remain_exact_after_terminal_frame_addition() {
10563        assert_eq!(
10564            serde_json::to_vec(&NodeEvent::ResyncRequired {
10565                oldest_available_sequence: 7,
10566            })
10567            .unwrap(),
10568            br#"{"kind":"resync-required","oldest_available_sequence":7}"#,
10569        );
10570    }
10571
10572    #[test]
10573    fn workspace_responses_and_events_round_trip_without_client_only_state() {
10574        let workspace = WorkspaceSnapshot {
10575            workspace_id: WorkspaceId::new("repo-2").unwrap(),
10576            canonical_root: host_path(r"C:\repo-2"),
10577            sessions: Vec::new(),
10578            worktree_service_mode: None,
10579            managed_worktree_profiles: None,
10580        };
10581        let registered = NodeResponse::WorkspaceRegistered {
10582            workspace: workspace.clone(),
10583        };
10584        let registered_json = serde_json::to_string(&registered).unwrap();
10585        assert_eq!(
10586            serde_json::from_str::<NodeResponse>(&registered_json).unwrap(),
10587            registered,
10588        );
10589        let standalone = NodeResponse::StandaloneWorkspaceCreated {
10590            workspace: workspace.clone(),
10591        };
10592        let standalone_json = serde_json::to_string(&standalone).unwrap();
10593        assert_eq!(
10594            serde_json::from_str::<NodeResponse>(&standalone_json).unwrap(),
10595            standalone,
10596        );
10597        let added = NodeEventEnvelope {
10598            sequence: 19,
10599            event: NodeEvent::WorkspaceAdded {
10600                workspace: workspace.clone(),
10601            },
10602        };
10603        let added_json = serde_json::to_string(&added).unwrap();
10604        assert_eq!(
10605            serde_json::from_str::<NodeEventEnvelope>(&added_json).unwrap(),
10606            added,
10607        );
10608        let removed = NodeEventEnvelope {
10609            sequence: 20,
10610            event: NodeEvent::WorkspaceRemoved {
10611                workspace_id: workspace.workspace_id.clone(),
10612            },
10613        };
10614        let removed_json = serde_json::to_string(&removed).unwrap();
10615        assert_eq!(
10616            serde_json::from_str::<NodeEventEnvelope>(&removed_json).unwrap(),
10617            removed,
10618        );
10619
10620        let created = NodeResponse::WorktreeCreated {
10621            worktree: GitWorktreeSnapshot {
10622                path: host_path(r"C:\trees\topic-one"),
10623                head: "abc1234".to_owned(),
10624                branch: Some("codex/topic-one".to_owned()),
10625                is_bare: false,
10626                is_main: false,
10627                locked: false,
10628                lock_reason: None,
10629                prunable: false,
10630                prunable_reason: None,
10631                workspace_id: Some(workspace.workspace_id.clone()),
10632            },
10633            workspace: workspace.clone(),
10634        };
10635        let created_json = serde_json::to_string(&created).unwrap();
10636        assert_eq!(serde_json::from_str::<NodeResponse>(&created_json).unwrap(), created);
10637        let removed = NodeResponse::WorktreeRemoved {
10638            target_root: host_path(r"C:\trees\topic-one"),
10639            workspace_id: Some(workspace.workspace_id),
10640        };
10641        let removed_json = serde_json::to_string(&removed).unwrap();
10642        assert_eq!(serde_json::from_str::<NodeResponse>(&removed_json).unwrap(), removed);
10643    }
10644
10645    #[test]
10646    fn workspace_inspection_round_trips_as_a_workspace_scoped_read_only_request() {
10647        let workspace_id = WorkspaceId::new("primary").unwrap();
10648        let request = NodeRequest::InspectWorkspace {
10649            workspace_id: workspace_id.clone(),
10650        };
10651        let request_json = serde_json::to_string(&request).unwrap();
10652        assert_eq!(
10653            request_json,
10654            r#"{"kind":"inspect-workspace","workspace_id":"primary"}"#,
10655        );
10656        assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
10657
10658        let response = NodeResponse::WorkspaceInspected {
10659            inspection: WorkspaceInspection {
10660                workspace_id,
10661                entries: vec![
10662                    WorkspaceEntry {
10663                        relative_path: repository_path("src"),
10664                        kind: WorkspaceEntryKind::Directory,
10665                    },
10666                    WorkspaceEntry {
10667                        relative_path: repository_path("src/lib.rs"),
10668                        kind: WorkspaceEntryKind::File,
10669                    },
10670                ],
10671                tree_truncated: false,
10672                git: GitSnapshot {
10673                    is_repository: true,
10674                    branch: Some("main".to_owned()),
10675                    status: vec![GitStatusEntry {
10676                        index_status: " ".to_owned(),
10677                        worktree_status: "M".to_owned(),
10678                        path: repository_path("src/lib.rs"),
10679                        previous_path: None,
10680                    }],
10681                    recent_commits: vec![GitCommitSummary {
10682                        id: "abc1234".to_owned(),
10683                        summary: "bounded summary".to_owned(),
10684                    }],
10685                    worktrees: vec![GitWorktreeSnapshot {
10686                        path: host_path(r"C:\repo"),
10687                        head: "abc1234".to_owned(),
10688                        branch: Some("main".to_owned()),
10689                        is_bare: false,
10690                        is_main: true,
10691                        locked: false,
10692                        lock_reason: None,
10693                        prunable: false,
10694                        prunable_reason: None,
10695                        workspace_id: Some(WorkspaceId::new("primary").unwrap()),
10696                    }],
10697                    managed_worktree: None,
10698                    truncated: false,
10699                    diagnostic: None,
10700                },
10701                truncation: None,
10702            },
10703        };
10704        let response_json = serde_json::to_string(&response).unwrap();
10705        assert!(!response_json.contains("managed_worktree"));
10706        assert!(!response_json.contains("truncation"));
10707        assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
10708    }
10709
10710    #[test]
10711    fn workspace_inspection_truncation_is_additive_and_defaults_on_old_payloads() {
10712        let workspace_id = WorkspaceId::new("primary").unwrap();
10713        let old_payload = serde_json::json!({
10714            "workspace_id": workspace_id.as_str(),
10715            "entries": [],
10716            "tree_truncated": false,
10717            "git": {
10718                "is_repository": false,
10719                "branch": null,
10720                "status": [],
10721                "recent_commits": [],
10722                "worktrees": [],
10723                "truncated": false,
10724                "diagnostic": null,
10725            },
10726        });
10727        let parsed: WorkspaceInspection = serde_json::from_value(old_payload).unwrap();
10728        assert_eq!(parsed.truncation, None);
10729
10730        let truncated = WorkspaceInspectionTruncationV1 {
10731            walk_time_budget_exceeded: true,
10732            walk_entry_cap_exceeded: false,
10733            git_time_budget_exceeded: true,
10734            entries_visited: 50_000,
10735            elapsed_ms: 8_000,
10736        };
10737        let inspection = WorkspaceInspection {
10738            workspace_id,
10739            entries: Vec::new(),
10740            tree_truncated: true,
10741            git: GitSnapshot {
10742                is_repository: false,
10743                branch: None,
10744                status: Vec::new(),
10745                recent_commits: Vec::new(),
10746                worktrees: Vec::new(),
10747                managed_worktree: None,
10748                truncated: true,
10749                diagnostic: Some("git inspection skipped: workspace inspection time budget exhausted".to_owned()),
10750            },
10751            truncation: Some(truncated),
10752        };
10753        let json = serde_json::to_string(&inspection).unwrap();
10754        let round_tripped: WorkspaceInspection = serde_json::from_str(&json).unwrap();
10755        assert_eq!(round_tripped.truncation, Some(truncated));
10756    }
10757
10758    #[test]
10759    fn managed_worktree_git_scope_is_optional_bounded_and_round_trips() {
10760        let scope = ManagedWorktreeGitScope {
10761            lease_id: ManagedWorktreeLeaseId::new("mw-scope").unwrap(),
10762            source_workspace_id: WorkspaceId::new("primary").unwrap(),
10763            branch: "gate4agent/mw-scope".to_owned(),
10764            base_commit: GitObjectId::new(
10765                "0123456789abcdef0123456789abcdef01234567".to_owned(),
10766            )
10767            .unwrap(),
10768            active_session_count: 1,
10769            managed_record_count: 1,
10770        };
10771        let json = serde_json::to_string(&scope).unwrap();
10772        assert_eq!(serde_json::from_str::<ManagedWorktreeGitScope>(&json).unwrap(), scope);
10773
10774        let oversized = format!(
10775            r#"{{"lease_id":"mw-scope","source_workspace_id":"primary","branch":"{}","base_commit":"0123456789abcdef0123456789abcdef01234567","active_session_count":0,"managed_record_count":0}}"#,
10776            "x".repeat(MAX_REPOSITORY_PATH_BYTES + 1),
10777        );
10778        assert!(serde_json::from_str::<ManagedWorktreeGitScope>(&oversized).is_err());
10779    }
10780
10781    #[test]
10782    fn workspace_inspection_rejects_inconsistent_managed_git_scope() {
10783        let valid = r#"{"workspace_id":"managed-a","entries":[],"tree_truncated":false,"git":{"is_repository":true,"branch":"gate4agent/a","status":[],"recent_commits":[],"worktrees":[],"managed_worktree":{"lease_id":"mw-a","source_workspace_id":"primary","branch":"gate4agent/a","base_commit":"0123456789abcdef0123456789abcdef01234567","active_session_count":1,"managed_record_count":0},"truncated":false,"diagnostic":null}}"#;
10784        assert!(serde_json::from_str::<WorkspaceInspection>(valid).is_ok());
10785        for invalid in [
10786            valid.replace("\"is_repository\":true", "\"is_repository\":false"),
10787            valid.replacen("\"branch\":\"gate4agent/a\"", "\"branch\":\"gate4agent/b\"", 1),
10788            valid.replace("\"source_workspace_id\":\"primary\"", "\"source_workspace_id\":\"managed-a\""),
10789            valid.replace("\"active_session_count\":1", "\"active_session_count\":0"),
10790        ] {
10791            assert!(serde_json::from_str::<WorkspaceInspection>(&invalid).is_err());
10792        }
10793    }
10794
10795    #[test]
10796    fn workspace_file_read_has_an_exact_capability_gated_wire_contract() {
10797        let workspace_id = WorkspaceId::new("primary").unwrap();
10798        let path = repository_path("src/lib.rs");
10799        let request = NodeRequest::ReadWorkspaceFile {
10800            workspace_id: workspace_id.clone(),
10801            path: path.clone(),
10802        };
10803        let request_json = serde_json::to_string(&request).unwrap();
10804        assert_eq!(
10805            request_json,
10806            r#"{"kind":"read-workspace-file","workspace_id":"primary","path":"src/lib.rs"}"#,
10807        );
10808        assert_eq!(request.required_capability(), Some(NODE_WORKSPACE_FILE_READ_CAPABILITY));
10809        assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
10810
10811        let response = NodeResponse::WorkspaceFileRead {
10812            file: WorkspaceFileRead {
10813                workspace_id,
10814                path,
10815                content: WorkspaceFileContent::Utf8 {
10816                    text: "fn main() {}\n".to_owned(),
10817                    byte_len: 13,
10818                },
10819                revision: None,
10820            },
10821        };
10822        let response_json = serde_json::to_string(&response).unwrap();
10823        assert_eq!(
10824            response_json,
10825            r#"{"kind":"workspace-file-read","file":{"workspace_id":"primary","path":"src/lib.rs","content":{"kind":"utf8","text":"fn main() {}\n","byte_len":13}}}"#,
10826        );
10827        assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
10828    }
10829
10830    #[test]
10831    fn workspace_entry_create_has_exact_capability_gated_wire_contracts() {
10832        assert!(production_node_client_compatibility_offer()
10833            .capabilities
10834            .iter()
10835            .any(|capability| capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY));
10836        let workspace_id = WorkspaceId::new("primary").unwrap();
10837        let file_path = repository_path("src/new.rs");
10838        let directory_path = repository_path("src/new");
10839        let create_file = NodeRequest::CreateWorkspaceFile {
10840            workspace_id: workspace_id.clone(),
10841            path: file_path.clone(),
10842        };
10843        let create_directory = NodeRequest::CreateWorkspaceDirectory {
10844            workspace_id: workspace_id.clone(),
10845            path: directory_path.clone(),
10846        };
10847        assert_eq!(
10848            serde_json::to_string(&create_file).unwrap(),
10849            r#"{"kind":"create-workspace-file","workspace_id":"primary","path":"src/new.rs"}"#,
10850        );
10851        assert_eq!(
10852            serde_json::to_string(&create_directory).unwrap(),
10853            r#"{"kind":"create-workspace-directory","workspace_id":"primary","path":"src/new"}"#,
10854        );
10855        for request in [create_file, create_directory] {
10856            assert_eq!(
10857                request.required_capability(),
10858                Some(NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY),
10859            );
10860            let encoded = serde_json::to_string(&request).unwrap();
10861            assert_eq!(serde_json::from_str::<NodeRequest>(&encoded).unwrap(), request);
10862        }
10863
10864        let file_response = NodeResponse::WorkspaceFileCreated {
10865            file: WorkspaceFileRead {
10866                workspace_id: workspace_id.clone(),
10867                path: file_path,
10868                content: WorkspaceFileContent::Utf8 {
10869                    text: String::new(),
10870                    byte_len: 0,
10871                },
10872                revision: Some(WorkspaceFileRevision::new("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned()).unwrap()),
10873            },
10874        };
10875        let directory_response = NodeResponse::WorkspaceDirectoryCreated {
10876            workspace_id,
10877            entry: WorkspaceEntry {
10878                relative_path: directory_path,
10879                kind: WorkspaceEntryKind::Directory,
10880            },
10881        };
10882        for response in [file_response, directory_response] {
10883            let encoded = serde_json::to_string(&response).unwrap();
10884            assert_eq!(serde_json::from_str::<NodeResponse>(&encoded).unwrap(), response);
10885        }
10886    }
10887
10888    #[test]
10889    fn workspace_file_read_worst_case_json_stays_within_the_server_frame_limit() {
10890        let response = NodeResponse::WorkspaceFileRead {
10891            file: WorkspaceFileRead {
10892                workspace_id: WorkspaceId::new("primary").unwrap(),
10893                path: repository_path(&"x".repeat(MAX_REPOSITORY_PATH_BYTES)),
10894                content: WorkspaceFileContent::Utf8 {
10895                    text: "\0".repeat(MAX_WORKSPACE_FILE_BYTES),
10896                    byte_len: u32::try_from(MAX_WORKSPACE_FILE_BYTES).unwrap(),
10897                },
10898                revision: None,
10899            },
10900        };
10901        let encoded = serde_json::to_vec(&response).unwrap();
10902        assert!(encoded.len() <= MAX_NODE_FRAME_BYTES, "{}", encoded.len());
10903    }
10904
10905    #[test]
10906    fn read_context_pack_has_an_exact_capability_gated_wire_contract() {
10907        let digest = SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).unwrap();
10908        let request = NodeRequest::ReadContextPack {
10909            digest: digest.clone(),
10910        };
10911        let request_json = serde_json::to_string(&request).unwrap();
10912        assert_eq!(
10913            request_json,
10914            format!(
10915                r#"{{"kind":"read-context-pack","digest":"sha256:{}"}}"#,
10916                "a".repeat(64),
10917            ),
10918        );
10919        assert_eq!(
10920            request.required_capability(),
10921            Some(NODE_HISTORY_CONTEXT_PACK_CAPABILITY),
10922        );
10923        assert!(request.history_context_pack_contract_is_valid());
10924        assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
10925
10926        let id = SpawnContextId::new(format!("ctx-{}", "a".repeat(64))).unwrap();
10927        let response = NodeResponse::ContextPackBytesRead {
10928            pack: ContextPackBytesRead {
10929                digest: digest.clone(),
10930                id: id.clone(),
10931                byte_len: 11,
10932                bytes: b"pack-bytes!".to_vec(),
10933            },
10934        };
10935        assert!(response.requires_history_context_pack_capability());
10936        let response_json = serde_json::to_string(&response).unwrap();
10937        assert_eq!(
10938            response_json,
10939            format!(
10940                r#"{{"kind":"context-pack-bytes-read","pack":{{"digest":"sha256:{}","id":"{}","byte_len":11,"bytes":[112,97,99,107,45,98,121,116,101,115,33]}}}}"#,
10941                "a".repeat(64),
10942                id.as_str(),
10943            ),
10944        );
10945        assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
10946    }
10947
10948    #[test]
10949    fn context_pack_bytes_read_deserialization_rejects_inconsistent_or_oversized_lengths() {
10950        let digest = SpawnContextDigest::new(format!("sha256:{}", "b".repeat(64))).unwrap();
10951        let id = SpawnContextId::new(format!("ctx-{}", "b".repeat(64))).unwrap();
10952
10953        let valid = ContextPackBytesRead {
10954            digest: digest.clone(),
10955            id: id.clone(),
10956            byte_len: 4,
10957            bytes: b"test".to_vec(),
10958        };
10959        let encoded = serde_json::to_string(&valid).unwrap();
10960        assert_eq!(serde_json::from_str::<ContextPackBytesRead>(&encoded).unwrap(), valid);
10961
10962        let declares_more_than_it_carries = ContextPackBytesRead {
10963            digest: digest.clone(),
10964            id: id.clone(),
10965            byte_len: 5,
10966            bytes: b"test".to_vec(),
10967        };
10968        let encoded = serde_json::to_string(&declares_more_than_it_carries).unwrap();
10969        assert!(serde_json::from_str::<ContextPackBytesRead>(&encoded).is_err());
10970
10971        let oversized_len = MAX_CONTEXT_PACK_BYTES + 1;
10972        let oversized = ContextPackBytesRead {
10973            digest,
10974            id,
10975            byte_len: oversized_len,
10976            bytes: vec![0u8; oversized_len as usize],
10977        };
10978        let encoded = serde_json::to_string(&oversized).unwrap();
10979        assert!(serde_json::from_str::<ContextPackBytesRead>(&encoded).is_err());
10980    }
10981
10982    /// Pins `context_pack_digest`'s formula (`SHA256(domain || JSON(lineage)
10983    /// || 0x00 || bytes)`, rendered `sha256:<hex>`) to a literal so a future
10984    /// edit to the hashing, the domain tag, the separator byte, or the
10985    /// lineage's own JSON shape shows up here as a changed digest rather than
10986    /// drifting silently between the node (which computes it on export) and
10987    /// the harness (which recomputes it to check a fetched pack).
10988    #[test]
10989    fn context_pack_digest_formula_is_pinned() {
10990        let lineage = ContextPackLineageReceipt {
10991            source_node_id: NodeId::new("node-pin").unwrap(),
10992            source_session: session_address("pin-workspace", 42),
10993            source_provider: agent("claude"),
10994        };
10995        let digest = context_pack_digest(&lineage, b"pinned-context-pack-bytes");
10996        assert_eq!(
10997            digest.as_str(),
10998            "sha256:d35f4bd15866f03ca3621129bd661862a307a46002fd90e1ff2ca988c72a7035",
10999        );
11000    }
11001
11002    /// Pins `sha256_hex`'s formula (plain `SHA256(bytes)`, bare lowercase hex,
11003    /// no prefix and no domain separation) to a literal so a future edit
11004    /// shows up here as a changed digest rather than drifting silently
11005    /// between the node (which stamps `WorkspaceFileRevision` with it on
11006    /// read) and the harness (which recomputes it to check a mailed
11007    /// `WorkspacePath` ref's bytes).
11008    #[test]
11009    fn sha256_hex_formula_is_pinned() {
11010        assert_eq!(
11011            sha256_hex(b"gate4agent"),
11012            "dfcb966151fe0d63481517c76a7ff6237aeaa5e24f7d76dba7fa862a3a3c5b6b",
11013        );
11014    }
11015
11016    #[test]
11017    fn workspace_write_and_git_reads_have_exact_capability_gated_contracts() {
11018        let workspace_id = WorkspaceId::new("primary").unwrap();
11019        let path = repository_path("src/lib.rs");
11020        let revision = WorkspaceFileRevision::new("a".repeat(64)).unwrap();
11021        let write = NodeRequest::WriteWorkspaceFile {
11022            workspace_id: workspace_id.clone(),
11023            path: path.clone(),
11024            expected_revision: revision,
11025            text: "updated\n".to_owned(),
11026        };
11027        assert_eq!(write.required_capability(), Some(NODE_WORKSPACE_FILE_WRITE_CAPABILITY));
11028        let encoded = serde_json::to_string(&write).unwrap();
11029        assert_eq!(serde_json::from_str::<NodeRequest>(&encoded).unwrap(), write);
11030
11031        let history = NodeRequest::ReadGitHistory {
11032            workspace_id: workspace_id.clone(),
11033            path: Some(path.clone()),
11034            before: Some(GitObjectId::new("b".repeat(40)).unwrap()),
11035            limit: MAX_GIT_HISTORY_COMMITS,
11036        };
11037        let diff = NodeRequest::ReadGitDiff {
11038            workspace_id,
11039            request: GitDiffRequest {
11040                mode: GitDiffMode::Commit {
11041                    revision: GitObjectId::new("c".repeat(40)).unwrap(),
11042                },
11043                path: Some(path),
11044            },
11045        };
11046        for request in [history, diff] {
11047            assert_eq!(request.required_capability(), Some(NODE_GIT_READ_CAPABILITY));
11048            let encoded = serde_json::to_string(&request).unwrap();
11049            assert_eq!(serde_json::from_str::<NodeRequest>(&encoded).unwrap(), request);
11050        }
11051    }
11052
11053    #[test]
11054    fn workspace_file_content_deserialization_rejects_unbounded_or_inconsistent_lengths() {
11055        let valid = [
11056            WorkspaceFileContent::Utf8 {
11057                text: "тест".to_owned(),
11058                byte_len: 8,
11059            },
11060            WorkspaceFileContent::NonUtf8 { byte_len: 17 },
11061            WorkspaceFileContent::TooLarge {
11062                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
11063            },
11064        ];
11065        for content in valid {
11066            let encoded = serde_json::to_string(&content).unwrap();
11067            assert_eq!(
11068                serde_json::from_str::<WorkspaceFileContent>(&encoded).unwrap(),
11069                content,
11070            );
11071        }
11072
11073        let inconsistent = r#"{"kind":"utf8","text":"hello","byte_len":4}"#;
11074        assert!(serde_json::from_str::<WorkspaceFileContent>(inconsistent).is_err());
11075
11076        let oversized_non_utf8 = format!(
11077            r#"{{"kind":"non-utf8","byte_len":{}}}"#,
11078            MAX_WORKSPACE_FILE_BYTES + 1,
11079        );
11080        assert!(serde_json::from_str::<WorkspaceFileContent>(&oversized_non_utf8).is_err());
11081
11082        let wrong_limit = r#"{"kind":"too-large","limit_bytes":1}"#;
11083        assert!(serde_json::from_str::<WorkspaceFileContent>(wrong_limit).is_err());
11084
11085        let oversized_utf8 = WorkspaceFileContent::Utf8 {
11086            text: "x".repeat(MAX_WORKSPACE_FILE_BYTES + 1),
11087            byte_len: u32::try_from(MAX_WORKSPACE_FILE_BYTES + 1).unwrap(),
11088        };
11089        let encoded = serde_json::to_string(&oversized_utf8).unwrap();
11090        assert!(serde_json::from_str::<WorkspaceFileContent>(&encoded).is_err());
11091    }
11092
11093    #[test]
11094    fn legacy_requests_do_not_acquire_a_new_required_capability() {
11095        let legacy_requests = [
11096            NodeRequest::Snapshot,
11097            NodeRequest::Resync { after_sequence: 7 },
11098            NodeRequest::InspectWorkspace {
11099                workspace_id: WorkspaceId::new("primary").unwrap(),
11100            },
11101        ];
11102        for request in legacy_requests {
11103            assert_eq!(request.required_capability(), None);
11104        }
11105    }
11106
11107    #[test]
11108    fn host_directory_browse_wire_contract_is_bounded_and_capability_gated() {
11109        assert!(production_node_client_compatibility_offer()
11110            .capabilities
11111            .iter()
11112            .any(|capability| capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1));
11113        let directory = OpaqueHostPath::utf8(r"C:\repo".to_owned()).unwrap();
11114        let request = NodeRequest::BrowseHostDirectories {
11115            directory: Some(directory.clone()),
11116            after: None,
11117        };
11118        assert_eq!(
11119            request.required_capability(),
11120            Some(CAPABILITY_HOST_DIRECTORY_BROWSE_V1),
11121        );
11122        assert_eq!(
11123            serde_json::to_string(&request).unwrap(),
11124            r#"{"kind":"browse-host-directories","directory":"C:\\repo","after":null}"#,
11125        );
11126
11127        let response = NodeResponse::HostDirectoriesBrowsed {
11128            listing: HostDirectoryListing {
11129                directory: Some(directory.clone()),
11130                parent: Some(OpaqueHostPath::utf8(r"C:\".to_owned()).unwrap()),
11131                entries: vec![HostDirectoryEntry {
11132                    path: OpaqueHostPath::utf8(r"C:\repo\child".to_owned()).unwrap(),
11133                    display_name: "child".to_owned(),
11134                    is_link: false,
11135                }],
11136                next_after: None,
11137                incomplete: false,
11138            },
11139        };
11140        let encoded = serde_json::to_string(&response).unwrap();
11141        assert_eq!(serde_json::from_str::<NodeResponse>(&encoded).unwrap(), response);
11142
11143        let entries = (0..=MAX_HOST_DIRECTORY_ENTRIES)
11144            .map(|index| serde_json::json!({
11145                "path": format!(r"C:\directory-{index}"),
11146                "display_name": format!("directory-{index}"),
11147                "is_link": false,
11148            }))
11149            .collect::<Vec<_>>();
11150        let overflow = serde_json::json!({
11151            "directory": null,
11152            "parent": null,
11153            "entries": entries,
11154            "next_after": null,
11155            "incomplete": true,
11156        });
11157        assert!(serde_json::from_value::<HostDirectoryListing>(overflow).is_err());
11158        assert_eq!(
11159            serde_json::to_string(&NodeFailureCode::HostDirectoryReadTimedOut).unwrap(),
11160            r#""host-directory-read-timed-out""#,
11161        );
11162    }
11163
11164    #[test]
11165    fn host_directory_entry_rejects_unbounded_control_or_non_utf8_wire_values() {
11166        let path = host_path(r"C:\repo\child");
11167        assert!(HostDirectoryEntry::new(path.clone(), "child".to_owned(), false).is_ok());
11168        for display_name in [
11169            String::new(),
11170            "child\nname".to_owned(),
11171            "x".repeat(MAX_HOST_DIRECTORY_DISPLAY_NAME_BYTES + 1),
11172        ] {
11173            let encoded = serde_json::json!({
11174                "path": path,
11175                "display_name": display_name,
11176                "is_link": false,
11177            });
11178            assert!(serde_json::from_value::<HostDirectoryEntry>(encoded).is_err());
11179        }
11180        let non_utf8 = serde_json::json!({
11181            "path": { "kind": "unix-bytes", "bytes": [47, 255] },
11182            "display_name": "child",
11183            "is_link": false,
11184        });
11185        assert!(serde_json::from_value::<HostDirectoryEntry>(non_utf8).is_err());
11186    }
11187
11188    #[test]
11189    fn git_snapshot_defaults_worktrees_for_legacy_inspection_payloads() {
11190        let json = r#"{"is_repository":true,"branch":"main","status":[],"recent_commits":[],"truncated":false,"diagnostic":null}"#;
11191        let snapshot = serde_json::from_str::<GitSnapshot>(json).unwrap();
11192        assert!(snapshot.worktrees.is_empty());
11193    }
11194
11195    #[test]
11196    fn promptless_resume_round_trips_as_null() {
11197        let request = NodeRequest::Resume {
11198            session: SessionAddress {
11199                workspace_id: WorkspaceId::new("primary").unwrap(),
11200                session: SessionKey {
11201                    instance_id: AgentInstanceId(7),
11202                    generation: SessionGeneration(2),
11203                },
11204            },
11205            terminal_size: TerminalSize { rows: 24, columns: 80 },
11206            initial_prompt: None,
11207        };
11208        let json = serde_json::to_string(&request).unwrap();
11209        assert!(json.contains(r#""initial_prompt":null"#));
11210        assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
11211    }
11212
11213    #[test]
11214    fn node_and_workspace_ids_are_bounded_validated_wire_values() {
11215        assert_eq!(NodeId::new("node-1").unwrap().as_str(), "node-1");
11216        assert_eq!(WorkspaceId::new("repo_main").unwrap().as_str(), "repo_main");
11217        assert!(NodeId::new("Node-1").is_err());
11218        assert!(WorkspaceId::new("-repo").is_err());
11219        assert!(WorkspaceId::new("x".repeat(MAX_NODE_IDENTIFIER_BYTES + 1)).is_err());
11220
11221        let encoded = serde_json::to_string(&WorkspaceId::new("repo-1").unwrap()).unwrap();
11222        assert_eq!(encoded, "\"repo-1\"");
11223        assert!(serde_json::from_str::<WorkspaceId>("\"Repo-1\"").is_err());
11224    }
11225
11226    #[test]
11227    fn durable_session_wire_contract_round_trips() {
11228        assert_eq!(MAX_SESSION_DISPLAY_NAME_BYTES, 256);
11229        let record = ManagedSessionRecord {
11230            record_id: SessionRecordId::new("session-001").unwrap(),
11231            display_name: "release shepherd".to_owned(),
11232            provider: agent("claude"),
11233            mode: SessionMode::Pty,
11234            state: ManagedSessionState::Dormant,
11235            workspace_id: WorkspaceId::new("primary").unwrap(),
11236            canonical_root: host_path(r"C:\repo"),
11237            provider_session: Some(ProviderSessionIdentity {
11238                key: gate4agent_types::ProviderSessionKey::SessionId,
11239                id: "b1ef3250-47a2-42ca-9076-cc241487ea22".to_owned(),
11240                transcript_path: Some(r"C:\provider\sessions\b1ef3250.jsonl".to_owned()),
11241            }),
11242            active_session: None,
11243            environment_profile: None,
11244            bundle: None,
11245            context_id: None,
11246            context: None,
11247            exported_context: None,
11248            task_binding: None,
11249            created_at_unix_ms: 1_723_000_000_000,
11250            updated_at_unix_ms: 1_723_000_000_123,
11251            last_error: None,
11252        };
11253        assert!(!serde_json::to_string(&record)
11254            .unwrap()
11255            .contains("environment_profile"));
11256
11257        let native_selection = NativeSessionSelection {
11258            route: NativeSessionCatalogRoute::workspace(
11259                record.workspace_id.clone(),
11260                record.provider.clone(),
11261            ),
11262            catalog_revision: 7,
11263            recent_cutoff_unix_ms: 8,
11264            selection_id: "hist_selection_1".to_owned(),
11265        };
11266
11267        let requests = [
11268            NodeRequest::IndexProviderSession {
11269                workspace_id: record.workspace_id.clone(),
11270                provider: record.provider.clone(),
11271                identity: ProviderSessionIdentity {
11272                    key: gate4agent_types::ProviderSessionKey::SessionId,
11273                    id: "b1ef3250-47a2-42ca-9076-cc241487ea22".to_owned(),
11274                    transcript_path: None,
11275                },
11276                display_name: "release shepherd".to_owned(),
11277            },
11278            NodeRequest::IndexNativeSession {
11279                selection: native_selection.clone(),
11280                display_name: "release shepherd".to_owned(),
11281            },
11282            NodeRequest::RenameSessionRecord {
11283                record_id: record.record_id.clone(),
11284                display_name: "release verification".to_owned(),
11285            },
11286            NodeRequest::ResumeSessionRecord {
11287                record_id: record.record_id.clone(),
11288                terminal_size: TerminalSize { rows: 40, columns: 120 },
11289                initial_prompt: None,
11290            },
11291            NodeRequest::ForgetSessionRecord {
11292                record_id: record.record_id.clone(),
11293            },
11294        ];
11295        for request in requests {
11296            let json = serde_json::to_string(&request).unwrap();
11297            assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
11298            if matches!(request, NodeRequest::IndexProviderSession { .. }) {
11299                assert_eq!(
11300                    request.required_capability(),
11301                    Some(NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY),
11302                );
11303            }
11304            if matches!(request, NodeRequest::IndexNativeSession { .. }) {
11305                assert_eq!(
11306                    request.required_capability(),
11307                    Some(NODE_NATIVE_SESSION_INDEX_CAPABILITY),
11308                );
11309            }
11310        }
11311
11312        let native_indexed = NodeResponse::NativeSessionIndexed {
11313            selection: native_selection,
11314            record: record.clone(),
11315        };
11316        assert!(native_indexed.requires_native_session_index_capability());
11317        assert!(native_indexed.native_session_index_contract_is_valid());
11318        let responses = [
11319            NodeResponse::ProviderSessionIndexed {
11320                record: record.clone(),
11321            },
11322            native_indexed,
11323            NodeResponse::SessionRecordUpdated {
11324                record: record.clone(),
11325            },
11326            NodeResponse::SessionRecordResumed {
11327                record: record.clone(),
11328                session: SessionAddress {
11329                    workspace_id: record.workspace_id.clone(),
11330                    session: SessionKey {
11331                        instance_id: AgentInstanceId(8),
11332                        generation: SessionGeneration(2),
11333                    },
11334                },
11335            },
11336            NodeResponse::SessionRecordForgotten {
11337                record_id: record.record_id.clone(),
11338            },
11339        ];
11340        for response in responses {
11341            let json = serde_json::to_string(&response).unwrap();
11342            assert_eq!(serde_json::from_str::<NodeResponse>(&json).unwrap(), response);
11343        }
11344
11345        let events = [
11346            NodeEvent::SessionRecordUpserted {
11347                record: record.clone(),
11348            },
11349            NodeEvent::SessionRecordRemoved {
11350                record_id: record.record_id.clone(),
11351            },
11352        ];
11353        for event in events {
11354            let json = serde_json::to_string(&event).unwrap();
11355            assert_eq!(serde_json::from_str::<NodeEvent>(&json).unwrap(), event);
11356        }
11357    }
11358
11359    #[test]
11360    fn session_record_ids_are_bounded_validated_wire_values() {
11361        let record_id = SessionRecordId::new("01j4k0jta3eynt5kxef132kr39").unwrap();
11362        assert_eq!(record_id.as_str(), "01j4k0jta3eynt5kxef132kr39");
11363        assert!(SessionRecordId::new("").is_err());
11364        assert!(SessionRecordId::new("Session-1").is_err());
11365        assert!(SessionRecordId::new("-session-1").is_err());
11366        assert!(SessionRecordId::new("x".repeat(MAX_NODE_IDENTIFIER_BYTES + 1)).is_err());
11367
11368        let json = serde_json::to_string(&record_id).unwrap();
11369        assert_eq!(serde_json::from_str::<SessionRecordId>(&json).unwrap(), record_id);
11370        assert!(serde_json::from_str::<SessionRecordId>("\"Session-1\"").is_err());
11371    }
11372
11373    #[test]
11374    fn node_snapshot_defaults_managed_sessions_for_legacy_wire_payloads() {
11375        let legacy = r#"{"node_id":"fixture-node","enabled_providers":[],"workspaces":[]}"#;
11376        let snapshot = serde_json::from_str::<NodeSnapshot>(legacy).unwrap();
11377        assert!(snapshot.session_records.is_empty());
11378    }
11379
11380    fn delivery_manifest() -> DeliveryBundleManifestV2 {
11381        DeliveryBundleManifestV2 {
11382            bundle_id: SpawnBundleId::new("review-bundle").unwrap(),
11383            revision: SpawnBundleRevision::new("review-bundle.r2").unwrap(),
11384            bundle_digest: SpawnBundleDigest::new(format!("sha256:{}", "a".repeat(64)))
11385                .unwrap(),
11386            manifest_digest: DeliveryManifestDigestV2::new(format!(
11387                "sha256:{}",
11388                "b".repeat(64),
11389            ))
11390            .unwrap(),
11391            components: vec![DeliveryComponentV2 {
11392                kind: DeliveryComponentKindV2::AgentDefinition,
11393                scope: DeliveryScopeV2::Workspace,
11394                relative_path: DeliveryRelativePathV2::new("agents/reviewer.md").unwrap(),
11395                blob: DeliveryBlobReceiptV1::new(
11396                    DeliveryBlobDigestV1::new(format!("sha256:{}", "c".repeat(64))).unwrap(),
11397                    12,
11398                )
11399                .unwrap(),
11400            }],
11401        }
11402    }
11403
11404    #[test]
11405    fn delivery_wire_serialization_is_exact() {
11406        let begin = NodeRequest::BeginDeliveryStage {
11407            manifest: delivery_manifest(),
11408        };
11409        assert_eq!(
11410            serde_json::to_string(&begin).unwrap(),
11411            format!(
11412                r#"{{"kind":"begin-delivery-stage","manifest":{{"bundle_id":"review-bundle","revision":"review-bundle.r2","bundle_digest":"sha256:{}","manifest_digest":"sha256:{}","components":[{{"kind":"agent-definition","scope":"workspace","relative_path":"agents/reviewer.md","blob":{{"digest":"sha256:{}","byte_len":12}}}}]}}}}"#,
11413                "a".repeat(64),
11414                "b".repeat(64),
11415                "c".repeat(64),
11416            ),
11417        );
11418
11419        let chunk = NodeRequest::PutDeliveryBlobChunk {
11420            stage_id: DeliveryStageId::new(format!(
11421                "delivery-stage-{}",
11422                "1".repeat(32),
11423            ))
11424            .unwrap(),
11425            blob_digest: DeliveryBlobDigestV1::new(format!("sha256:{}", "c".repeat(64)))
11426                .unwrap(),
11427            offset: 0,
11428            chunk_hex: DeliveryBlobChunkHexV1::new("00ff").unwrap(),
11429        };
11430        assert_eq!(
11431            serde_json::to_string(&chunk).unwrap(),
11432            format!(
11433                r#"{{"kind":"put-delivery-blob-chunk","stage_id":"delivery-stage-{}","blob_digest":"sha256:{}","offset":0,"chunk_hex":"00ff"}}"#,
11434                "1".repeat(32),
11435                "c".repeat(64),
11436            ),
11437        );
11438
11439        let committed = NodeResponse::DeliveryCommitted {
11440            receipt: DeliveryCommitReceiptV1 {
11441                bundle_id: SpawnBundleId::new("review-bundle").unwrap(),
11442                revision: SpawnBundleRevision::new("review-bundle.r2").unwrap(),
11443                bundle_digest: SpawnBundleDigest::new(format!("sha256:{}", "a".repeat(64)))
11444                    .unwrap(),
11445                manifest_digest: DeliveryManifestDigestV2::new(format!(
11446                    "sha256:{}",
11447                    "b".repeat(64),
11448                ))
11449                .unwrap(),
11450                blobs: vec![DeliveryBlobReceiptV1::new(
11451                    DeliveryBlobDigestV1::new(format!("sha256:{}", "c".repeat(64))).unwrap(),
11452                    12,
11453                )
11454                .unwrap()],
11455            },
11456        };
11457        assert_eq!(
11458            serde_json::from_str::<NodeResponse>(&serde_json::to_string(&committed).unwrap())
11459                .unwrap(),
11460            committed,
11461        );
11462    }
11463
11464    #[test]
11465    fn delivery_wire_rejects_bounds_order_and_case_fold_collisions() {
11466        assert!(DeliveryRelativePathV2::new("a".repeat(MAX_DELIVERY_RELATIVE_PATH_BYTES))
11467            .is_ok());
11468        assert!(DeliveryRelativePathV2::new("a".repeat(MAX_DELIVERY_RELATIVE_PATH_BYTES + 1))
11469            .is_err());
11470        for invalid in [
11471            "CON",
11472            "con.txt",
11473            "PRN.md",
11474            "AUX",
11475            "NUL.json",
11476            "COM1.txt",
11477            "LPT9",
11478            "trailing.",
11479            "trailing ",
11480            "bad<name",
11481            "bad>name",
11482            "bad\"name",
11483            "bad|name",
11484            "bad?name",
11485            "bad*name",
11486        ] {
11487            assert!(DeliveryRelativePathV2::new(invalid).is_err(), "{invalid}");
11488        }
11489        assert!(DeliveryBlobChunkHexV1::new("00".repeat(MAX_DELIVERY_CHUNK_RAW_BYTES)).is_ok());
11490        assert!(DeliveryBlobChunkHexV1::new("00".repeat(MAX_DELIVERY_CHUNK_RAW_BYTES + 1))
11491            .is_err());
11492        assert!(DeliveryBlobChunkHexV1::new("AA").is_err());
11493        assert!(DeliveryBlobChunkHexV1::new("").is_err());
11494
11495        let mut manifest = delivery_manifest();
11496        let mut collision = manifest.components[0].clone();
11497        collision.relative_path = DeliveryRelativePathV2::new("AGENTS/REVIEWER.MD").unwrap();
11498        manifest.components.push(collision);
11499        manifest.components.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
11500        let json = serde_json::to_string(&manifest).unwrap();
11501        assert!(serde_json::from_str::<DeliveryBundleManifestV2>(&json).is_err());
11502
11503        let mut over_total = delivery_manifest();
11504        over_total.components.clear();
11505        for index in 0..=MAX_DELIVERY_TOTAL_BYTES / MAX_DELIVERY_FILE_BYTES {
11506            over_total.components.push(DeliveryComponentV2 {
11507                kind: DeliveryComponentKindV2::File,
11508                scope: DeliveryScopeV2::Workspace,
11509                relative_path: DeliveryRelativePathV2::new(format!("file-{index:03}.txt"))
11510                    .unwrap(),
11511                blob: DeliveryBlobReceiptV1::new(
11512                    DeliveryBlobDigestV1::new(format!(
11513                        "sha256:{index:064x}",
11514                    ))
11515                    .unwrap(),
11516                    MAX_DELIVERY_FILE_BYTES as u64,
11517                )
11518                .unwrap(),
11519            });
11520        }
11521        assert!(over_total.validate().is_err());
11522        assert!(DeliveryBlobReceiptV1::new(
11523            DeliveryBlobDigestV1::new(format!("sha256:{}", "d".repeat(64))).unwrap(),
11524            MAX_DELIVERY_FILE_BYTES as u64 + 1,
11525        )
11526        .is_err());
11527
11528        let mut too_many = delivery_manifest();
11529        too_many.components = (0..=MAX_DELIVERY_FILES)
11530            .map(|index| DeliveryComponentV2 {
11531                kind: DeliveryComponentKindV2::File,
11532                scope: DeliveryScopeV2::Workspace,
11533                relative_path: DeliveryRelativePathV2::new(format!("file-{index:03}.txt"))
11534                    .unwrap(),
11535                blob: DeliveryBlobReceiptV1::new(
11536                    DeliveryBlobDigestV1::new(format!("sha256:{index:064x}")).unwrap(),
11537                    0,
11538                )
11539                .unwrap(),
11540            })
11541            .collect();
11542        assert!(serde_json::from_str::<DeliveryBundleManifestV2>(
11543            &serde_json::to_string(&too_many).unwrap(),
11544        )
11545        .is_err());
11546
11547        let digest_a = format!("sha256:{}", "a".repeat(64));
11548        let digest_b = format!("sha256:{}", "b".repeat(64));
11549        let unsorted = format!(
11550            r#"{{"kind":"delivery-stage-begun","stage_id":"delivery-stage-{}","manifest_digest":"{}","missing_blobs":["{}","{}"]}}"#,
11551            "1".repeat(32),
11552            digest_a,
11553            digest_b,
11554            digest_a,
11555        );
11556        assert!(serde_json::from_str::<NodeResponse>(&unsorted).is_err());
11557    }
11558
11559    /// `NodeRequest::is_replay_safe` covers every variant with no `_` arm
11560    /// (enforced at compile time, not by this test), so this only has to
11561    /// spot-check representative members of each side: pure reads and the
11562    /// harness-MCP read-proxy reply on the `true` side; a lifecycle
11563    /// transition, a spawn, an export, and the request-less `Shutdown` on
11564    /// the `false` side.
11565    #[test]
11566    fn replay_safety_predicate_admits_only_reads_and_the_harness_mcp_reply() {
11567        assert!(NodeRequest::Snapshot.is_replay_safe());
11568        assert!(NodeRequest::Resync { after_sequence: 0 }.is_replay_safe());
11569        assert!(NodeRequest::ReadWorkspaceFile {
11570            workspace_id: WorkspaceId::new("primary").unwrap(),
11571            path: repository_path("README.md"),
11572        }
11573        .is_replay_safe());
11574        assert!(NodeRequest::ReadContextPack {
11575            digest: SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).unwrap(),
11576        }
11577        .is_replay_safe());
11578        assert!(NodeRequest::PutHarnessMcpReplyChunk {
11579            reservation_id: HarnessMcpReservationId::new(format!(
11580                "hmcpres_{}", "a".repeat(24),
11581            )).unwrap(),
11582            activation_digest: HarnessMcpActivationDigest::new(format!(
11583                "sha256:{}", "b".repeat(64),
11584            )).unwrap(),
11585            record_id: SessionRecordId::new("record-a").unwrap(),
11586            session: session_address("primary", 1),
11587            call_id: HarnessMcpCallId::new(format!("hmcpcall_{}", "c".repeat(24))).unwrap(),
11588            offset: 0,
11589            final_chunk: true,
11590            chunk_hex: HarnessMcpReplyChunkHexV1::new("00".repeat(4)).unwrap(),
11591        }
11592        .is_replay_safe());
11593
11594        assert!(!NodeRequest::Shutdown.is_replay_safe());
11595        assert!(!NodeRequest::AcquireController { lease_ms: 1_000 }.is_replay_safe());
11596        assert!(!NodeRequest::ActivateHarnessMcpReservation {
11597            reservation_id: HarnessMcpReservationId::new(format!(
11598                "hmcpres_{}", "a".repeat(24),
11599            )).unwrap(),
11600            activation_digest: HarnessMcpActivationDigest::new(format!(
11601                "sha256:{}", "b".repeat(64),
11602            )).unwrap(),
11603            record_id: SessionRecordId::new("record-a").unwrap(),
11604            session: session_address("primary", 1),
11605        }
11606        .is_replay_safe());
11607        assert!(!NodeRequest::ExportContextPack { session: session_address("primary", 1) }
11608            .is_replay_safe());
11609    }
11610}