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/// Launch options for [`NodeRequest::SpawnWithOptions`]. The node maps them to the
5892/// provider CLI's own flags; a provider without a mapping refuses the spawn.
5893#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
5894#[serde(deny_unknown_fields)]
5895pub struct SpawnSessionOptions {
5896    #[serde(default, skip_serializing_if = "Option::is_none")]
5897    pub model: Option<String>,
5898    #[serde(default, skip_serializing_if = "Option::is_none")]
5899    pub effort: Option<String>,
5900    /// `human` | `agent` — recorded in the node's session registry.
5901    #[serde(default, skip_serializing_if = "Option::is_none")]
5902    pub origin: Option<String>,
5903    /// Parent session signature (e.g. `claude#3`) when spawned by another chat.
5904    #[serde(default, skip_serializing_if = "Option::is_none")]
5905    pub parent_session_id: Option<String>,
5906}
5907
5908impl SpawnSessionOptions {
5909    pub const MAX_BYTES: usize = 128;
5910
5911    /// Plain identifiers only (letters, digits, `-_.:/[]`), bounded.
5912    pub fn validate(&self) -> Result<(), &'static str> {
5913        let ok = |v: &str| {
5914            !v.is_empty()
5915                && v.len() <= Self::MAX_BYTES
5916                && v.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.:/[]".contains(&b))
5917        };
5918        if self.model.as_deref().is_some_and(|m| !ok(m)) {
5919            return Err("spawn model is invalid");
5920        }
5921        if self.effort.as_deref().is_some_and(|e| !ok(e)) {
5922            return Err("spawn effort is invalid");
5923        }
5924        if let Some(o) = self.origin.as_deref() {
5925            if o != "human" && o != "agent" {
5926                return Err("spawn origin must be human or agent");
5927            }
5928        }
5929        if let Some(p) = self.parent_session_id.as_deref() {
5930            let parent_ok = !p.is_empty()
5931                && p.len() <= Self::MAX_BYTES
5932                && p.bytes().all(|b| b.is_ascii_alphanumeric() || b"#-_.:/".contains(&b));
5933            if !parent_ok {
5934                return Err("spawn parent_session_id is invalid");
5935            }
5936        }
5937        Ok(())
5938    }
5939}
5940
5941#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5942#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5943pub enum NodeRequest {
5944    Snapshot,
5945    Resync { after_sequence: u64 },
5946    ArmHarnessMcpReservation {
5947        reservation_id: HarnessMcpReservationId,
5948        activation_digest: HarnessMcpActivationDigest,
5949        spawn_spec: SpawnSpec,
5950        launch: HarnessMcpLaunchV1,
5951        expires_at_unix_ms: u64,
5952    },
5953    SpawnSpecWithHarnessMcp {
5954        reservation_id: HarnessMcpReservationId,
5955        activation_digest: HarnessMcpActivationDigest,
5956        spec: SpawnSpec,
5957        deadline_unix_ms: u64,
5958    },
5959    ActivateHarnessMcpReservation {
5960        reservation_id: HarnessMcpReservationId,
5961        activation_digest: HarnessMcpActivationDigest,
5962        record_id: SessionRecordId,
5963        session: SessionAddress,
5964    },
5965    AbortHarnessMcpReservation {
5966        reservation_id: HarnessMcpReservationId,
5967        activation_digest: HarnessMcpActivationDigest,
5968    },
5969    PutHarnessMcpReplyChunk {
5970        reservation_id: HarnessMcpReservationId,
5971        activation_digest: HarnessMcpActivationDigest,
5972        record_id: SessionRecordId,
5973        session: SessionAddress,
5974        call_id: HarnessMcpCallId,
5975        offset: u32,
5976        final_chunk: bool,
5977        chunk_hex: HarnessMcpReplyChunkHexV1,
5978    },
5979    RejectHarnessMcpCall {
5980        reservation_id: HarnessMcpReservationId,
5981        activation_digest: HarnessMcpActivationDigest,
5982        record_id: SessionRecordId,
5983        session: SessionAddress,
5984        call_id: HarnessMcpCallId,
5985        reason: HarnessMcpRejectReasonV1,
5986    },
5987    BeginDeliveryStage {
5988        manifest: DeliveryBundleManifestV2,
5989    },
5990    PutDeliveryBlobChunk {
5991        stage_id: DeliveryStageId,
5992        blob_digest: DeliveryBlobDigestV1,
5993        offset: u64,
5994        chunk_hex: DeliveryBlobChunkHexV1,
5995    },
5996    CommitDeliveryStage {
5997        stage_id: DeliveryStageId,
5998    },
5999    AbortDeliveryStage {
6000        stage_id: DeliveryStageId,
6001    },
6002    BrowseHostDirectories {
6003        directory: Option<OpaqueHostPath>,
6004        after: Option<OpaqueHostPath>,
6005    },
6006    InspectWorkspace { workspace_id: WorkspaceId },
6007    ReadWorkspaceFile {
6008        workspace_id: WorkspaceId,
6009        path: RepositoryPath,
6010    },
6011    WriteWorkspaceFile {
6012        workspace_id: WorkspaceId,
6013        path: RepositoryPath,
6014        expected_revision: WorkspaceFileRevision,
6015        #[serde(deserialize_with = "deserialize_workspace_file_text")]
6016        text: String,
6017    },
6018    CreateWorkspaceFile {
6019        workspace_id: WorkspaceId,
6020        path: RepositoryPath,
6021    },
6022    CreateWorkspaceDirectory {
6023        workspace_id: WorkspaceId,
6024        path: RepositoryPath,
6025    },
6026    ReadGitHistory {
6027        workspace_id: WorkspaceId,
6028        #[serde(default, skip_serializing_if = "Option::is_none")]
6029        path: Option<RepositoryPath>,
6030        before: Option<GitObjectId>,
6031        #[serde(deserialize_with = "deserialize_git_history_limit")]
6032        limit: u16,
6033    },
6034    ReadGitDiff {
6035        workspace_id: WorkspaceId,
6036        request: GitDiffRequest,
6037    },
6038    AcquireController { lease_ms: u64 },
6039    ReleaseController,
6040    RegisterWorkspace {
6041        workspace_id: WorkspaceId,
6042        root: OpaqueHostPath,
6043    },
6044    CreateStandaloneWorkspace {
6045        workspace_id: WorkspaceId,
6046        root: OpaqueHostPath,
6047        #[serde(default, deserialize_with = "deserialize_optional_initial_branch")]
6048        initial_branch: Option<String>,
6049    },
6050    UnregisterWorkspace {
6051        workspace_id: WorkspaceId,
6052    },
6053    CreateWorktree {
6054        source_workspace_id: WorkspaceId,
6055        workspace_id: WorkspaceId,
6056        target_root: OpaqueHostPath,
6057        branch: String,
6058        base: Option<String>,
6059    },
6060    RemoveWorktree {
6061        source_workspace_id: WorkspaceId,
6062        target_root: OpaqueHostPath,
6063    },
6064    Spawn {
6065        workspace_id: WorkspaceId,
6066        provider: AgentId,
6067        mode: SessionMode,
6068        terminal_size: TerminalSize,
6069        initial_prompt: Option<String>,
6070    },
6071    /// `Spawn` plus launch options (model / reasoning effort). Separate variant so the plain
6072    /// `Spawn` wire shape stays unchanged for older peers.
6073    SpawnWithOptions {
6074        workspace_id: WorkspaceId,
6075        provider: AgentId,
6076        mode: SessionMode,
6077        terminal_size: TerminalSize,
6078        initial_prompt: Option<String>,
6079        #[serde(default)]
6080        options: SpawnSessionOptions,
6081    },
6082    SpawnSpec {
6083        spec: SpawnSpec,
6084    },
6085    SpawnManagedWorktree {
6086        request: ManagedWorktreeSpawnRequest,
6087    },
6088    SpawnManagedWorktreeV2 {
6089        request: ManagedWorktreeSpawnRequestV2,
6090    },
6091    CleanupManagedWorktree {
6092        lease_id: ManagedWorktreeLeaseId,
6093    },
6094    Resume {
6095        session: SessionAddress,
6096        terminal_size: TerminalSize,
6097        initial_prompt: Option<String>,
6098    },
6099    RenameSessionRecord {
6100        record_id: SessionRecordId,
6101        display_name: String,
6102    },
6103    SetSessionTask {
6104        record_id: SessionRecordId,
6105        expected_revision: u64,
6106        target: SessionTaskTargetV1,
6107    },
6108    IndexProviderSession {
6109        workspace_id: WorkspaceId,
6110        provider: AgentId,
6111        identity: ProviderSessionIdentity,
6112        display_name: String,
6113    },
6114    IndexNativeSession {
6115        selection: NativeSessionSelection,
6116        display_name: String,
6117    },
6118    ResumeSessionRecord {
6119        record_id: SessionRecordId,
6120        terminal_size: TerminalSize,
6121        initial_prompt: Option<String>,
6122    },
6123    ForgetSessionRecord {
6124        record_id: SessionRecordId,
6125    },
6126    CatalogNativeSessions {
6127        route: NativeSessionCatalogRoute,
6128        #[serde(deserialize_with = "deserialize_native_session_catalog_limit")]
6129        limit: u16,
6130    },
6131    PageNativeSessions {
6132        route: NativeSessionCatalogRoute,
6133        window: NativeSessionCatalogWindow,
6134        catalog_revision: u64,
6135        recent_cutoff_unix_ms: u64,
6136        #[serde(default, deserialize_with = "deserialize_optional_history_candidate_id")]
6137        after_selection_id: Option<String>,
6138        #[serde(deserialize_with = "deserialize_native_session_catalog_limit")]
6139        limit: u16,
6140    },
6141    PreviewNativeSession {
6142        selection: NativeSessionSelection,
6143        #[serde(deserialize_with = "deserialize_native_session_preview_limit")]
6144        message_limit: u16,
6145    },
6146    PreviewSessionRecord {
6147        record_id: SessionRecordId,
6148        #[serde(deserialize_with = "deserialize_native_session_preview_limit")]
6149        message_limit: u16,
6150    },
6151    DiscoverHistory {
6152        session: SessionAddress,
6153        #[serde(deserialize_with = "deserialize_history_discovery_limit")]
6154        limit: u16,
6155    },
6156    LoadHistory {
6157        session: SessionAddress,
6158        #[serde(deserialize_with = "deserialize_history_candidate_id")]
6159        candidate_id: String,
6160    },
6161    ExportContextPackForSessionRecord {
6162        record_id: SessionRecordId,
6163        session: SessionAddress,
6164    },
6165    ExportContextPack {
6166        session: SessionAddress,
6167    },
6168    ForgetContextPack {
6169        context_id: SpawnContextId,
6170    },
6171    ResolveDurableContextPack {
6172        context_id: SpawnContextId,
6173    },
6174    ReadContextPack {
6175        digest: SpawnContextDigest,
6176    },
6177    Prompt { session: SessionAddress, text: String },
6178    Paste { session: SessionAddress, text: String },
6179    Input { session: SessionAddress, text: String },
6180    TerminalBytes { session: SessionAddress, bytes: Vec<u8> },
6181    TerminalControl { session: SessionAddress, control: TerminalControl },
6182    Resize { session: SessionAddress, size: TerminalSize },
6183    Interrupt { session: SessionAddress },
6184    Stop { session: SessionAddress, force: bool },
6185    Remove { session: SessionAddress },
6186    /// Answers the `correlation_id` an `AgentStreamChunkKindV1::InteractionPrompt`
6187    /// carries (minted by `correlation::interaction_correlation`).
6188    /// `response` must additionally match the interaction's own kind
6189    /// (`ProviderInteractionResponse::validate_for`); that check needs the
6190    /// live interaction and is the routing layer's job, not this wire's.
6191    ResolveInteraction {
6192        session: SessionAddress,
6193        #[serde(deserialize_with = "deserialize_acp_correlation_id")]
6194        correlation_id: String,
6195        response: ProviderInteractionResponse,
6196    },
6197    SetSessionMode {
6198        session: SessionAddress,
6199        #[serde(deserialize_with = "deserialize_acp_control_id")]
6200        mode_id: String,
6201    },
6202    SetSessionConfigOption {
6203        session: SessionAddress,
6204        #[serde(deserialize_with = "deserialize_acp_control_id")]
6205        option_id: String,
6206        #[serde(deserialize_with = "deserialize_acp_config_value_json")]
6207        value_json: String,
6208    },
6209    SetSessionModel {
6210        session: SessionAddress,
6211        #[serde(deserialize_with = "deserialize_acp_control_id")]
6212        model_id: String,
6213    },
6214    Shutdown,
6215}
6216
6217fn deserialize_workspace_file_text<'de, D>(deserializer: D) -> Result<String, D::Error>
6218where
6219    D: Deserializer<'de>,
6220{
6221    let text = String::deserialize(deserializer)?;
6222    if text.len() > MAX_WORKSPACE_FILE_BYTES {
6223        return Err(serde::de::Error::custom("workspace file text exceeds the byte limit"));
6224    }
6225    Ok(text)
6226}
6227
6228fn deserialize_git_history_limit<'de, D>(deserializer: D) -> Result<u16, D::Error>
6229where
6230    D: Deserializer<'de>,
6231{
6232    let limit = u16::deserialize(deserializer)?;
6233    if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
6234        return Err(serde::de::Error::custom("git history limit is invalid"));
6235    }
6236    Ok(limit)
6237}
6238
6239fn deserialize_optional_initial_branch<'de, D>(
6240    deserializer: D,
6241) -> Result<Option<String>, D::Error>
6242where
6243    D: Deserializer<'de>,
6244{
6245    let branch = Option::<String>::deserialize(deserializer)?;
6246    if let Some(branch) = branch.as_deref() {
6247        if branch.is_empty()
6248            || branch.len() > MAX_REPOSITORY_PATH_BYTES
6249            || branch.starts_with('-')
6250            || branch.chars().any(char::is_control)
6251        {
6252            return Err(serde::de::Error::custom(
6253                "initial branch must be bounded, non-empty, option-safe, and free of control characters",
6254            ));
6255        }
6256    }
6257    Ok(branch)
6258}
6259
6260/// A control character outside the three ACP content is allowed to carry
6261/// verbatim (`\n`, `\r`, `\t`) -- mirrors
6262/// `gate4agent_types::control::validate_text`'s own allowance so free text
6263/// round-tripped through `AgentStreamChunkV1` and `SetSessionConfigOption`
6264/// validates against the same rule the provider event stream already
6265/// applies to it.
6266fn contains_unsafe_control_bytes(value: &str) -> bool {
6267    value
6268        .chars()
6269        .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
6270}
6271
6272fn deserialize_acp_correlation_id<'de, D>(deserializer: D) -> Result<String, D::Error>
6273where
6274    D: Deserializer<'de>,
6275{
6276    let value = String::deserialize(deserializer)?;
6277    if value.is_empty()
6278        || value.len() > MAX_ACP_CORRELATION_ID_BYTES
6279        || value.chars().any(char::is_control)
6280    {
6281        return Err(serde::de::Error::custom(
6282            "interaction correlation id must be non-empty, bounded, and free of control characters",
6283        ));
6284    }
6285    Ok(value)
6286}
6287
6288fn deserialize_acp_control_id<'de, D>(deserializer: D) -> Result<String, D::Error>
6289where
6290    D: Deserializer<'de>,
6291{
6292    let value = String::deserialize(deserializer)?;
6293    if value.is_empty()
6294        || value.len() > MAX_ACP_CONTROL_ID_BYTES
6295        || value.chars().any(char::is_control)
6296    {
6297        return Err(serde::de::Error::custom(
6298            "acp control id must be non-empty, bounded, and free of control characters",
6299        ));
6300    }
6301    Ok(value)
6302}
6303
6304fn deserialize_acp_config_value_json<'de, D>(deserializer: D) -> Result<String, D::Error>
6305where
6306    D: Deserializer<'de>,
6307{
6308    let value = String::deserialize(deserializer)?;
6309    if value.is_empty()
6310        || value.len() > MAX_ACP_CONTROL_TEXT_BYTES
6311        || contains_unsafe_control_bytes(&value)
6312    {
6313        return Err(serde::de::Error::custom(
6314            "acp config option value must be non-empty, bounded, and free of unsafe control characters",
6315        ));
6316    }
6317    // The field is named `value_json` and its only consumer parses it into a
6318    // `serde_json::Value` to hand to `session/set_config_option`. A string
6319    // that is merely bounded and printable would cross this wire intact and
6320    // fail deep inside the node, one process and several layers away from the
6321    // sender that could have said what was wrong with it. Refuse it here,
6322    // where the offending input is still in hand.
6323    if let Err(error) = serde_json::from_str::<serde_json::Value>(&value) {
6324        return Err(serde::de::Error::custom(format!(
6325            "acp config option value must be valid JSON: {error}"
6326        )));
6327    }
6328    Ok(value)
6329}
6330
6331impl NodeRequest {
6332    pub fn harness_mcp_contract_is_valid_at(&self, now_unix_ms: u64) -> bool {
6333        match self {
6334            Self::ArmHarnessMcpReservation { expires_at_unix_ms, launch, .. } => {
6335                launch.validate().is_ok()
6336                    && *expires_at_unix_ms > now_unix_ms
6337                    && expires_at_unix_ms.saturating_sub(now_unix_ms)
6338                        <= MAX_HARNESS_MCP_RESERVATION_TTL_MS
6339            }
6340            Self::SpawnSpecWithHarnessMcp { deadline_unix_ms, .. } => {
6341                *deadline_unix_ms > now_unix_ms
6342                    && deadline_unix_ms.saturating_sub(now_unix_ms)
6343                        <= MAX_HARNESS_MCP_SPAWN_RELAY_DEADLINE_MS
6344            }
6345            Self::PutHarnessMcpReplyChunk { offset, chunk_hex, .. } => {
6346                usize::try_from(*offset).ok()
6347                    .and_then(|offset| offset.checked_add(chunk_hex.raw_len()))
6348                    .is_some_and(|end| end <= MAX_HARNESS_MCP_AGGREGATE_REPLY_BYTES)
6349            }
6350            _ => true,
6351        }
6352    }
6353
6354    pub fn native_session_catalog_contract_is_valid(&self) -> bool {
6355        match self {
6356            Self::CatalogNativeSessions { route, limit } => {
6357                route.validate().is_ok()
6358                    && (1..=NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(limit)
6359            }
6360            Self::PageNativeSessions { route, after_selection_id, limit, .. } => {
6361                route.validate().is_ok()
6362                    && (1..=NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(limit)
6363                    && after_selection_id
6364                        .as_deref()
6365                        .map_or(true, |cursor| {
6366                            gate4agent_types::validate_candidate_id(cursor).is_ok()
6367                        })
6368            }
6369            _ => true,
6370        }
6371    }
6372
6373    pub fn native_session_preview_contract_is_valid(&self) -> bool {
6374        match self {
6375            Self::PreviewNativeSession { selection, message_limit } => {
6376                (1..=NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX).contains(message_limit)
6377                    && selection.validate().is_ok()
6378            }
6379            Self::PreviewSessionRecord { message_limit, .. } => {
6380                (1..=NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX).contains(message_limit)
6381            }
6382            _ => true,
6383        }
6384    }
6385
6386    pub fn history_context_pack_contract_is_valid(&self) -> bool {
6387        match self {
6388            Self::DiscoverHistory { limit, .. } => {
6389                (1..=HISTORY_DISCOVERY_LIMIT_MAX).contains(limit)
6390            }
6391            Self::LoadHistory { candidate_id, .. } => {
6392                gate4agent_types::validate_candidate_id(candidate_id).is_ok()
6393            }
6394            Self::Snapshot
6395            | Self::Resync { .. }
6396            | Self::ArmHarnessMcpReservation { .. }
6397            | Self::SpawnSpecWithHarnessMcp { .. }
6398            | Self::ActivateHarnessMcpReservation { .. }
6399            | Self::AbortHarnessMcpReservation { .. }
6400            | Self::PutHarnessMcpReplyChunk { .. }
6401            | Self::RejectHarnessMcpCall { .. }
6402            | Self::BeginDeliveryStage { .. }
6403            | Self::PutDeliveryBlobChunk { .. }
6404            | Self::CommitDeliveryStage { .. }
6405            | Self::AbortDeliveryStage { .. }
6406            | Self::BrowseHostDirectories { .. }
6407            | Self::InspectWorkspace { .. }
6408            | Self::ReadWorkspaceFile { .. }
6409            | Self::WriteWorkspaceFile { .. }
6410            | Self::CreateWorkspaceFile { .. }
6411            | Self::CreateWorkspaceDirectory { .. }
6412            | Self::ReadGitHistory { .. }
6413            | Self::ReadGitDiff { .. }
6414            | Self::AcquireController { .. }
6415            | Self::ReleaseController
6416            | Self::RegisterWorkspace { .. }
6417            | Self::CreateStandaloneWorkspace { .. }
6418            | Self::UnregisterWorkspace { .. }
6419            | Self::CreateWorktree { .. }
6420            | Self::RemoveWorktree { .. }
6421            | Self::Spawn { .. }
6422            | Self::SpawnWithOptions { .. }
6423            | Self::SpawnSpec { .. }
6424            | Self::SpawnManagedWorktree { .. }
6425            | Self::SpawnManagedWorktreeV2 { .. }
6426            | Self::CleanupManagedWorktree { .. }
6427            | Self::Resume { .. }
6428            | Self::RenameSessionRecord { .. }
6429            | Self::SetSessionTask { .. }
6430            | Self::IndexProviderSession { .. }
6431            | Self::IndexNativeSession { .. }
6432            | Self::ResumeSessionRecord { .. }
6433            | Self::ForgetSessionRecord { .. }
6434            | Self::CatalogNativeSessions { .. }
6435            | Self::PageNativeSessions { .. }
6436            | Self::PreviewNativeSession { .. }
6437            | Self::PreviewSessionRecord { .. }
6438            | Self::ExportContextPackForSessionRecord { .. }
6439            | Self::ExportContextPack { .. }
6440            | Self::ForgetContextPack { .. }
6441            | Self::ResolveDurableContextPack { .. }
6442            | Self::ReadContextPack { .. }
6443            | Self::Prompt { .. }
6444            | Self::Paste { .. }
6445            | Self::Input { .. }
6446            | Self::TerminalBytes { .. }
6447            | Self::TerminalControl { .. }
6448            | Self::Resize { .. }
6449            | Self::Interrupt { .. }
6450            | Self::Stop { .. }
6451            | Self::Remove { .. }
6452            | Self::ResolveInteraction { .. }
6453            | Self::SetSessionMode { .. }
6454            | Self::SetSessionConfigOption { .. }
6455            | Self::SetSessionModel { .. }
6456            | Self::Shutdown => true,
6457        }
6458    }
6459
6460    pub fn required_capability(&self) -> Option<&'static str> {
6461        match self {
6462            Self::ArmHarnessMcpReservation { .. }
6463            | Self::SpawnSpecWithHarnessMcp { .. }
6464            | Self::ActivateHarnessMcpReservation { .. }
6465            | Self::AbortHarnessMcpReservation { .. }
6466            | Self::PutHarnessMcpReplyChunk { .. }
6467            | Self::RejectHarnessMcpCall { .. } => {
6468                Some(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
6469            }
6470            Self::BeginDeliveryStage { .. }
6471            | Self::PutDeliveryBlobChunk { .. }
6472            | Self::CommitDeliveryStage { .. }
6473            | Self::AbortDeliveryStage { .. } => {
6474                Some(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
6475            }
6476            Self::BrowseHostDirectories { .. } => Some(CAPABILITY_HOST_DIRECTORY_BROWSE_V1),
6477            Self::CreateStandaloneWorkspace { .. } => {
6478                Some(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
6479            }
6480            Self::ReadWorkspaceFile { .. } => Some(NODE_WORKSPACE_FILE_READ_CAPABILITY),
6481            Self::WriteWorkspaceFile { .. } => Some(NODE_WORKSPACE_FILE_WRITE_CAPABILITY),
6482            Self::CreateWorkspaceFile { .. } | Self::CreateWorkspaceDirectory { .. } => {
6483                Some(NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY)
6484            }
6485            Self::ReadGitHistory { .. } | Self::ReadGitDiff { .. } => {
6486                Some(NODE_GIT_READ_CAPABILITY)
6487            }
6488            Self::IndexProviderSession { .. } => {
6489                Some(NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY)
6490            }
6491            Self::SetSessionTask { .. } => Some(NODE_SESSION_TASK_CORRELATION_CAPABILITY),
6492            Self::IndexNativeSession { .. } => Some(NODE_NATIVE_SESSION_INDEX_CAPABILITY),
6493            Self::CatalogNativeSessions { .. } => Some(NODE_NATIVE_SESSION_CATALOG_CAPABILITY),
6494            Self::PageNativeSessions { .. } => {
6495                Some(NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY)
6496            }
6497            Self::PreviewNativeSession { .. } => Some(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY),
6498            Self::PreviewSessionRecord { .. } => Some(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY),
6499            Self::SpawnSpec { .. } => Some(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY),
6500            Self::SpawnManagedWorktree { .. }
6501            | Self::CleanupManagedWorktree { .. } => {
6502                Some(NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY)
6503            }
6504            Self::SpawnManagedWorktreeV2 { .. } => {
6505                Some(NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY)
6506            }
6507            Self::ExportContextPackForSessionRecord { .. } => {
6508                Some(NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY)
6509            }
6510            Self::DiscoverHistory { .. }
6511            | Self::LoadHistory { .. }
6512            | Self::ExportContextPack { .. }
6513            | Self::ForgetContextPack { .. }
6514            | Self::ResolveDurableContextPack { .. }
6515            | Self::ReadContextPack { .. } => {
6516                Some(NODE_HISTORY_CONTEXT_PACK_CAPABILITY)
6517            }
6518            Self::ResolveInteraction { .. }
6519            | Self::SetSessionMode { .. }
6520            | Self::SetSessionConfigOption { .. }
6521            | Self::SetSessionModel { .. } => Some(NODE_ACP_CONTROL_CAPABILITY),
6522            Self::Snapshot
6523            | Self::Resync { .. }
6524            | Self::InspectWorkspace { .. }
6525            | Self::AcquireController { .. }
6526            | Self::ReleaseController
6527            | Self::RegisterWorkspace { .. }
6528            | Self::UnregisterWorkspace { .. }
6529            | Self::CreateWorktree { .. }
6530            | Self::RemoveWorktree { .. }
6531            | Self::Spawn { .. }
6532            | Self::SpawnWithOptions { .. }
6533            | Self::Resume { .. }
6534            | Self::RenameSessionRecord { .. }
6535            | Self::ResumeSessionRecord { .. }
6536            | Self::ForgetSessionRecord { .. }
6537            | Self::Prompt { .. }
6538            | Self::Paste { .. }
6539            | Self::Input { .. }
6540            | Self::TerminalBytes { .. }
6541            | Self::TerminalControl { .. }
6542            | Self::Resize { .. }
6543            | Self::Interrupt { .. }
6544            | Self::Stop { .. }
6545            | Self::Remove { .. }
6546            | Self::Shutdown => None,
6547        }
6548    }
6549
6550    pub fn requires_worktree_selection_capability(&self) -> bool {
6551        matches!(self, Self::SpawnSpec { spec } if spec.target.worktree_id.is_some())
6552            || matches!(self,
6553                Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6554                | Self::SpawnSpecWithHarnessMcp { spec, .. }
6555                if spec.target.worktree_id.is_some())
6556            || matches!(
6557                self,
6558                Self::SpawnManagedWorktree { .. } | Self::CleanupManagedWorktree { .. }
6559                    | Self::SpawnManagedWorktreeV2 { .. }
6560            )
6561    }
6562
6563    pub fn requires_spawn_spec_defaults_overrides_capability(&self) -> bool {
6564        matches!(
6565            self,
6566            Self::SpawnSpec { .. }
6567                | Self::SpawnManagedWorktree { .. }
6568                | Self::SpawnManagedWorktreeV2 { .. }
6569                | Self::ArmHarnessMcpReservation { .. }
6570                | Self::SpawnSpecWithHarnessMcp { .. }
6571        )
6572    }
6573
6574    pub fn requires_spawn_profile_revision_capability(&self) -> bool {
6575        matches!(
6576            self,
6577            Self::SpawnSpec { .. }
6578                | Self::SpawnManagedWorktree { .. }
6579                | Self::SpawnManagedWorktreeV2 { .. }
6580                | Self::ArmHarnessMcpReservation { .. }
6581                | Self::SpawnSpecWithHarnessMcp { .. }
6582        )
6583    }
6584
6585    pub fn requires_child_environment_profile_capability(&self) -> bool {
6586        let spec = match self {
6587            Self::SpawnSpec { spec } => spec,
6588            Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6589            | Self::SpawnSpecWithHarnessMcp { spec, .. } => spec,
6590            Self::SpawnManagedWorktree { request } => &request.spawn_spec,
6591            Self::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
6592            _ => return false,
6593        };
6594        matches!(
6595            spec.overrides.environment_profile_id,
6596            SpawnOverride::Set { .. }
6597        )
6598    }
6599
6600
6601    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
6602        let spec = match self {
6603            Self::SpawnSpec { spec } => spec,
6604            Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6605            | Self::SpawnSpecWithHarnessMcp { spec, .. } => spec,
6606            Self::SpawnManagedWorktree { request } => &request.spawn_spec,
6607            Self::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
6608            _ => return false,
6609        };
6610        !matches!(spec.overrides.bundle_id, SpawnOverride::Clear)
6611    }
6612
6613    pub fn requires_history_context_pack_capability(&self) -> bool {
6614        match self {
6615            Self::DiscoverHistory { .. }
6616            | Self::LoadHistory { .. }
6617            | Self::ExportContextPackForSessionRecord { .. }
6618            | Self::ExportContextPack { .. }
6619            | Self::ForgetContextPack { .. } => true,
6620            Self::SpawnSpec { spec } => {
6621                !matches!(spec.overrides.context_id, SpawnOverride::Clear)
6622            }
6623            Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6624            | Self::SpawnSpecWithHarnessMcp { spec, .. } => {
6625                !matches!(spec.overrides.context_id, SpawnOverride::Clear)
6626            }
6627            Self::SpawnManagedWorktree { request } => {
6628                !matches!(request.spawn_spec.overrides.context_id, SpawnOverride::Clear)
6629            }
6630            Self::SpawnManagedWorktreeV2 { request } => {
6631                !matches!(request.spawn_spec.overrides.context_id, SpawnOverride::Clear)
6632            }
6633            _ => false,
6634        }
6635    }
6636
6637    /// Whether a caller may resend this exact request after a physical C2
6638    /// reconnect without risking a double application on the node --
6639    /// `gate4agent_c2_client::reconnect::C2ReconnectingHandle::request_until`
6640    /// is the only caller: it never retries a request this returns `false`
6641    /// for, at any budget, no matter how long the link is down.
6642    ///
6643    /// Conservative by construction: `true` only for a request that is
6644    /// either a pure read (nothing on the node changes, however many times
6645    /// it is sent) or otherwise idempotent by design (the same bytes at the
6646    /// same identity always land the same way, and a duplicate lands on an
6647    /// already-settled node state as a clean no-op/mismatch rather than a
6648    /// second effect) -- `PutHarnessMcpReplyChunk`/`RejectHarnessMcpCall`
6649    /// (the harness-MCP read-proxy call's own terminal reply, keyed by
6650    /// `(reservation_id, activation_digest, call_id, offset)`) are the only
6651    /// non-read members of that second group. Every spawn, every
6652    /// reservation/worktree/workspace lifecycle transition, every session
6653    /// mutation, and every delivery-bundle stage transition returns `false`
6654    /// -- including `PutDeliveryBlobChunk`, which looks offset-keyed like
6655    /// `PutHarnessMcpReplyChunk` but backs a bundle commit this method does
6656    /// not have a mandate to reclassify as retry-safe.
6657    ///
6658    /// Deliberately exhaustive with no `_` arm: a newly added `NodeRequest`
6659    /// variant fails to compile here until someone classifies it, rather
6660    /// than silently inheriting a default in either direction.
6661    pub fn is_replay_safe(&self) -> bool {
6662        match self {
6663            Self::Snapshot
6664            | Self::Resync { .. }
6665            | Self::PutHarnessMcpReplyChunk { .. }
6666            | Self::RejectHarnessMcpCall { .. }
6667            | Self::BrowseHostDirectories { .. }
6668            | Self::InspectWorkspace { .. }
6669            | Self::ReadWorkspaceFile { .. }
6670            | Self::ReadGitHistory { .. }
6671            | Self::ReadGitDiff { .. }
6672            | Self::CatalogNativeSessions { .. }
6673            | Self::PageNativeSessions { .. }
6674            | Self::PreviewNativeSession { .. }
6675            | Self::PreviewSessionRecord { .. }
6676            | Self::DiscoverHistory { .. }
6677            | Self::ResolveDurableContextPack { .. }
6678            | Self::ReadContextPack { .. } => true,
6679            Self::ArmHarnessMcpReservation { .. }
6680            | Self::SpawnSpecWithHarnessMcp { .. }
6681            | Self::ActivateHarnessMcpReservation { .. }
6682            | Self::AbortHarnessMcpReservation { .. }
6683            | Self::BeginDeliveryStage { .. }
6684            | Self::PutDeliveryBlobChunk { .. }
6685            | Self::CommitDeliveryStage { .. }
6686            | Self::AbortDeliveryStage { .. }
6687            | Self::WriteWorkspaceFile { .. }
6688            | Self::CreateWorkspaceFile { .. }
6689            | Self::CreateWorkspaceDirectory { .. }
6690            | Self::AcquireController { .. }
6691            | Self::ReleaseController
6692            | Self::RegisterWorkspace { .. }
6693            | Self::CreateStandaloneWorkspace { .. }
6694            | Self::UnregisterWorkspace { .. }
6695            | Self::CreateWorktree { .. }
6696            | Self::RemoveWorktree { .. }
6697            | Self::Spawn { .. }
6698            | Self::SpawnWithOptions { .. }
6699            | Self::SpawnSpec { .. }
6700            | Self::SpawnManagedWorktree { .. }
6701            | Self::SpawnManagedWorktreeV2 { .. }
6702            | Self::CleanupManagedWorktree { .. }
6703            | Self::Resume { .. }
6704            | Self::RenameSessionRecord { .. }
6705            | Self::SetSessionTask { .. }
6706            | Self::IndexProviderSession { .. }
6707            | Self::IndexNativeSession { .. }
6708            | Self::ResumeSessionRecord { .. }
6709            | Self::ForgetSessionRecord { .. }
6710            | Self::LoadHistory { .. }
6711            | Self::ExportContextPackForSessionRecord { .. }
6712            | Self::ExportContextPack { .. }
6713            | Self::ForgetContextPack { .. }
6714            | Self::Prompt { .. }
6715            | Self::Paste { .. }
6716            | Self::Input { .. }
6717            | Self::TerminalBytes { .. }
6718            | Self::TerminalControl { .. }
6719            | Self::Resize { .. }
6720            | Self::Interrupt { .. }
6721            | Self::Stop { .. }
6722            | Self::Remove { .. }
6723            | Self::ResolveInteraction { .. }
6724            | Self::SetSessionMode { .. }
6725            | Self::SetSessionConfigOption { .. }
6726            | Self::SetSessionModel { .. }
6727            | Self::Shutdown => false,
6728        }
6729    }
6730}
6731
6732#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6733pub struct ResponseEnvelope {
6734    pub request_id: u64,
6735    pub result: Result<NodeResponse, NodeFailure>,
6736}
6737
6738#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6739#[serde(tag = "kind", rename_all = "kebab-case")]
6740pub enum NodeResponse {
6741    Snapshot {
6742        event_sequence: u64,
6743        controller: Option<ControllerState>,
6744        snapshot: NodeSnapshot,
6745    },
6746    Resync {
6747        event_sequence: u64,
6748        oldest_available_sequence: u64,
6749        snapshot: NodeSnapshot,
6750        events: Vec<NodeEventEnvelope>,
6751    },
6752    Armed {
6753        reservation_id: HarnessMcpReservationId,
6754        activation_digest: HarnessMcpActivationDigest,
6755        expires_at_unix_ms: u64,
6756    },
6757    Spawned {
6758        reservation_id: HarnessMcpReservationId,
6759        activation_digest: HarnessMcpActivationDigest,
6760        receipt: ResolvedSpawnReceipt,
6761    },
6762    Activated {
6763        reservation_id: HarnessMcpReservationId,
6764        activation_digest: HarnessMcpActivationDigest,
6765        record_id: SessionRecordId,
6766        session: SessionAddress,
6767    },
6768    Aborted {
6769        reservation_id: HarnessMcpReservationId,
6770        activation_digest: HarnessMcpActivationDigest,
6771    },
6772    ReplyChunkAccepted {
6773        reservation_id: HarnessMcpReservationId,
6774        activation_digest: HarnessMcpActivationDigest,
6775        record_id: SessionRecordId,
6776        session: SessionAddress,
6777        call_id: HarnessMcpCallId,
6778        next_offset: u32,
6779        completed: bool,
6780    },
6781    CallRejected {
6782        reservation_id: HarnessMcpReservationId,
6783        activation_digest: HarnessMcpActivationDigest,
6784        record_id: SessionRecordId,
6785        session: SessionAddress,
6786        call_id: HarnessMcpCallId,
6787    },
6788    DeliveryStageBegun {
6789        stage_id: DeliveryStageId,
6790        manifest_digest: DeliveryManifestDigestV2,
6791        #[serde(deserialize_with = "deserialize_delivery_blob_digests")]
6792        missing_blobs: Vec<DeliveryBlobDigestV1>,
6793    },
6794    DeliveryBlobChunkAccepted {
6795        stage_id: DeliveryStageId,
6796        blob_digest: DeliveryBlobDigestV1,
6797        next_offset: u64,
6798    },
6799    DeliveryCommitted {
6800        receipt: DeliveryCommitReceiptV1,
6801    },
6802    DeliveryStageAborted {
6803        stage_id: DeliveryStageId,
6804    },
6805    WorkspaceInspected {
6806        inspection: WorkspaceInspection,
6807    },
6808    HostDirectoriesBrowsed {
6809        listing: HostDirectoryListing,
6810    },
6811    WorkspaceFileRead {
6812        file: WorkspaceFileRead,
6813    },
6814    WorkspaceFileWritten {
6815        file: WorkspaceFileRead,
6816    },
6817    WorkspaceFileCreated {
6818        file: WorkspaceFileRead,
6819    },
6820    WorkspaceDirectoryCreated {
6821        workspace_id: WorkspaceId,
6822        entry: WorkspaceEntry,
6823    },
6824    GitHistoryRead {
6825        workspace_id: WorkspaceId,
6826        page: GitHistoryPage,
6827    },
6828    GitDiffRead {
6829        workspace_id: WorkspaceId,
6830        diff: GitDiff,
6831    },
6832    Controller {
6833        controller: Option<ControllerState>,
6834    },
6835    SpawnAccepted {
6836        session: SessionAddress,
6837    },
6838    SpawnSpecAccepted {
6839        receipt: ResolvedSpawnReceipt,
6840    },
6841    ManagedWorktreeSpawnAccepted {
6842        receipt: ManagedWorktreeSpawnReceipt,
6843    },
6844    ManagedWorktreeCleanup {
6845        lease: ManagedWorktreeLeaseSnapshot,
6846    },
6847    SessionRecordUpdated {
6848        record: ManagedSessionRecord,
6849    },
6850    ProviderSessionIndexed {
6851        record: ManagedSessionRecord,
6852    },
6853    NativeSessionIndexed {
6854        selection: NativeSessionSelection,
6855        record: ManagedSessionRecord,
6856    },
6857    SessionRecordResumed {
6858        record: ManagedSessionRecord,
6859        session: SessionAddress,
6860    },
6861    SessionRecordForgotten {
6862        record_id: SessionRecordId,
6863    },
6864    NativeSessionsCataloged {
6865        route: NativeSessionCatalogRoute,
6866        #[serde(deserialize_with = "deserialize_native_session_catalog_entries")]
6867        entries: Vec<NativeSessionCatalogEntry>,
6868        #[serde(
6869            default,
6870            skip_serializing_if = "Option::is_none",
6871            deserialize_with = "deserialize_optional_native_session_catalog_summary"
6872        )]
6873        summary: Option<NativeSessionCatalogSummary>,
6874    },
6875    NativeSessionsPaged {
6876        route: NativeSessionCatalogRoute,
6877        #[serde(deserialize_with = "deserialize_native_session_catalog_page")]
6878        page: NativeSessionCatalogPage,
6879    },
6880    NativeSessionPreviewed {
6881        selection: NativeSessionSelection,
6882        #[serde(deserialize_with = "deserialize_native_session_preview")]
6883        preview: NativeSessionPreview,
6884    },
6885    SessionRecordPreviewed {
6886        record_id: SessionRecordId,
6887        #[serde(deserialize_with = "deserialize_session_record_preview")]
6888        preview: SessionRecordPreview,
6889    },
6890    HistoryDiscovered {
6891        session: SessionAddress,
6892        #[serde(deserialize_with = "deserialize_history_candidates")]
6893        candidates: Vec<HistoryCandidateSummary>,
6894    },
6895    HistoryLoaded {
6896        session: SessionAddress,
6897        #[serde(deserialize_with = "deserialize_history_session_id")]
6898        session_id: String,
6899        message_count: u64,
6900        #[serde(default, skip_serializing_if = "Option::is_none")]
6901        completed_turn_count: Option<u64>,
6902    },
6903    ContextPackExported {
6904        context: ResolvedContextPackReceipt,
6905    },
6906    ContextPackForSessionRecordExported {
6907        record_id: SessionRecordId,
6908        session: SessionAddress,
6909        context: ResolvedContextPackReceipt,
6910    },
6911    ContextPackForgotten {
6912        context_id: SpawnContextId,
6913    },
6914    DurableContextPackResolved {
6915        context: ResolvedContextPackReceipt,
6916    },
6917    ContextPackBytesRead {
6918        pack: ContextPackBytesRead,
6919    },
6920    WorkspaceRegistered {
6921        workspace: WorkspaceSnapshot,
6922    },
6923    StandaloneWorkspaceCreated {
6924        workspace: WorkspaceSnapshot,
6925    },
6926    WorkspaceUnregistered {
6927        workspace_id: WorkspaceId,
6928    },
6929    WorktreeCreated {
6930        worktree: GitWorktreeSnapshot,
6931        workspace: WorkspaceSnapshot,
6932    },
6933    WorktreeRemoved {
6934        target_root: OpaqueHostPath,
6935        workspace_id: Option<WorkspaceId>,
6936    },
6937    Accepted,
6938    ShuttingDown,
6939}
6940
6941impl NodeResponse {
6942    pub fn requires_session_record_context_export_capability(&self) -> bool {
6943        matches!(self, Self::ContextPackForSessionRecordExported { .. })
6944    }
6945
6946    pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
6947        matches!(self,
6948            Self::Armed { .. }
6949                | Self::Spawned { .. }
6950                | Self::Activated { .. }
6951                | Self::Aborted { .. }
6952                | Self::ReplyChunkAccepted { .. }
6953                | Self::CallRejected { .. })
6954            || matches!(self, Self::SpawnSpecAccepted { receipt }
6955                if receipt.harness_mcp_proxy.is_some())
6956            || matches!(self, Self::ManagedWorktreeSpawnAccepted { receipt }
6957                if receipt.spawn.harness_mcp_proxy.is_some())
6958    }
6959
6960    pub fn native_session_catalog_contract_is_valid(&self) -> bool {
6961        match self {
6962            Self::NativeSessionsCataloged { route, entries, summary: Some(summary) } => {
6963                route.validate().is_ok()
6964                    && validate_native_session_catalog_entries(route, entries).is_ok()
6965                    && summary.validate_initial_entries(entries.len()).is_ok()
6966            }
6967            Self::NativeSessionsCataloged { route, entries, summary: None } => {
6968                route.validate().is_ok()
6969                    && validate_native_session_catalog_entries(route, entries).is_ok()
6970            }
6971            Self::NativeSessionsPaged { route, page } => {
6972                page.validate_for_route(route).is_ok()
6973            }
6974            _ => true,
6975        }
6976    }
6977
6978    pub fn requires_native_session_catalog_capability(&self) -> bool {
6979        matches!(self, Self::NativeSessionsCataloged { .. })
6980    }
6981
6982    pub fn requires_native_session_catalog_paging_capability(&self) -> bool {
6983        matches!(self, Self::NativeSessionsPaged { .. })
6984    }
6985
6986    pub fn requires_native_session_preview_capability(&self) -> bool {
6987        matches!(
6988            self,
6989            Self::NativeSessionPreviewed { .. } | Self::SessionRecordPreviewed { .. }
6990        )
6991    }
6992
6993    pub fn requires_native_session_index_capability(&self) -> bool {
6994        matches!(self, Self::NativeSessionIndexed { .. })
6995    }
6996
6997    pub fn native_session_index_contract_is_valid(&self) -> bool {
6998        match self {
6999            Self::NativeSessionIndexed { selection, record } => {
7000                selection.validate().is_ok()
7001                    && selection.route.scope == NativeSessionCatalogScope::Workspace
7002                    && selection.route.workspace_id.as_ref() == Some(&record.workspace_id)
7003                    && selection.route.provider == record.provider
7004            }
7005            _ => false,
7006        }
7007    }
7008
7009    pub fn native_session_preview_contract_is_valid(&self) -> bool {
7010        match self {
7011            Self::NativeSessionPreviewed { selection, preview } => {
7012                selection.validate().is_ok() && preview.validate().is_ok()
7013            }
7014            Self::SessionRecordPreviewed { preview, .. } => preview.validate().is_ok(),
7015            _ => true,
7016        }
7017    }
7018
7019    pub fn requires_worktree_selection_capability(&self) -> bool {
7020        matches!(self,
7021            Self::SpawnSpecAccepted { receipt }
7022                | Self::Spawned { receipt, .. }
7023            if receipt.target.worktree_id.is_some())
7024            || matches!(
7025                self,
7026                Self::ManagedWorktreeSpawnAccepted { .. }
7027                    | Self::ManagedWorktreeCleanup { .. }
7028            )
7029    }
7030
7031
7032    pub fn requires_spawn_spec_defaults_overrides_capability(&self) -> bool {
7033        matches!(
7034            self,
7035            Self::SpawnSpecAccepted { .. }
7036                | Self::Spawned { .. }
7037                | Self::ManagedWorktreeSpawnAccepted { .. }
7038        )
7039    }
7040
7041    pub fn requires_spawn_profile_revision_capability(&self) -> bool {
7042        matches!(
7043            self,
7044            Self::SpawnSpecAccepted { .. }
7045                | Self::Spawned { .. }
7046                | Self::ManagedWorktreeSpawnAccepted { .. }
7047        )
7048    }
7049
7050    pub fn requires_child_environment_profile_capability(&self) -> bool {
7051        match self {
7052            Self::Snapshot { snapshot, .. } => {
7053                snapshot.requires_child_environment_profile_capability()
7054            }
7055            Self::Resync {
7056                snapshot, events, ..
7057            } => {
7058                snapshot.requires_child_environment_profile_capability()
7059                    || events.iter().any(|event| {
7060                        event.event.requires_child_environment_profile_capability()
7061                    })
7062            }
7063            Self::SpawnSpecAccepted { receipt }
7064            | Self::Spawned { receipt, .. } => receipt.environment_profile.is_some(),
7065            Self::ManagedWorktreeSpawnAccepted { receipt } => {
7066                receipt.spawn.environment_profile.is_some()
7067            }
7068            Self::SessionRecordUpdated { record }
7069            | Self::ProviderSessionIndexed { record }
7070            | Self::NativeSessionIndexed { record, .. }
7071            | Self::SessionRecordResumed { record, .. } => {
7072                record.environment_profile.is_some()
7073            }
7074            Self::Armed { .. }
7075            | Self::Activated { .. }
7076            | Self::Aborted { .. }
7077            | Self::ReplyChunkAccepted { .. }
7078            | Self::CallRejected { .. }
7079            | Self::WorkspaceInspected { .. }
7080            | Self::DeliveryStageBegun { .. }
7081            | Self::DeliveryBlobChunkAccepted { .. }
7082            | Self::DeliveryCommitted { .. }
7083            | Self::DeliveryStageAborted { .. }
7084            | Self::HostDirectoriesBrowsed { .. }
7085            | Self::WorkspaceFileRead { .. }
7086            | Self::WorkspaceFileWritten { .. }
7087            | Self::WorkspaceFileCreated { .. }
7088            | Self::WorkspaceDirectoryCreated { .. }
7089            | Self::GitHistoryRead { .. }
7090            | Self::GitDiffRead { .. }
7091            | Self::Controller { .. }
7092            | Self::SpawnAccepted { .. }
7093            | Self::ManagedWorktreeCleanup { .. }
7094            | Self::SessionRecordForgotten { .. }
7095            | Self::NativeSessionsCataloged { .. }
7096            | Self::NativeSessionsPaged { .. }
7097            | Self::NativeSessionPreviewed { .. }
7098            | Self::SessionRecordPreviewed { .. }
7099            | Self::HistoryDiscovered { .. }
7100            | Self::HistoryLoaded { .. }
7101            | Self::ContextPackForSessionRecordExported { .. }
7102            | Self::ContextPackExported { .. }
7103            | Self::ContextPackForgotten { .. }
7104            | Self::DurableContextPackResolved { .. }
7105            | Self::ContextPackBytesRead { .. }
7106            | Self::WorkspaceRegistered { .. }
7107            | Self::StandaloneWorkspaceCreated { .. }
7108            | Self::WorkspaceUnregistered { .. }
7109            | Self::WorktreeCreated { .. }
7110            | Self::WorktreeRemoved { .. }
7111            | Self::Accepted
7112            | Self::ShuttingDown => false,
7113        }
7114    }
7115
7116    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
7117        match self {
7118            Self::Snapshot { snapshot, .. } => {
7119                snapshot.requires_session_bundle_materialization_capability()
7120            }
7121            Self::Resync {
7122                snapshot, events, ..
7123            } => {
7124                snapshot.requires_session_bundle_materialization_capability()
7125                    || events.iter().any(|event| {
7126                        event.event.requires_session_bundle_materialization_capability()
7127                    })
7128            }
7129            Self::SpawnSpecAccepted { receipt }
7130            | Self::Spawned { receipt, .. } => receipt.bundle.is_some(),
7131            Self::ManagedWorktreeSpawnAccepted { receipt } => receipt.spawn.bundle.is_some(),
7132            Self::SessionRecordUpdated { record }
7133            | Self::ProviderSessionIndexed { record }
7134            | Self::NativeSessionIndexed { record, .. }
7135            | Self::SessionRecordResumed { record, .. } => record.bundle.is_some(),
7136            Self::DurableContextPackResolved { .. } => false,
7137            Self::Armed { .. }
7138            | Self::Activated { .. }
7139            | Self::Aborted { .. }
7140            | Self::ReplyChunkAccepted { .. }
7141            | Self::CallRejected { .. }
7142            | Self::WorkspaceInspected { .. }
7143            | Self::DeliveryStageBegun { .. }
7144            | Self::DeliveryBlobChunkAccepted { .. }
7145            | Self::DeliveryCommitted { .. }
7146            | Self::DeliveryStageAborted { .. }
7147            | Self::HostDirectoriesBrowsed { .. }
7148            | Self::WorkspaceFileRead { .. }
7149            | Self::WorkspaceFileWritten { .. }
7150            | Self::WorkspaceFileCreated { .. }
7151            | Self::WorkspaceDirectoryCreated { .. }
7152            | Self::GitHistoryRead { .. }
7153            | Self::GitDiffRead { .. }
7154            | Self::Controller { .. }
7155            | Self::SpawnAccepted { .. }
7156            | Self::ManagedWorktreeCleanup { .. }
7157            | Self::SessionRecordForgotten { .. }
7158            | Self::NativeSessionsCataloged { .. }
7159            | Self::NativeSessionsPaged { .. }
7160            | Self::NativeSessionPreviewed { .. }
7161            | Self::SessionRecordPreviewed { .. }
7162            | Self::HistoryDiscovered { .. }
7163            | Self::HistoryLoaded { .. }
7164            | Self::ContextPackForSessionRecordExported { .. }
7165            | Self::ContextPackExported { .. }
7166            | Self::ContextPackForgotten { .. }
7167            | Self::ContextPackBytesRead { .. }
7168            | Self::WorkspaceRegistered { .. }
7169            | Self::StandaloneWorkspaceCreated { .. }
7170            | Self::WorkspaceUnregistered { .. }
7171            | Self::WorktreeCreated { .. }
7172            | Self::WorktreeRemoved { .. }
7173            | Self::Accepted
7174            | Self::ShuttingDown => false,
7175        }
7176    }
7177
7178    pub fn requires_history_context_pack_capability(&self) -> bool {
7179        match self {
7180            Self::Snapshot { snapshot, .. } => {
7181                snapshot.requires_history_context_pack_capability()
7182            }
7183            Self::Resync {
7184                snapshot, events, ..
7185            } => {
7186                snapshot.requires_history_context_pack_capability()
7187                    || events.iter().any(|event| {
7188                        event.event.requires_history_context_pack_capability()
7189                    })
7190            }
7191            Self::SpawnSpecAccepted { receipt }
7192            | Self::Spawned { receipt, .. } => {
7193                receipt.context_id.is_some() || receipt.context.is_some()
7194            }
7195            Self::ManagedWorktreeSpawnAccepted { receipt } => {
7196                receipt.spawn.context_id.is_some() || receipt.spawn.context.is_some()
7197            }
7198            Self::SessionRecordUpdated { record }
7199            | Self::ProviderSessionIndexed { record }
7200            | Self::NativeSessionIndexed { record, .. }
7201            | Self::SessionRecordResumed { record, .. } => {
7202                record.context_id.is_some() || record.context.is_some()
7203            }
7204            Self::HistoryDiscovered { .. }
7205            | Self::HistoryLoaded { .. }
7206            | Self::ContextPackForSessionRecordExported { .. }
7207            | Self::ContextPackExported { .. }
7208            | Self::ContextPackForgotten { .. }
7209            | Self::DurableContextPackResolved { .. }
7210            | Self::ContextPackBytesRead { .. } => true,
7211            Self::Armed { .. }
7212            | Self::Activated { .. }
7213            | Self::Aborted { .. }
7214            | Self::ReplyChunkAccepted { .. }
7215            | Self::CallRejected { .. }
7216            | Self::WorkspaceInspected { .. }
7217            | Self::DeliveryStageBegun { .. }
7218            | Self::DeliveryBlobChunkAccepted { .. }
7219            | Self::DeliveryCommitted { .. }
7220            | Self::DeliveryStageAborted { .. }
7221            | Self::HostDirectoriesBrowsed { .. }
7222            | Self::WorkspaceFileRead { .. }
7223            | Self::WorkspaceFileWritten { .. }
7224            | Self::WorkspaceFileCreated { .. }
7225            | Self::WorkspaceDirectoryCreated { .. }
7226            | Self::GitHistoryRead { .. }
7227            | Self::GitDiffRead { .. }
7228            | Self::Controller { .. }
7229            | Self::SpawnAccepted { .. }
7230            | Self::ManagedWorktreeCleanup { .. }
7231            | Self::SessionRecordForgotten { .. }
7232            | Self::NativeSessionsCataloged { .. }
7233            | Self::NativeSessionsPaged { .. }
7234            | Self::NativeSessionPreviewed { .. }
7235            | Self::SessionRecordPreviewed { .. }
7236            | Self::WorkspaceRegistered { .. }
7237            | Self::StandaloneWorkspaceCreated { .. }
7238            | Self::WorkspaceUnregistered { .. }
7239            | Self::WorktreeCreated { .. }
7240            | Self::WorktreeRemoved { .. }
7241            | Self::Accepted
7242            | Self::ShuttingDown => false,
7243        }
7244    }
7245}
7246
7247#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7248pub struct NodeFailure {
7249    pub code: NodeFailureCode,
7250    pub message: String,
7251}
7252
7253#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7254#[serde(rename_all = "kebab-case")]
7255pub enum NodeFailureCode {
7256    InvalidRequest,
7257    UnsupportedCapability,
7258    SpawnProfileRevisionMismatch,
7259    HarnessMcpUnavailable,
7260    ReservationNotFound,
7261    ReservationConflict,
7262    ReservationExpired,
7263    BindingMismatch,
7264    NotActivated,
7265    CallNotFound,
7266    ChunkOutOfOrder,
7267    ResponseTooLarge,
7268    DeliveryManifestInvalid,
7269    UnknownDeliveryStage,
7270    DeliveryStageConflict,
7271    DeliveryBlobUnexpected,
7272    DeliveryChunkOutOfOrder,
7273    DeliveryBlobDigestMismatch,
7274    DeliveryBundleDigestMismatch,
7275    DeliveryStageIncomplete,
7276    DeliveryStageStorageFailed,
7277    Unauthorized,
7278    ObserverReadOnly,
7279    ControllerBusy,
7280    ControllerRequired,
7281    UnknownWorkspace,
7282    HostDirectoryInvalid,
7283    HostDirectoryReadFailed,
7284    HostDirectoryReadTimedOut,
7285    InvalidRepositoryPath,
7286    RepositoryFileNotFound,
7287    RepositoryFileNotRegular,
7288    RepositoryPathUnsafe,
7289    RepositoryFileReadTimedOut,
7290    RepositoryFileReadFailed,
7291    RepositoryFileWriteTimedOut,
7292    RepositoryFileWriteFailed,
7293    RepositoryFileRevisionConflict,
7294    RepositoryEntryAlreadyExists,
7295    RepositoryParentNotFound,
7296    RepositoryParentNotDirectory,
7297    RepositoryEntryCreateTimedOut,
7298    RepositoryEntryCreateFailed,
7299    GitReadTimedOut,
7300    GitReadFailed,
7301    InvalidWorkspaceRoot,
7302    DuplicateWorkspaceId,
7303    DuplicateWorkspaceRoot,
7304    WorkspaceBusy,
7305    LastWorkspace,
7306    NotGitRepository,
7307    WorktreeConflict,
7308    WorktreeProtected,
7309    WorktreeDirty,
7310    WorktreeLocked,
7311    UnknownManagedWorktreeLease,
7312    ManagedWorktreeBusy,
7313    ManagedWorktreeOwnershipConflict,
7314    ManagedWorktreeProfileRevisionMismatch,
7315    ManagedWorktreeRecoveryRequired,
7316    StandaloneWorkspaceRecoveryRequired,
7317    UnknownSpawnProfile,
7318    UnknownBundle,
7319    UnknownContextPack,
7320    ContextPackBusy,
7321    ContextPackMaterializationFailed,
7322    UnknownEnvironmentProfile,
7323    /// Station network allowlist policy id is not registered in this node's
7324    /// empty-default catalog (`NodeShared::network_allowlist_catalog`).
7325    /// Plan `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 /
7326    /// §4 — refuse unknown ids rather than silent ambient. Opaque id only;
7327    /// never credentials on C2.
7328    UnknownNetworkAllowlist,
7329    /// Catalog entry carries a provider-native network mapping this spawn's
7330    /// provider cannot honor (e.g. `codex_network_access` for Claude / Kimi).
7331    /// Plan `dig2browser-station-probe-and-network-permit-set-2026-10-02.md`
7332    /// Track B — refuse rather than silent ambient. Never secrets on C2.
7333    UnsupportedNetworkAllowlistMapping,
7334    /// `browser_profile_id` set while feature `dig2-station-probe` is on, but
7335    /// the cheap station probe cannot run on this platform (dig2browser
7336    /// named-pipe IPC is Windows-first; no unix socket path yet) or the
7337    /// configured pipe suffix is invalid. Plan
7338    /// `dig2browser-station-probe-and-network-permit-set-2026-10-02.md` Track A.
7339    /// Never cookies / OAuth on C2.
7340    BrowserStationProbeUnavailable,
7341    /// `browser_profile_id` set, probe feature on, and the local
7342    /// dig2browser-station named-pipe path is missing or not connectable.
7343    /// Path reachability only — no session import / cookie frames.
7344    BrowserStationUnreachable,
7345    /// Feature `dig2-station-probe`: another live session already holds an
7346    /// exclusive node-local lease on this opaque `browser_profile_id`.
7347    /// Never cookies / ImportSession on C2 — lease is node bookkeeping only.
7348    /// Sketch: `dig2-station-bind-lease-sketch-2026-10-02.md`.
7349    BrowserStationProfileBusy,
7350    BundleBindingMismatch,
7351    EnvironmentProfileBindingMismatch,
7352    BundleMaterializationFailed,
7353    SpawnTargetMismatch,
7354    SpawnIdempotencyConflict,
7355    SpawnIdempotencyCapacity,
7356    SpawnDeadlineExceeded,
7357    UnsupportedSpawnCapability,
7358    /// The requested provider does not declare the requested transport
7359    /// (the kernel's own `UnsupportedTransport` rejection, named rather
7360    /// than folded into `BackendOperationFailed`/`UnsupportedCapability` so
7361    /// a caller several layers up can carry the same "provider + transport"
7362    /// specificity all the way to the operator instead of collapsing it
7363    /// into a generic backend failure or -- if the underlying rejection
7364    /// arrives asynchronously and is caught only by a blind commit-deadline
7365    /// poll -- a `SpawnDeadlineExceeded` that names neither).
7366    UnsupportedTransport,
7367    /// The addressed session already has a provider turn in flight --
7368    /// `TurnStarted` observed with no matching `TurnCompleted` yet -- and a
7369    /// `Prompt`/`Paste` against it was refused by name rather than handed to
7370    /// the agent. Applies to the two transports that admit exactly one turn
7371    /// at a time (ACP, and `Pipe`/inline): what a provider does with an
7372    /// overlapping second prompt is vendor-specific and unobservable here
7373    /// (queue it, drop it, interleave it into the running turn), so the node
7374    /// authors its own refusal instead of gambling on that behaviour. PTY
7375    /// sessions never produce this code -- they are still gated by
7376    /// `ProviderRuntimePolicy`'s PTY-terminal-text-inference flags, which
7377    /// answer `UnsupportedCapability` instead.
7378    TurnInFlight,
7379    UnknownSession,
7380    UnknownSessionRecord,
7381    SessionRecordNotResumable,
7382    SessionRecordBusy,
7383    SessionRecordConflict,
7384    SessionWorkspaceMismatch,
7385    WorkspaceRegistrationRequired,
7386    StaleNativeSessionCatalog,
7387    StaleGeneration,
7388    BackendBusy,
7389    BackendDisconnected,
7390    BackendOperationFailed,
7391    ShuttingDown,
7392}
7393
7394/// One named catalog entry -- a selectable session mode or model on the
7395/// `ModeCatalog`/`ModelCatalog` chunk kinds. `id` is what
7396/// `SetSessionMode`/`SetSessionModel` take back.
7397#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7398#[serde(deny_unknown_fields)]
7399pub struct AgentStreamNamedIdV1 {
7400    pub id: String,
7401    pub name: String,
7402    pub description: Option<String>,
7403}
7404
7405/// One selectable answer to an `InteractionPrompt` chunk -- an ACP
7406/// permission option, named by the provider rather than invented here.
7407#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7408#[serde(deny_unknown_fields)]
7409pub struct AgentStreamInteractionOptionV1 {
7410    pub option_id: String,
7411    pub name: String,
7412    pub kind: String,
7413}
7414
7415/// WHO or WHAT blocked an action -- see `AgentStreamChunkKindV1::Blocked`.
7416///
7417/// The host side of the wire (`HostGate`, `HostPolicy`, `HostDeadline`,
7418/// `Operator`) names the four deciders of a host request the node relays to an
7419/// ACP session; the provider side (`ProviderClassifier` ... `ProviderQuota`) names
7420/// refusals the provider reported through a typed field. `Unknown` means no
7421/// typed field named the blocker -- text may still have filled `reason`/`help`,
7422/// but never this field: authority is never guessed from text.
7423#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7424#[serde(rename_all = "kebab-case")]
7425pub enum BlockAuthorityV1 {
7426    /// A dangerous-command gate forced the outcome ahead of the host's policy.
7427    HostGate,
7428    /// The host's policy decided the request the instant it arrived.
7429    HostPolicy,
7430    /// A deferred request the host's policy decided after no operator
7431    /// answered in time.
7432    HostDeadline,
7433    /// An operator explicitly declined.
7434    Operator,
7435    /// The provider's own auto-mode classifier refused the call.
7436    ProviderClassifier,
7437    /// A provider-side permission rule (an allow/deny list entry) refused
7438    /// the call.
7439    ProviderPermissionRule,
7440    /// The provider's sandbox refused the call.
7441    ProviderSandbox,
7442    /// The provider declined with a `stopReason: "refusal"` or equivalent.
7443    ProviderRefusal,
7444    /// A human declined inside the provider's OWN UI/CLI.
7445    UserRejected,
7446    /// The provider's own account/plan quota, rate limit, or usage cap was
7447    /// exhausted. `reason_kind` carries the provider's own vendor code.
7448    ProviderQuota,
7449    /// No typed field named who blocked it.
7450    Unknown,
7451}
7452
7453/// The kind of a single `AgentStreamChunkV1` -- content the operator needs
7454/// to act on a running ACP session: what the agent is saying, a pending
7455/// interaction it needs answered, and the catalogs the three ACP setter
7456/// verbs (`SetSessionMode`, `SetSessionConfigOption`, `SetSessionModel`)
7457/// operate over. Mirrors `gate4agent_types::ProviderEvent`'s content
7458/// variants deliberately -- see
7459/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
7460/// §3-4.
7461#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7462#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7463pub enum AgentStreamChunkKindV1 {
7464    Text { text: String, is_delta: bool },
7465    Thinking { text: String },
7466    /// The `correlation_id` `correlation::interaction_correlation` mints for
7467    /// the interaction -- the same id a client deriving telemetry from
7468    /// `NodeEvent::Control` computes -- plus the question and option list
7469    /// `NodeRequest::ResolveInteraction` answers blind without.
7470    InteractionPrompt {
7471        correlation_id: String,
7472        interaction_kind: ProviderInteractionKind,
7473        tool_name: String,
7474        title: Option<String>,
7475        prompt: String,
7476        options: Vec<AgentStreamInteractionOptionV1>,
7477    },
7478    ModeCatalog {
7479        current: Option<String>,
7480        available: Vec<AgentStreamNamedIdV1>,
7481    },
7482    /// Mirrors `gate4agent_types::ProviderConfigOption` verbatim rather
7483    /// than inventing a second shape for the same content.
7484    ConfigOptions { options: Vec<ProviderConfigOption> },
7485    ModelCatalog {
7486        current: Option<String>,
7487        available: Vec<AgentStreamNamedIdV1>,
7488    },
7489    /// An action a provider or the host refused, with who refused it
7490    /// (`authority`), why (`reason_kind`/`reason`) and what to do about it
7491    /// (`help`). `BlockAuthorityV1`'s doc comment explains why authority is
7492    /// never guessed. An EVENT, not state: a session that reconnects
7493    /// mid-block does not get this replayed to it -- the same seeded-state-
7494    /// vs-unreplayed-event split the agent stream already draws for `Text`/
7495    /// `Thinking`/`InteractionPrompt`.
7496    Blocked {
7497        correlation_id: Option<String>,
7498        tool_class: String,
7499        authority: BlockAuthorityV1,
7500        reason_kind: Option<String>,
7501        reason: String,
7502        help: Option<String>,
7503    },
7504}
7505
7506impl AgentStreamChunkKindV1 {
7507    pub fn validate(&self) -> Result<(), &'static str> {
7508        match self {
7509            Self::Text { text, .. } | Self::Thinking { text } => {
7510                if text.len() > MAX_ACP_CONTROL_TEXT_BYTES || contains_unsafe_control_bytes(text) {
7511                    return Err("agent stream text chunk is invalid");
7512                }
7513                Ok(())
7514            }
7515            Self::InteractionPrompt {
7516                correlation_id,
7517                tool_name,
7518                title,
7519                prompt,
7520                options,
7521                ..
7522            } => {
7523                if correlation_id.is_empty()
7524                    || correlation_id.len() > MAX_ACP_CORRELATION_ID_BYTES
7525                    || correlation_id.chars().any(char::is_control)
7526                {
7527                    return Err("agent stream interaction prompt correlation id is invalid");
7528                }
7529                if tool_name.is_empty()
7530                    || tool_name.len() > MAX_ACP_CONTROL_ID_BYTES
7531                    || tool_name.chars().any(char::is_control)
7532                {
7533                    return Err("agent stream interaction prompt tool name is invalid");
7534                }
7535                if let Some(title) = title {
7536                    if title.len() > MAX_ACP_CONTROL_TEXT_BYTES
7537                        || contains_unsafe_control_bytes(title)
7538                    {
7539                        return Err("agent stream interaction prompt title is invalid");
7540                    }
7541                }
7542                if prompt.len() > MAX_ACP_CONTROL_TEXT_BYTES || contains_unsafe_control_bytes(prompt)
7543                {
7544                    return Err("agent stream interaction prompt text is invalid");
7545                }
7546                if options.len() > MAX_ACP_INTERACTION_OPTIONS {
7547                    return Err("agent stream interaction prompt has too many options");
7548                }
7549                for option in options {
7550                    if option.option_id.is_empty()
7551                        || option.option_id.len() > MAX_ACP_CONTROL_ID_BYTES
7552                        || option.option_id.chars().any(char::is_control)
7553                    {
7554                        return Err("agent stream interaction prompt option id is invalid");
7555                    }
7556                    if option.name.is_empty()
7557                        || option.name.len() > MAX_ACP_CONTROL_ID_BYTES
7558                        || option.name.chars().any(char::is_control)
7559                    {
7560                        return Err("agent stream interaction prompt option name is invalid");
7561                    }
7562                    if option.kind.is_empty()
7563                        || option.kind.len() > MAX_ACP_CONTROL_ID_BYTES
7564                        || option.kind.chars().any(char::is_control)
7565                    {
7566                        return Err("agent stream interaction prompt option kind is invalid");
7567                    }
7568                }
7569                Ok(())
7570            }
7571            Self::ModeCatalog { current, available } | Self::ModelCatalog { current, available } => {
7572                if let Some(current) = current {
7573                    if current.is_empty()
7574                        || current.len() > MAX_ACP_CONTROL_ID_BYTES
7575                        || current.chars().any(char::is_control)
7576                    {
7577                        return Err("agent stream catalog current id is invalid");
7578                    }
7579                }
7580                if available.len() > MAX_ACP_CATALOG_ENTRIES {
7581                    return Err("agent stream catalog has too many entries");
7582                }
7583                for entry in available {
7584                    if entry.id.is_empty()
7585                        || entry.id.len() > MAX_ACP_CONTROL_ID_BYTES
7586                        || entry.id.chars().any(char::is_control)
7587                    {
7588                        return Err("agent stream catalog entry id is invalid");
7589                    }
7590                    if entry.name.is_empty()
7591                        || entry.name.len() > MAX_ACP_CONTROL_ID_BYTES
7592                        || entry.name.chars().any(char::is_control)
7593                    {
7594                        return Err("agent stream catalog entry name is invalid");
7595                    }
7596                    if let Some(description) = &entry.description {
7597                        if description.len() > MAX_ACP_CONTROL_TEXT_BYTES
7598                            || contains_unsafe_control_bytes(description)
7599                        {
7600                            return Err("agent stream catalog entry description is invalid");
7601                        }
7602                    }
7603                }
7604                Ok(())
7605            }
7606            Self::ConfigOptions { options } => {
7607                if options.len() > MAX_ACP_CATALOG_ENTRIES {
7608                    return Err("agent stream config options exceed the catalog entry limit");
7609                }
7610                for option in options {
7611                    if option.id.is_empty()
7612                        || option.id.len() > MAX_ACP_CONTROL_ID_BYTES
7613                        || option.id.chars().any(char::is_control)
7614                    {
7615                        return Err("agent stream config option id is invalid");
7616                    }
7617                    if option.name.is_empty()
7618                        || option.name.len() > MAX_ACP_CONTROL_ID_BYTES
7619                        || option.name.chars().any(char::is_control)
7620                    {
7621                        return Err("agent stream config option name is invalid");
7622                    }
7623                    if let Some(description) = &option.description {
7624                        if description.len() > MAX_ACP_CONTROL_TEXT_BYTES
7625                            || contains_unsafe_control_bytes(description)
7626                        {
7627                            return Err("agent stream config option description is invalid");
7628                        }
7629                    }
7630                    if let Some(category) = &option.category {
7631                        if category.len() > MAX_ACP_CONTROL_ID_BYTES
7632                            || category.chars().any(char::is_control)
7633                        {
7634                            return Err("agent stream config option category is invalid");
7635                        }
7636                    }
7637                    if option.value_json.len() > MAX_ACP_CONTROL_TEXT_BYTES
7638                        || contains_unsafe_control_bytes(&option.value_json)
7639                    {
7640                        return Err("agent stream config option value is invalid");
7641                    }
7642                    if option.choices.len() > MAX_ACP_INTERACTION_OPTIONS {
7643                        return Err("agent stream config option has too many choices");
7644                    }
7645                    for choice in &option.choices {
7646                        if choice.value_json.len() > MAX_ACP_CONTROL_TEXT_BYTES
7647                            || contains_unsafe_control_bytes(&choice.value_json)
7648                        {
7649                            return Err("agent stream config option choice value is invalid");
7650                        }
7651                        if let Some(label) = &choice.label {
7652                            if label.len() > MAX_ACP_CONTROL_TEXT_BYTES
7653                                || contains_unsafe_control_bytes(label)
7654                            {
7655                                return Err("agent stream config option choice label is invalid");
7656                            }
7657                        }
7658                    }
7659                }
7660                Ok(())
7661            }
7662            Self::Blocked {
7663                correlation_id,
7664                tool_class,
7665                reason_kind,
7666                reason,
7667                help,
7668                ..
7669            } => {
7670                if let Some(correlation_id) = correlation_id {
7671                    if correlation_id.is_empty()
7672                        || correlation_id.len() > MAX_ACP_CORRELATION_ID_BYTES
7673                        || correlation_id.chars().any(char::is_control)
7674                    {
7675                        return Err("agent stream blocked chunk correlation id is invalid");
7676                    }
7677                }
7678                if tool_class.is_empty()
7679                    || tool_class.len() > MAX_ACP_CORRELATION_ID_BYTES
7680                    || tool_class.chars().any(char::is_control)
7681                {
7682                    return Err("agent stream blocked chunk tool class is invalid");
7683                }
7684                if let Some(reason_kind) = reason_kind {
7685                    if reason_kind.is_empty()
7686                        || reason_kind.len() > MAX_ACP_CORRELATION_ID_BYTES
7687                        || reason_kind.chars().any(char::is_control)
7688                    {
7689                        return Err("agent stream blocked chunk reason kind is invalid");
7690                    }
7691                }
7692                if reason.is_empty()
7693                    || reason.len() > MAX_ACP_BLOCKED_REASON_BYTES
7694                    || contains_unsafe_control_bytes(reason)
7695                {
7696                    return Err("agent stream blocked chunk reason is invalid");
7697                }
7698                if let Some(help) = help {
7699                    if help.is_empty()
7700                        || help.len() > MAX_ACP_BLOCKED_HELP_BYTES
7701                        || contains_unsafe_control_bytes(help)
7702                    {
7703                        return Err("agent stream blocked chunk help is invalid");
7704                    }
7705                }
7706                Ok(())
7707            }
7708        }
7709    }
7710}
7711
7712/// One chunk of the outbound agent content stream -- the
7713/// `agent-stream-events-v1` capability's push channel, mirroring
7714/// `NodeEvent::TerminalFrame` exactly: its own subscription, its own type,
7715/// no resync promise. `source_sequence` orders chunks within one provider
7716/// source, the same number the provider event carries.
7717#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7718#[serde(deny_unknown_fields)]
7719pub struct AgentStreamChunkV1 {
7720    pub source_sequence: u64,
7721    pub kind: AgentStreamChunkKindV1,
7722}
7723
7724impl AgentStreamChunkV1 {
7725    pub fn validate(&self) -> Result<(), &'static str> {
7726        self.kind.validate()
7727    }
7728}
7729
7730#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7731pub struct NodeEventEnvelope {
7732    pub sequence: u64,
7733    pub event: NodeEvent,
7734}
7735
7736#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7737#[serde(tag = "kind", rename_all = "kebab-case")]
7738pub enum NodeEvent {
7739    HarnessMcpReadCall {
7740        reservation_id: HarnessMcpReservationId,
7741        activation_digest: HarnessMcpActivationDigest,
7742        record_id: SessionRecordId,
7743        session: SessionAddress,
7744        call_id: HarnessMcpCallId,
7745        request: HarnessMcpOpaquePayloadV1,
7746        deadline_unix_ms: u64,
7747    },
7748    Control { address: SessionAddress, event: ControlEvent },
7749    /// Aggregate facts about one session record's native history, published
7750    /// when a client previews the record. Counts only -- never messages -- so
7751    /// it is safe to fan out to every subscriber.
7752    SessionRecordHistorySummarized { record_id: SessionRecordId, summary: SessionHistorySummaryV1 },
7753    TerminalFrame { address: SessionAddress, frame: TerminalFrame },
7754    AgentStream { address: SessionAddress, chunk: AgentStreamChunkV1 },
7755    ControllerChanged { controller: Option<ControllerState> },
7756    WorkspaceAdded { workspace: WorkspaceSnapshot },
7757    WorkspaceRemoved { workspace_id: WorkspaceId },
7758    SessionRecordUpserted { record: ManagedSessionRecord },
7759    SessionRecordRemoved { record_id: SessionRecordId },
7760    ManagedWorktreeUpserted { lease: ManagedWorktreeLeaseSnapshot },
7761    ManagedWorktreeRemoved { lease_id: ManagedWorktreeLeaseId },
7762    ResyncRequired { oldest_available_sequence: u64 },
7763}
7764
7765impl NodeEvent {
7766    pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
7767        matches!(self, Self::HarnessMcpReadCall { .. })
7768    }
7769
7770    pub fn harness_mcp_contract_is_valid_at(&self, now_unix_ms: u64) -> bool {
7771        match self {
7772            Self::HarnessMcpReadCall { request, deadline_unix_ms, .. } => {
7773                serde_json::to_vec(request)
7774                    .is_ok_and(|wire| wire.len() <= MAX_HARNESS_MCP_LOCAL_REQUEST_BYTES)
7775                    && *deadline_unix_ms > now_unix_ms
7776                    && deadline_unix_ms.saturating_sub(now_unix_ms)
7777                        <= MAX_HARNESS_MCP_CALL_DEADLINE_MS
7778            }
7779            _ => true,
7780        }
7781    }
7782
7783    pub fn requires_child_environment_profile_capability(&self) -> bool {
7784        matches!(self, Self::SessionRecordUpserted { record }
7785            if record.environment_profile.is_some())
7786    }
7787
7788
7789    pub fn requires_session_bundle_materialization_capability(&self) -> bool {
7790        matches!(self, Self::SessionRecordUpserted { record }
7791            if record.bundle.is_some())
7792    }
7793
7794    pub fn requires_history_context_pack_capability(&self) -> bool {
7795        matches!(self, Self::SessionRecordUpserted { record }
7796            if record.context_id.is_some() || record.context.is_some())
7797    }
7798}
7799
7800/// Aggregate facts about one session record's native history: how many messages
7801/// and completed turns it holds and what it cost, without any of its content.
7802#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7803#[serde(deny_unknown_fields)]
7804pub struct SessionHistorySummaryV1 {
7805    pub message_count: u64,
7806    pub message_count_exact: bool,
7807    pub completed_turn_count: Option<u64>,
7808    pub total_tokens: Option<u64>,
7809    pub modified_at_unix_ms: Option<u64>,
7810}
7811
7812impl From<&SessionRecordPreview> for SessionHistorySummaryV1 {
7813    fn from(preview: &SessionRecordPreview) -> Self {
7814        Self {
7815            message_count: preview.message_count,
7816            message_count_exact: preview.message_count_exact,
7817            completed_turn_count: preview.completed_turn_count,
7818            total_tokens: preview.total_tokens,
7819            modified_at_unix_ms: preview.modified_at_unix_ms,
7820        }
7821    }
7822}
7823
7824/// The first frame on a CALL-HOME connection, and the only frame this
7825/// protocol adds for it: the node announcing which node it is.
7826///
7827/// Every other connection on this wire is dialled by the operator, which
7828/// therefore already knows which node it reached and which per-node access
7829/// token to prove against. A call-home connection inverts who opens the
7830/// socket -- a node with no reachable address connects out to the relay
7831/// instead of waiting to be connected to -- and the relay, holding a fresh
7832/// accepted socket, has no idea whose it is. It cannot even verify the
7833/// server proof, because that proof is computed from the token belonging
7834/// to a specific node.
7835///
7836/// So the node says its name first. Note what this frame is NOT: it is not
7837/// authentication and it grants nothing. It only SELECTS which configured
7838/// node's token the relay will use for the handshake that follows, and
7839/// that handshake is the same mutual challenge-response every dialled
7840/// connection runs (`ServerChallenge`/`ClientAuthentication`, both sides
7841/// proving over both nonces). A caller announcing a node id it does not
7842/// hold the token for fails at the very next frame, exactly as an
7843/// impostor on a dialled connection would.
7844///
7845/// Deliberately not a variant of [`ClientFrame`]: it travels before the
7846/// wire's own handshake begins, in one direction only, and adding it to
7847/// the frame enum would make it look like something a client may send at
7848/// any point in a session. It is a preface, not a frame of the protocol.
7849#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7850pub struct NodeCallHomeAnnounce {
7851    pub build_stamp: String,
7852    pub node_id: String,
7853}
7854
7855impl NodeCallHomeAnnounce {
7856    pub fn new(node_id: impl Into<String>) -> Self {
7857        Self { build_stamp: BUILD_STAMP.to_owned(), node_id: node_id.into() }
7858    }
7859}
7860
7861#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7862#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
7863pub enum ClientFrame {
7864    Hello(ClientHello),
7865    Authenticate(ClientAuthentication),
7866    Request(RequestEnvelope),
7867}
7868
7869#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7870#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
7871pub enum ServerFrame {
7872    Challenge(ServerChallenge),
7873    Hello(NodeHello),
7874    Reply(ResponseEnvelope),
7875    Event(NodeEventEnvelope),
7876}
7877
7878pub async fn read_json_frame<R, T>(reader: &mut R) -> Result<T, FrameError>
7879where
7880    R: AsyncRead + Unpin,
7881    T: DeserializeOwned,
7882{
7883    read_json_frame_limited(reader, MAX_NODE_FRAME_BYTES).await
7884}
7885
7886pub async fn read_json_frame_limited<R, T>(reader: &mut R, max_bytes: usize) -> Result<T, FrameError>
7887where
7888    R: AsyncRead + Unpin,
7889    T: DeserializeOwned,
7890{
7891    let length = reader.read_u32_le().await? as usize;
7892    if length == 0 || length > max_bytes {
7893        return Err(FrameError::InvalidLength {
7894            length,
7895            max: max_bytes,
7896        });
7897    }
7898    let mut payload = vec![0; length];
7899    reader.read_exact(&mut payload).await?;
7900    Ok(serde_json::from_slice(&payload)?)
7901}
7902
7903pub async fn read_json_frame_limited_body_timeout<R, T>(
7904    reader: &mut R,
7905    max_bytes: usize,
7906    body_timeout: Duration,
7907) -> Result<T, FrameError>
7908where
7909    R: AsyncRead + Unpin,
7910    T: DeserializeOwned,
7911{
7912    let mut length_prefix = [0_u8; std::mem::size_of::<u32>()];
7913    reader.read_exact(&mut length_prefix[..1]).await?;
7914    timeout(body_timeout, reader.read_exact(&mut length_prefix[1..]))
7915        .await
7916        .map_err(|_| FrameError::PrefixTimedOut)??;
7917    let length = u32::from_le_bytes(length_prefix) as usize;
7918    if length == 0 || length > max_bytes {
7919        return Err(FrameError::InvalidLength {
7920            length,
7921            max: max_bytes,
7922        });
7923    }
7924    let mut payload = vec![0; length];
7925    timeout(body_timeout, reader.read_exact(&mut payload))
7926        .await
7927        .map_err(|_| FrameError::BodyTimedOut { length })??;
7928    Ok(serde_json::from_slice(&payload)?)
7929}
7930
7931pub async fn write_json_frame<W, T>(writer: &mut W, value: &T) -> Result<(), FrameError>
7932where
7933    W: AsyncWrite + Unpin,
7934    T: Serialize,
7935{
7936    write_json_frame_limited(writer, value, MAX_NODE_FRAME_BYTES).await
7937}
7938
7939pub async fn write_json_frame_limited<W, T>(
7940    writer: &mut W,
7941    value: &T,
7942    max_bytes: usize,
7943) -> Result<(), FrameError>
7944where
7945    W: AsyncWrite + Unpin,
7946    T: Serialize,
7947{
7948    let payload = serde_json::to_vec(value)?;
7949    if payload.is_empty() || payload.len() > max_bytes {
7950        return Err(FrameError::InvalidLength {
7951            length: payload.len(),
7952            max: max_bytes,
7953        });
7954    }
7955    writer.write_u32_le(payload.len() as u32).await?;
7956    writer.write_all(&payload).await?;
7957    writer.flush().await?;
7958    Ok(())
7959}
7960
7961#[derive(Debug, Error)]
7962pub enum FrameError {
7963    #[error("node frame I/O failed: {0}")]
7964    Io(#[from] io::Error),
7965    #[error("node frame JSON failed: {0}")]
7966    Json(#[from] serde_json::Error),
7967    #[error("node frame length {length} is outside 1..={max}")]
7968    InvalidLength { length: usize, max: usize },
7969    #[error("node frame body of {length} bytes was not received before the bounded deadline")]
7970    BodyTimedOut { length: usize },
7971    #[error("node frame length prefix was not completed before the bounded deadline")]
7972    PrefixTimedOut,
7973}
7974
7975#[cfg(test)]
7976mod tests {
7977    use super::*;
7978
7979    /// A blocked-action chunk carries all six fields and
7980    /// serialises additively (`"kind":"blocked"` alongside `Text`/
7981    /// `Thinking`/`InteractionPrompt`/`ModeCatalog`/`ConfigOptions`/
7982    /// `ModelCatalog`, never replacing any of them), and round-trips exactly.
7983    #[test]
7984    fn agent_stream_blocked_chunk_serialises_additively_with_all_six_fields() {
7985        let chunk = AgentStreamChunkV1 {
7986            source_sequence: 7,
7987            kind: AgentStreamChunkKindV1::Blocked {
7988                correlation_id: Some("tool-deadbeefcafebabe".to_owned()),
7989                tool_class: "Shell".to_owned(),
7990                authority: BlockAuthorityV1::HostGate,
7991                reason_kind: Some("gate-rule".to_owned()),
7992                reason: "blocked by dangerous-command gate: rule=filesystem-wipe".to_owned(),
7993                help: Some("add a Bash permission rule".to_owned()),
7994            },
7995        };
7996        chunk.validate().unwrap();
7997        let json = serde_json::to_string(&chunk).unwrap();
7998        assert_eq!(
7999            json,
8000            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"}}"#,
8001        );
8002        assert_eq!(serde_json::from_str::<AgentStreamChunkV1>(&json).unwrap(), chunk);
8003
8004        // `correlation_id`/`reason_kind`/`help` are `None` for the harness's
8005        // own denials today (see `gate4agent-node`'s `host_request_denied_
8006        // block`) -- confirm the wire keeps them as explicit JSON `null`,
8007        // not an omitted field, matching every other `Option` field this
8008        // enum already carries (`ModeCatalog::current`, `title`).
8009        let minimal = AgentStreamChunkV1 {
8010            source_sequence: 8,
8011            kind: AgentStreamChunkKindV1::Blocked {
8012                correlation_id: None,
8013                tool_class: "Write".to_owned(),
8014                authority: BlockAuthorityV1::Unknown,
8015                reason_kind: None,
8016                reason: "blocked; no reason was reported".to_owned(),
8017                help: None,
8018            },
8019        };
8020        minimal.validate().unwrap();
8021        let minimal_json = serde_json::to_string(&minimal).unwrap();
8022        assert_eq!(
8023            minimal_json,
8024            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}}"#,
8025        );
8026        assert_eq!(
8027            serde_json::from_str::<AgentStreamChunkV1>(&minimal_json).unwrap(),
8028            minimal,
8029        );
8030    }
8031
8032    #[test]
8033    fn harness_mcp_protocol_serde_bounds_and_privacy() {
8034        let reservation_id = HarnessMcpReservationId::new(format!(
8035            "hmcpres_{}", "a".repeat(24),
8036        )).unwrap();
8037        let call_id = HarnessMcpCallId::new(format!("hmcpcall_{}", "b".repeat(24))).unwrap();
8038        let digest = HarnessMcpActivationDigest::new(format!(
8039            "sha256:{}", "c".repeat(64),
8040        )).unwrap();
8041        let token = HarnessMcpLocalToken::new(format!("g4ah3_{}", "d".repeat(64))).unwrap();
8042        assert!(!format!("{token:?}").contains(token.expose()));
8043        assert!(HarnessMcpReservationId::new(format!("hmcpres_{}", "A".repeat(24))).is_err());
8044        assert!(HarnessMcpCallId::new(format!("hmcpcall_{}", "b".repeat(23))).is_err());
8045        assert!(HarnessMcpActivationDigest::new(format!("sha256:{}", "g".repeat(64))).is_err());
8046
8047        let local = HarnessMcpLocalRequestV1 {
8048            version: 1,
8049            token,
8050            request: HarnessMcpOpaquePayloadV1 {
8051                content_type: HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
8052                body: br#"{"kind":"context-get"}"#.to_vec(),
8053            },
8054        };
8055        local.validate().unwrap();
8056        let mut unknown = serde_json::to_value(&local).unwrap();
8057        unknown["endpoint"] = serde_json::Value::String("forbidden".to_owned());
8058        assert!(serde_json::from_value::<HarnessMcpLocalRequestV1>(unknown).is_err());
8059        assert!(HarnessMcpReplyChunkHexV1::new(
8060            "00".repeat(MAX_HARNESS_MCP_REPLY_CHUNK_RAW_BYTES),
8061        ).is_ok());
8062        assert!(HarnessMcpReplyChunkHexV1::new(
8063            "00".repeat(MAX_HARNESS_MCP_REPLY_CHUNK_RAW_BYTES + 1),
8064        ).is_err());
8065
8066        let event = NodeEvent::HarnessMcpReadCall {
8067            reservation_id: reservation_id.clone(),
8068            activation_digest: digest.clone(),
8069            record_id: SessionRecordId::new("record-a").unwrap(),
8070            session: session_address("primary", 1),
8071            call_id,
8072            request: HarnessMcpOpaquePayloadV1 {
8073                content_type: HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
8074                body: br#"{"kind":"context-get"}"#.to_vec(),
8075            },
8076            deadline_unix_ms: 4_000,
8077        };
8078        assert!(event.harness_mcp_contract_is_valid_at(1_000));
8079        assert!(!event.harness_mcp_contract_is_valid_at(999));
8080        let request = NodeRequest::AbortHarnessMcpReservation {
8081            reservation_id,
8082            activation_digest: digest,
8083        };
8084        assert_eq!(request.required_capability(), Some(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY));
8085        let json = serde_json::to_string(&event).unwrap();
8086        assert!(!json.contains("g4ah3_"));
8087        assert!(!json.contains("endpoint"));
8088        assert!(!json.contains("path"));
8089    }
8090
8091    #[test]
8092    fn task_id_is_fixed_opaque_and_bounded() {
8093        let task_id = TaskId::from_nonce([
8094            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xff,
8095        ]);
8096        assert_eq!(task_id.as_str(), "task-00112233445566778899aaff");
8097        assert_eq!(task_id.as_str().len(), 29);
8098        assert_eq!(task_id.as_str().parse::<TaskId>().unwrap(), task_id);
8099        assert!("task-00112233445566778899aaf".parse::<TaskId>().is_err());
8100        assert!("task-00112233445566778899aaff00".parse::<TaskId>().is_err());
8101        assert!("task-00112233445566778899aaFF".parse::<TaskId>().is_err());
8102        assert!("work-00112233445566778899aaff".parse::<TaskId>().is_err());
8103        assert!(serde_json::from_str::<TaskId>(
8104            r#""task-00112233445566778899aaff-extra""#,
8105        )
8106        .is_err());
8107    }
8108
8109    fn agent(value: &str) -> AgentId {
8110        AgentId::new(value).unwrap()
8111    }
8112
8113    fn host_path(value: &str) -> OpaqueHostPath {
8114        OpaqueHostPath::utf8(value.to_owned()).unwrap()
8115    }
8116
8117    fn repository_path(value: &str) -> RepositoryPath {
8118        RepositoryPath::utf8(value.to_owned()).unwrap()
8119    }
8120
8121    fn session_address(workspace_id: &str, instance_id: u64) -> SessionAddress {
8122        SessionAddress {
8123            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
8124            session: SessionKey {
8125                instance_id: AgentInstanceId(instance_id),
8126                generation: SessionGeneration(1),
8127            },
8128        }
8129    }
8130
8131    fn context_receipt(
8132        id: &str,
8133        source_session: SessionAddress,
8134    ) -> ResolvedContextPackReceipt {
8135        ResolvedContextPackReceipt {
8136            id: SpawnContextId::new(id).unwrap(),
8137            digest: SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).unwrap(),
8138            lineage: ContextPackLineageReceipt {
8139                source_node_id: NodeId::new("node-a").unwrap(),
8140                source_session,
8141                source_provider: agent("claude"),
8142            },
8143            source_message_count: 3,
8144            retained_message_count: 2,
8145            byte_len: 32,
8146            truncated: true,
8147        }
8148    }
8149
8150    fn portable_node_support() -> NodeCompatibilitySupport {
8151        NodeCompatibilitySupport {
8152            build_stamp: BUILD_STAMP.to_owned(),
8153            capabilities: vec![
8154                CapabilityId::new("workspace.inspect").unwrap(),
8155                CapabilityId::new("session.spawn").unwrap(),
8156            ],
8157            host: HostDescriptor {
8158                operating_system: OperatingSystemId::new("windows").unwrap(),
8159                architecture: ArchitectureId::new("x86_64").unwrap(),
8160            },
8161            path_semantics: PathSemantics {
8162                style: PathStyle::Windows,
8163                encoding: PathEncoding::Utf8,
8164            },
8165            local_transport: LocalTransportKind::WindowsNamedPipe,
8166            state_schema: StateSchemaSupport {
8167                versions: ProtocolRange::new(3, 5).unwrap(),
8168            },
8169            provider_contracts: vec![ProviderContractSupport {
8170                provider: agent("codex"),
8171                revision: ProviderContractRevision::new("codex.2026-08").unwrap(),
8172            }],
8173            provider_adapter_contracts: vec![ProviderAdapterContractSupport {
8174                provider: agent("codex"),
8175                family: AdapterFamily::PtySemantic,
8176                adapter_id: AdapterId::new("codex-cli").unwrap(),
8177                revision: AdapterContractRevision::new("pty-semantic-v1").unwrap(),
8178            }],
8179        }
8180    }
8181
8182    #[test]
8183    fn legacy_hello_json_carries_the_build_stamp() {
8184        let client = ClientHello::new(ClientRole::Observer, [0; NODE_AUTH_NONCE_BYTES]);
8185        assert_eq!(
8186            serde_json::to_string(&client).unwrap(),
8187            format!(
8188                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]}}"#,
8189            ),
8190        );
8191
8192        let challenge = ServerChallenge {
8193            build_stamp: BUILD_STAMP.to_owned(),
8194            server_nonce: [0; NODE_AUTH_NONCE_BYTES],
8195            server_proof: [0; NODE_AUTH_PROOF_BYTES],
8196            compatibility: None,
8197        };
8198        let json = serde_json::to_string(&challenge).unwrap();
8199        assert!(!json.contains("compatibility"));
8200        assert_eq!(serde_json::from_str::<ServerChallenge>(&json).unwrap(), challenge);
8201    }
8202
8203    #[test]
8204    fn legacy_hello_omits_compatibility_instead_of_synthesizing_a_selection() {
8205        let hello = ClientHello::new(ClientRole::Observer, [0; NODE_AUTH_NONCE_BYTES]);
8206        assert_eq!(hello.compatibility, None);
8207    }
8208
8209    #[test]
8210    fn legacy_spawn_json_remains_exact_after_spawn_spec() {
8211        let request = NodeRequest::Spawn {
8212            workspace_id: WorkspaceId::new("primary").unwrap(),
8213            provider: agent("claude"),
8214            mode: SessionMode::Pty,
8215            terminal_size: TerminalSize {
8216                rows: 24,
8217                columns: 80,
8218            },
8219            initial_prompt: None,
8220        };
8221        assert_eq!(
8222            serde_json::to_string(&request).unwrap(),
8223            r#"{"kind":"spawn","workspace_id":"primary","provider":"claude","mode":"pty","terminal_size":{"rows":24,"columns":80},"initial_prompt":null}"#,
8224        );
8225        assert_eq!(request.required_capability(), None);
8226
8227        let response = NodeResponse::SpawnAccepted {
8228            session: SessionAddress {
8229                workspace_id: WorkspaceId::new("primary").unwrap(),
8230                session: SessionKey {
8231                    instance_id: AgentInstanceId(7),
8232                    generation: SessionGeneration(1),
8233                },
8234            },
8235        };
8236        assert_eq!(
8237            serde_json::to_string(&response).unwrap(),
8238            r#"{"kind":"spawn-accepted","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}}}"#,
8239        );
8240    }
8241
8242    #[test]
8243    fn history_loaded_completed_turn_count_is_optional_wire_metadata() {
8244        let legacy_json = r#"{"kind":"history-loaded","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}},"session_id":"session-7","message_count":12}"#;
8245        let legacy = serde_json::from_str::<NodeResponse>(legacy_json).unwrap();
8246        assert_eq!(serde_json::to_string(&legacy).unwrap(), legacy_json);
8247        assert!(matches!(
8248            legacy,
8249            NodeResponse::HistoryLoaded {
8250                completed_turn_count: None,
8251                ..
8252            }
8253        ));
8254
8255        let current = NodeResponse::HistoryLoaded {
8256            session: SessionAddress {
8257                workspace_id: WorkspaceId::new("primary").unwrap(),
8258                session: SessionKey {
8259                    instance_id: AgentInstanceId(7),
8260                    generation: SessionGeneration(1),
8261                },
8262            },
8263            session_id: "session-7".to_owned(),
8264            message_count: 12,
8265            completed_turn_count: Some(5),
8266        };
8267        assert_eq!(
8268            serde_json::to_string(&current).unwrap(),
8269            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}"#,
8270        );
8271    }
8272
8273    #[test]
8274    fn spawn_spec_defaults_overrides_are_deterministic() {
8275        let profile_id = SpawnProfileId::new("review-default").unwrap();
8276        let defaults = SpawnProfileDefaults {
8277            profile_id: profile_id.clone(),
8278            revision: SpawnProfileRevision::new("review-default.r3").unwrap(),
8279            provider: agent("claude"),
8280            mode: SessionMode::Pty,
8281            terminal_size: TerminalSize {
8282                rows: 24,
8283                columns: 80,
8284            },
8285            prompt: Some(SpawnPrompt::new("profile prompt").unwrap()),
8286            bundle_id: Some(SpawnBundleId::new("review-bundle").unwrap()),
8287            context_id: Some(SpawnContextId::new("repo-context").unwrap()),
8288            environment_profile_id: Some(
8289                SpawnEnvironmentProfileId::new("local-default").unwrap(),
8290            ),
8291        };
8292        let spec = SpawnSpec {
8293            target: SpawnTarget {
8294                node_id: NodeId::new("node-a").unwrap(),
8295                workspace_id: WorkspaceId::new("primary").unwrap(),
8296                worktree_id: Some(WorkspaceId::new("review-tree").unwrap()),
8297            },
8298            profile_id,
8299            expected_profile_revision: defaults.revision.clone(),
8300            overrides: SpawnOverrides {
8301                provider: SpawnOverride::Inherit,
8302                mode: SpawnOverride::Set {
8303                    value: SessionMode::Inline,
8304                },
8305                terminal_size: SpawnOverride::Set {
8306                    value: TerminalSize {
8307                        rows: 31,
8308                        columns: 97,
8309                    },
8310                },
8311                prompt: SpawnOverride::Set {
8312                    value: SpawnPrompt::new("private prompt text").unwrap(),
8313                },
8314                bundle_id: SpawnOverride::Clear,
8315                context_id: SpawnOverride::Inherit,
8316                environment_profile_id: SpawnOverride::Clear,
8317                approval_level: None,
8318                network_allowlist: None,
8319                browser_profile_id: None,
8320            },
8321            deadline_ms: SpawnDeadlineMs::new(30_000).unwrap(),
8322            idempotency_key: SpawnIdempotencyKey::new("request-0001").unwrap(),
8323            required_capabilities: SpawnRequiredCapabilities::new([
8324                CapabilityId::new(SPAWN_RUNTIME_STRUCTURED_PROMPT).unwrap(),
8325                CapabilityId::new(SPAWN_RUNTIME_RAW_PTY_LIFECYCLE).unwrap(),
8326            ])
8327            .unwrap(),
8328        };
8329
8330        let first = spec.resolve(&defaults).unwrap();
8331        let second = spec.resolve(&defaults).unwrap();
8332        assert_eq!(first, second);
8333        assert_eq!(first.provider, agent("claude"));
8334        assert_eq!(first.mode, SessionMode::Inline);
8335        assert_eq!(first.terminal_size.rows, 31);
8336        assert_eq!(first.prompt.as_ref().unwrap().as_str(), "private prompt text");
8337        assert_eq!(first.bundle_id, None);
8338        assert_eq!(
8339            first.context_id.as_ref().map(SpawnContextId::as_str),
8340            Some("repo-context"),
8341        );
8342        assert_eq!(first.environment_profile_id, None);
8343        assert_eq!(first.network_allowlist, None);
8344        assert_eq!(first.browser_profile_id, None);
8345        assert_eq!(first.provenance.provider, SpawnFieldProvenance::Profile);
8346        assert_eq!(first.provenance.mode, SpawnFieldProvenance::Override);
8347        assert_eq!(first.provenance.prompt, SpawnFieldProvenance::Override);
8348        assert_eq!(first.provenance.bundle_id, SpawnFieldProvenance::Cleared);
8349        assert_eq!(first.provenance.context_id, SpawnFieldProvenance::Profile);
8350        assert_eq!(
8351            first.required_capabilities.as_slice(),
8352            &[
8353                CapabilityId::new(SPAWN_RUNTIME_RAW_PTY_LIFECYCLE).unwrap(),
8354                CapabilityId::new(SPAWN_RUNTIME_STRUCTURED_PROMPT).unwrap(),
8355            ],
8356        );
8357
8358        let source_session = session_address("primary", 7);
8359        let receipt = first.receipt_with_materialization(
8360            NodeIncarnationId::from_bytes([9; NODE_INCARNATION_ID_BYTES]),
8361            session_address("review-tree", 11),
8362            None,
8363            None,
8364            Some(context_receipt("repo-context", source_session)),
8365        );
8366        assert_eq!(receipt.prompt, SpawnPromptMetadata {
8367            present: true,
8368            byte_len: 19,
8369        });
8370        let receipt_json = serde_json::to_string(&receipt).unwrap();
8371        assert_eq!(
8372            receipt_json,
8373            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"}}"#,
8374        );
8375        assert_eq!(
8376            serde_json::from_str::<ResolvedSpawnReceipt>(&receipt_json).unwrap(),
8377            receipt,
8378        );
8379        assert!(!receipt_json.contains("private prompt text"));
8380        assert!(!receipt_json.contains("profile prompt"));
8381
8382        let environment_profile = ResolvedEnvironmentProfileReceipt {
8383            profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8384            profile_revision: SpawnEnvironmentProfileRevision::new(
8385                "local-default.2026-08",
8386            )
8387            .unwrap(),
8388            network_allowlist: None,
8389            browser_profile_id: None,
8390        };
8391        let environment_receipt = first.receipt_with_materialization(
8392            NodeIncarnationId::from_bytes([9; NODE_INCARNATION_ID_BYTES]),
8393            receipt.session.clone(),
8394            Some(environment_profile.clone()),
8395            None,
8396            receipt.context.clone(),
8397        );
8398        let environment_json = serde_json::to_string(&environment_receipt).unwrap();
8399        assert!(environment_json.contains(
8400            r#""environment_profile_id":{"profile_id":"local-default","profile_revision":"local-default.2026-08"}"#,
8401        ));
8402        assert_eq!(
8403            serde_json::from_str::<ResolvedSpawnReceipt>(&environment_json)
8404                .unwrap()
8405                .environment_profile,
8406            Some(environment_profile),
8407        );
8408        assert!(serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8409            r#"{"profile_id":"local-default","profile_revision":"r1","value":"secret"}"#,
8410        )
8411        .is_err());
8412        assert!(SpawnEnvironmentProfileRevision::new(
8413            "r".repeat(MAX_SPAWN_ENVIRONMENT_PROFILE_REVISION_BYTES),
8414        )
8415        .is_ok());
8416        assert!(SpawnEnvironmentProfileRevision::new(
8417            "r".repeat(MAX_SPAWN_ENVIRONMENT_PROFILE_REVISION_BYTES + 1),
8418        )
8419        .is_err());
8420
8421        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
8422            .requires_child_environment_profile_capability());
8423        let mut explicit_environment = spec.clone();
8424        explicit_environment.overrides.environment_profile_id = SpawnOverride::Set {
8425            value: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8426        };
8427        assert!(NodeRequest::SpawnSpec {
8428            spec: explicit_environment,
8429        }
8430        .requires_child_environment_profile_capability());
8431        let mut inherited_environment = spec.clone();
8432        inherited_environment.overrides.environment_profile_id = SpawnOverride::Inherit;
8433        assert!(!NodeRequest::SpawnSpec {
8434            spec: inherited_environment,
8435        }
8436        .requires_child_environment_profile_capability());
8437
8438        let mut clear_required = spec.clone();
8439        clear_required.overrides.provider = SpawnOverride::Clear;
8440        assert!(matches!(
8441            clear_required.resolve(&defaults),
8442            Err(SpawnSpecResolveError::RequiredFieldCleared { field: "provider" }),
8443        ));
8444
8445        let mut stale_revision = spec.clone();
8446        stale_revision.expected_profile_revision =
8447            SpawnProfileRevision::new("review-default.r2").unwrap();
8448        assert!(matches!(
8449            stale_revision.resolve(&defaults),
8450            Err(SpawnSpecResolveError::ProfileRevisionMismatch {
8451                expected,
8452                loaded,
8453            }) if expected.as_str() == "review-default.r2"
8454                && loaded.as_str() == "review-default.r3",
8455        ));
8456
8457        let missing_revision_json = r#"{"target":{"node_id":"node-a","workspace_id":"primary"},"profile_id":"review-default","deadline_ms":1,"idempotency_key":"request-0002"}"#;
8458        assert!(serde_json::from_str::<SpawnSpec>(missing_revision_json).is_err());
8459        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"}"#;
8460        let minimal = serde_json::from_str::<SpawnSpec>(minimal_json).unwrap();
8461        assert_eq!(minimal.overrides, SpawnOverrides::default());
8462        assert!(minimal.required_capabilities.is_empty());
8463        let mut unknown_field = serde_json::to_value(&minimal).unwrap();
8464        unknown_field["profile_revision"] = serde_json::json!("review-default.r3");
8465        assert!(serde_json::from_value::<SpawnSpec>(unknown_field).is_err());
8466        assert!(SpawnDeadlineMs::new(MAX_SPAWN_DEADLINE_MS + 1).is_err());
8467        assert!(SpawnProfileId::new("unsafe/profile").is_err());
8468        assert!(serde_json::from_str::<SpawnOverride<AgentId>>(
8469            r#"{"kind":"set","value":"claude","typo":true}"#,
8470        )
8471        .is_err());
8472    }
8473
8474    /// Station knobs slice (plan
8475    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §4): optional
8476    /// `network_allowlist` + `browser_profile_id` on `SpawnOverrides` and
8477    /// `ResolvedEnvironmentProfileReceipt` round-trip as opaque ids only.
8478    /// Omitted when None; secret-looking unknown fields are refused.
8479    /// Empty/whitespace ids refuse. Dig2browser-station bind / reachability
8480    /// refuse is node-side (stubbed until a cheap local probe exists).
8481    #[test]
8482    fn station_network_and_browser_profile_knobs_serde_ids_only() {
8483        let allowlist = SpawnNetworkAllowlistId::new("egress-default").unwrap();
8484        let browser = SpawnBrowserProfileId::new("station-profile-a").unwrap();
8485        assert_eq!(allowlist.as_str(), "egress-default");
8486        assert_eq!(browser.as_str(), "station-profile-a");
8487        assert!(SpawnNetworkAllowlistId::new("").is_err());
8488        assert!(SpawnBrowserProfileId::new("").is_err());
8489        assert!(SpawnNetworkAllowlistId::new(" ").is_err());
8490        assert!(SpawnBrowserProfileId::new("\t").is_err());
8491        assert!(SpawnNetworkAllowlistId::new("\n").is_err());
8492        assert!(SpawnBrowserProfileId::new("bad/id").is_err());
8493        assert!(SpawnNetworkAllowlistId::new(
8494            "x".repeat(MAX_SPAWN_RESOURCE_ID_BYTES + 1),
8495        )
8496        .is_err());
8497
8498        let overrides = SpawnOverrides {
8499            network_allowlist: Some(allowlist.clone()),
8500            browser_profile_id: Some(browser.clone()),
8501            ..SpawnOverrides::default()
8502        };
8503        let overrides_json = serde_json::to_string(&overrides).unwrap();
8504        assert_eq!(
8505            overrides_json,
8506            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"}"#,
8507        );
8508        assert!(!overrides_json.contains("cookie"));
8509        assert!(!overrides_json.contains("oauth"));
8510        assert!(!overrides_json.contains("password"));
8511        assert_eq!(
8512            serde_json::from_str::<SpawnOverrides>(&overrides_json).unwrap(),
8513            overrides,
8514        );
8515
8516        let omitted = serde_json::to_string(&SpawnOverrides::default()).unwrap();
8517        assert!(!omitted.contains("network_allowlist"));
8518        assert!(!omitted.contains("browser_profile_id"));
8519        assert_eq!(
8520            serde_json::from_str::<SpawnOverrides>(r#"{}"#).unwrap(),
8521            SpawnOverrides::default(),
8522        );
8523
8524        let mut unknown = serde_json::to_value(&overrides).unwrap();
8525        unknown["proxy_password"] = serde_json::json!("nope");
8526        assert!(serde_json::from_value::<SpawnOverrides>(unknown).is_err());
8527        let mut cookie_field = serde_json::to_value(&overrides).unwrap();
8528        cookie_field["cookie_bytes"] = serde_json::json!("deadbeef");
8529        assert!(serde_json::from_value::<SpawnOverrides>(cookie_field).is_err());
8530
8531        let receipt = ResolvedEnvironmentProfileReceipt {
8532            profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8533            profile_revision: SpawnEnvironmentProfileRevision::new("r1").unwrap(),
8534            network_allowlist: Some(allowlist),
8535            browser_profile_id: Some(browser),
8536        };
8537        let receipt_json = serde_json::to_string(&receipt).unwrap();
8538        assert_eq!(
8539            receipt_json,
8540            r#"{"profile_id":"local-default","profile_revision":"r1","network_allowlist":"egress-default","browser_profile_id":"station-profile-a"}"#,
8541        );
8542        assert_eq!(
8543            serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(&receipt_json).unwrap(),
8544            receipt,
8545        );
8546
8547        let bare = ResolvedEnvironmentProfileReceipt {
8548            profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8549            profile_revision: SpawnEnvironmentProfileRevision::new("r1").unwrap(),
8550            network_allowlist: None,
8551            browser_profile_id: None,
8552        };
8553        assert_eq!(
8554            serde_json::to_string(&bare).unwrap(),
8555            r#"{"profile_id":"local-default","profile_revision":"r1"}"#,
8556        );
8557        assert_eq!(
8558            serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8559                r#"{"profile_id":"local-default","profile_revision":"r1"}"#,
8560            )
8561            .unwrap(),
8562            bare,
8563        );
8564        assert!(serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8565            r#"{"profile_id":"local-default","profile_revision":"r1","cookie":"x"}"#,
8566        )
8567        .is_err());
8568        assert!(serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8569            r#"{"profile_id":"local-default","profile_revision":"r1","oauth_token":"x"}"#,
8570        )
8571        .is_err());
8572        // Empty / whitespace ids refuse at type + serde (not silent ambient).
8573        assert!(serde_json::from_str::<SpawnOverrides>(
8574            r#"{"network_allowlist":""}"#,
8575        )
8576        .is_err());
8577        assert!(serde_json::from_str::<SpawnOverrides>(
8578            r#"{"browser_profile_id":" "}"#,
8579        )
8580        .is_err());
8581    }
8582
8583    /// `SpawnSpec::resolve` copies station knobs onto `ResolvedSpawnSpec` so
8584    /// node resolve can echo them onto `ResolvedEnvironmentProfileReceipt`.
8585    /// Empty/whitespace already refused at id construction; no dig2browser
8586    /// station probe in this crate.
8587    #[test]
8588    fn spawn_spec_resolve_echoes_station_network_and_browser_profile_knobs() {
8589        let profile_id = SpawnProfileId::new("review-default").unwrap();
8590        let defaults = SpawnProfileDefaults {
8591            profile_id: profile_id.clone(),
8592            revision: SpawnProfileRevision::new("review-default.r3").unwrap(),
8593            provider: agent("claude"),
8594            mode: SessionMode::Pty,
8595            terminal_size: TerminalSize {
8596                rows: 24,
8597                columns: 80,
8598            },
8599            prompt: None,
8600            bundle_id: None,
8601            context_id: None,
8602            environment_profile_id: Some(
8603                SpawnEnvironmentProfileId::new("local-default").unwrap(),
8604            ),
8605        };
8606        let allowlist = SpawnNetworkAllowlistId::new("egress-default").unwrap();
8607        let browser = SpawnBrowserProfileId::new("station-profile-a").unwrap();
8608        let spec = SpawnSpec {
8609            target: SpawnTarget {
8610                node_id: NodeId::new("node-a").unwrap(),
8611                workspace_id: WorkspaceId::new("primary").unwrap(),
8612                worktree_id: None,
8613            },
8614            profile_id,
8615            expected_profile_revision: defaults.revision.clone(),
8616            overrides: SpawnOverrides {
8617                network_allowlist: Some(allowlist.clone()),
8618                browser_profile_id: Some(browser.clone()),
8619                ..SpawnOverrides::default()
8620            },
8621            deadline_ms: SpawnDeadlineMs::new(30_000).unwrap(),
8622            idempotency_key: SpawnIdempotencyKey::new("request-station-knobs").unwrap(),
8623            required_capabilities: SpawnRequiredCapabilities::default(),
8624        };
8625        let resolved = spec.resolve(&defaults).unwrap();
8626        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
8627        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
8628        assert_eq!(
8629            resolved
8630                .environment_profile_id
8631                .as_ref()
8632                .map(SpawnEnvironmentProfileId::as_str),
8633            Some("local-default"),
8634        );
8635
8636        let omitted = SpawnSpec {
8637            overrides: SpawnOverrides::default(),
8638            idempotency_key: SpawnIdempotencyKey::new("request-station-knobs-omit").unwrap(),
8639            ..spec.clone()
8640        };
8641        let resolved_omit = omitted.resolve(&defaults).unwrap();
8642        assert_eq!(resolved_omit.network_allowlist, None);
8643        assert_eq!(resolved_omit.browser_profile_id, None);
8644    }
8645
8646    #[test]
8647    fn session_bundle_materialization_contract_is_bounded_exact_and_dual_gated() {
8648        assert_eq!(NODE_STATE_SCHEMA_V7, 7);
8649        assert_eq!(NODE_STATE_SCHEMA_V8, 8);
8650        assert_eq!(
8651            NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
8652            "session-bundle-materialization-v1",
8653        );
8654        let capability =
8655            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
8656        assert!(production_node_client_compatibility_offer()
8657            .capabilities
8658            .contains(&capability));
8659        let mut support = portable_node_support();
8660        support.capabilities = vec![capability.clone()];
8661        let offer = ClientCompatibilityOffer {
8662            build_stamp: BUILD_STAMP.to_owned(),
8663            capabilities: vec![capability.clone()],
8664            state_schema: None,
8665        };
8666        let selected = support
8667            .negotiate(&offer)
8668            .unwrap();
8669        assert_eq!(selected.capabilities, vec![capability]);
8670        let bound = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8671        assert!(bound
8672            .windows(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.len())
8673            .any(|window| {
8674                window == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.as_bytes()
8675            }));
8676        assert!(SpawnBundleRevision::new(
8677            "r".repeat(MAX_SPAWN_BUNDLE_REVISION_BYTES),
8678        )
8679        .is_ok());
8680        assert!(SpawnBundleRevision::new(
8681            "r".repeat(MAX_SPAWN_BUNDLE_REVISION_BYTES + 1),
8682        )
8683        .is_err());
8684
8685        let digest = format!("sha256:{}", "a".repeat(64));
8686        let receipt = ResolvedBundleReceipt {
8687            id: SpawnBundleId::new("review-bundle").unwrap(),
8688            revision: SpawnBundleRevision::new("review-bundle.r1").unwrap(),
8689            digest: SpawnBundleDigest::new(&digest).unwrap(),
8690        };
8691        assert_eq!(
8692            serde_json::to_string(&receipt).unwrap(),
8693            format!(
8694                r#"{{"id":"review-bundle","revision":"review-bundle.r1","digest":"{digest}"}}"#,
8695            ),
8696        );
8697        for invalid in [
8698            format!("sha256:{}", "a".repeat(63)),
8699            format!("sha256:{}", "A".repeat(64)),
8700            "sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
8701                .to_owned(),
8702        ] {
8703            assert!(SpawnBundleDigest::new(invalid).is_err());
8704        }
8705
8706        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"}"#;
8707        let inherited = serde_json::from_str::<SpawnSpec>(minimal_json).unwrap();
8708        assert!(NodeRequest::SpawnSpec {
8709            spec: inherited.clone(),
8710        }
8711        .requires_session_bundle_materialization_capability());
8712
8713        let mut explicit = inherited.clone();
8714        explicit.overrides.bundle_id = SpawnOverride::Set {
8715            value: SpawnBundleId::new("review-bundle").unwrap(),
8716        };
8717        assert!(NodeRequest::SpawnSpec { spec: explicit }
8718            .requires_session_bundle_materialization_capability());
8719
8720        let mut cleared = inherited;
8721        cleared.overrides.bundle_id = SpawnOverride::Clear;
8722        assert!(!NodeRequest::SpawnSpec { spec: cleared }
8723            .requires_session_bundle_materialization_capability());
8724    }
8725
8726    #[test]
8727    fn provider_ids_preserve_legacy_json_and_accept_open_values() {
8728        for (provider, expected) in [
8729            (agent("claude"), r#""claude""#),
8730            (agent("codex"), r#""codex""#),
8731            (agent("kimi"), r#""kimi""#),
8732        ] {
8733            assert!(provider_id_is_legacy(&provider));
8734            assert_eq!(serde_json::to_string(&provider).unwrap(), expected);
8735            assert_eq!(serde_json::from_str::<AgentId>(expected).unwrap(), provider);
8736        }
8737
8738        let open = agent("third-party-agent");
8739        assert!(!provider_id_is_legacy(&open));
8740        assert_eq!(serde_json::to_string(&open).unwrap(), r#""third-party-agent""#);
8741    }
8742
8743    #[test]
8744    fn provider_runtime_wire_is_exact_bounded_and_consistent() {
8745        let statuses = ProviderRuntimeStatuses::new([
8746            ProviderRuntimeStatus::raw_passthrough(
8747                agent("claude"),
8748                Some(ProviderRuntimeVersion::new("2.1.220").unwrap()),
8749            ),
8750            ProviderRuntimeStatus::verified_semantic(
8751                agent("codex"),
8752                ProviderRuntimeVersion::new("0.147.0").unwrap(),
8753                ProviderRuntimeContractId::new("codex.windows-x86_64.0.147.0").unwrap(),
8754            ),
8755            ProviderRuntimeStatus::unavailable(agent("kimi")),
8756        ])
8757        .unwrap();
8758        let encoded = serde_json::to_string(&statuses).unwrap();
8759        assert_eq!(
8760            encoded,
8761            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"}]"#,
8762        );
8763        assert_eq!(
8764            serde_json::from_str::<ProviderRuntimeStatuses>(&encoded).unwrap(),
8765            statuses,
8766        );
8767
8768        let duplicate = r#"[{"provider":"codex","mode":"unavailable"},{"provider":"codex","mode":"unavailable"}]"#;
8769        assert!(serde_json::from_str::<ProviderRuntimeStatuses>(duplicate).is_err());
8770        let too_many = (0..=MAX_PROVIDER_RUNTIME_STATUSES)
8771            .map(|index| ProviderRuntimeStatus::unavailable(agent(&format!("provider-{index}"))))
8772            .collect::<Vec<_>>();
8773        assert!(matches!(
8774            ProviderRuntimeStatuses::new(too_many),
8775            Err(ProviderRuntimeStatusError::TooMany {
8776                max: MAX_PROVIDER_RUNTIME_STATUSES,
8777            }),
8778        ));
8779        let inconsistent = r#"[{"provider":"codex","mode":"verified-semantic","version":"0.147.0"}]"#;
8780        assert!(serde_json::from_str::<ProviderRuntimeStatuses>(inconsistent).is_err());
8781        for arbitrary in ["secret-token", "raw.stdout", "1.2", "01.2.3", "1.2.3.4"] {
8782            assert!(ProviderRuntimeVersion::new(arbitrary).is_err(), "accepted {arbitrary}");
8783        }
8784        let overflow = format!(
8785            r#"[{{"provider":"codex","mode":"raw-passthrough","version":"{}"}}]"#,
8786            "1".repeat(MAX_PROVIDER_RUNTIME_VERSION_BYTES + 1),
8787        );
8788        assert!(serde_json::from_str::<ProviderRuntimeStatuses>(&overflow).is_err());
8789    }
8790
8791    #[test]
8792    fn legacy_node_snapshot_defaults_runtime_status_to_unreported() {
8793        let legacy = r#"{"node_id":"legacy-node","enabled_providers":["codex"],"workspaces":[]}"#;
8794        let snapshot = serde_json::from_str::<NodeSnapshot>(legacy).unwrap();
8795        assert!(snapshot.provider_runtime_statuses.is_empty());
8796        let reencoded = serde_json::to_string(&snapshot).unwrap();
8797        assert!(!reencoded.contains("provider_runtime"));
8798    }
8799
8800    #[test]
8801    fn compatibility_negotiation_keeps_the_build_stamp_and_selects_highest_state_schema() {
8802        let offer = ClientCompatibilityOffer {
8803            build_stamp: BUILD_STAMP.to_owned(),
8804            capabilities: vec![
8805                CapabilityId::new("session.spawn").unwrap(),
8806                CapabilityId::new("unknown.future").unwrap(),
8807            ],
8808            state_schema: Some(StateSchemaSupport {
8809                versions: ProtocolRange::new(4, 6).unwrap(),
8810            }),
8811        };
8812        let hello = ClientHello::negotiating(
8813            ClientRole::Operator,
8814            [1; NODE_AUTH_NONCE_BYTES],
8815            offer.clone(),
8816        );
8817        assert_eq!(hello.compatibility, Some(offer.clone()));
8818
8819        let support = portable_node_support();
8820        let negotiated = support.negotiate(&offer).unwrap();
8821        assert_eq!(negotiated.build_stamp, BUILD_STAMP);
8822        assert_eq!(negotiated.state_schema_version, Some(5));
8823        assert_eq!(
8824            negotiated.capabilities,
8825            vec![CapabilityId::new("session.spawn").unwrap()],
8826        );
8827        assert!(negotiated.provider_contracts.is_empty());
8828        assert!(negotiated.provider_adapter_contracts.is_empty());
8829    }
8830
8831    #[test]
8832    fn negotiate_rejects_a_foreign_build_stamp_naming_both_values() {
8833        let foreign_stamp = "f".repeat(40);
8834        let offer = ClientCompatibilityOffer {
8835            build_stamp: foreign_stamp.clone(),
8836            capabilities: Vec::new(),
8837            state_schema: None,
8838        };
8839        let error = portable_node_support().negotiate(&offer).unwrap_err();
8840        assert!(matches!(
8841            &error,
8842            ProtocolNegotiationError::BuildStampMismatch { local, remote }
8843                if local == BUILD_STAMP && remote == &foreign_stamp,
8844        ));
8845        assert_eq!(
8846            error.to_string(),
8847            format!("build stamp mismatch: local={BUILD_STAMP} remote={foreign_stamp}"),
8848        );
8849    }
8850
8851    #[test]
8852    fn compatibility_auth_binding_has_an_exact_bounded_encoding() {
8853        let offer = ClientCompatibilityOffer {
8854            build_stamp: BUILD_STAMP.to_owned(),
8855            capabilities: vec![CapabilityId::new("session.spawn").unwrap()],
8856            state_schema: Some(StateSchemaSupport {
8857                versions: ProtocolRange::new(4, 6).unwrap(),
8858            }),
8859        };
8860        let support = portable_node_support();
8861        let selected = support
8862            .negotiate(&offer)
8863            .unwrap();
8864        let encoded = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8865        assert_eq!(
8866            String::from_utf8(encoded).unwrap(),
8867            format!(
8868                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":[]}}}}"#,
8869            ),
8870        );
8871    }
8872
8873    #[test]
8874    fn provider_contract_manifest_is_capability_gated_and_auth_bound_exactly() {
8875        let manifest_capability =
8876            CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap();
8877        let mut support = portable_node_support();
8878        support.capabilities.push(manifest_capability.clone());
8879        let offer = ClientCompatibilityOffer {
8880            build_stamp: BUILD_STAMP.to_owned(),
8881            capabilities: vec![manifest_capability.clone()],
8882            state_schema: None,
8883        };
8884        let selected = support
8885            .negotiate(&offer)
8886            .unwrap();
8887        assert_eq!(selected.capabilities, vec![manifest_capability]);
8888        assert_eq!(selected.provider_contracts, support.provider_contracts);
8889        assert_eq!(
8890            selected.provider_adapter_contracts,
8891            support.provider_adapter_contracts,
8892        );
8893        let encoded = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8894        assert_eq!(
8895            String::from_utf8(encoded).unwrap(),
8896            format!(
8897                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"}}]}}}}"#,
8898            ),
8899        );
8900    }
8901
8902    #[test]
8903    fn open_provider_capability_and_manifest_are_auth_bound_exactly() {
8904        let manifest_capability =
8905            CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap();
8906        let open_capability = CapabilityId::new(NODE_PROVIDER_ID_OPEN_CAPABILITY).unwrap();
8907        let mut support = portable_node_support();
8908        support.capabilities = vec![manifest_capability.clone(), open_capability.clone()];
8909        support.provider_contracts = vec![ProviderContractSupport {
8910            provider: agent("third-party-agent"),
8911            revision: ProviderContractRevision::new("third-party.2026-08").unwrap(),
8912        }];
8913        support.provider_adapter_contracts.clear();
8914        let offer = ClientCompatibilityOffer {
8915            build_stamp: BUILD_STAMP.to_owned(),
8916            capabilities: vec![manifest_capability, open_capability],
8917            state_schema: None,
8918        };
8919        let selected = support
8920            .negotiate(&offer)
8921            .unwrap();
8922        let encoded = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8923        assert_eq!(
8924            String::from_utf8(encoded).unwrap(),
8925            format!(
8926                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"}}]}}}}"#,
8927            ),
8928        );
8929    }
8930
8931    #[test]
8932    fn n_minus_one_selected_json_round_trips_without_manifest_fields() {
8933        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":[]}"#;
8934        let selected: NegotiatedNodeCompatibility = serde_json::from_str(json).unwrap();
8935        assert!(selected.provider_contracts.is_empty());
8936        assert!(selected.provider_adapter_contracts.is_empty());
8937        assert_eq!(serde_json::to_string(&selected).unwrap(), json);
8938    }
8939
8940    #[test]
8941    fn provider_contract_manifest_rejects_bounds_duplicates_and_unlinked_entries() {
8942        let provider = ProviderContractSupport {
8943            provider: agent("codex"),
8944            revision: ProviderContractRevision::new("codex.2026-08").unwrap(),
8945        };
8946        let adapter = ProviderAdapterContractSupport {
8947            provider: agent("codex"),
8948            family: AdapterFamily::PtySemantic,
8949            adapter_id: AdapterId::new("codex-cli").unwrap(),
8950            revision: AdapterContractRevision::new("pty-semantic-v1").unwrap(),
8951        };
8952        assert!(matches!(
8953            validate_provider_contract_manifest(
8954                &vec![provider.clone(); MAX_PROVIDER_CONTRACTS + 1],
8955                &[],
8956            ),
8957            Err(ProviderContractManifestError::TooManyProviders { .. }),
8958        ));
8959        assert!(matches!(
8960            validate_provider_contract_manifest(
8961                &[provider.clone()],
8962                &vec![adapter.clone(); MAX_PROVIDER_ADAPTER_CONTRACTS + 1],
8963            ),
8964            Err(ProviderContractManifestError::TooManyAdapterContracts { .. }),
8965        ));
8966        assert!(matches!(
8967            validate_provider_contract_manifest(
8968                &[provider.clone()],
8969                &vec![adapter.clone(); MAX_PROVIDER_ADAPTER_CONTRACTS],
8970            ),
8971            Err(ProviderContractManifestError::DuplicateProviderFamily { .. }),
8972        ));
8973        assert!(matches!(
8974            validate_provider_contract_manifest(&[provider.clone(), provider.clone()], &[]),
8975            Err(ProviderContractManifestError::DuplicateProvider { provider })
8976                if provider == agent("codex"),
8977        ));
8978        let mut unlinked = adapter.clone();
8979        unlinked.provider = agent("claude");
8980        assert!(matches!(
8981            validate_provider_contract_manifest(&[provider.clone()], &[unlinked]),
8982            Err(ProviderContractManifestError::UnlinkedAdapterProvider { provider })
8983                if provider == agent("claude"),
8984        ));
8985        let mut duplicate_family = adapter.clone();
8986        duplicate_family.adapter_id = AdapterId::new("codex-cli-next").unwrap();
8987        assert!(matches!(
8988            validate_provider_contract_manifest(
8989                &[provider.clone()],
8990                &[adapter.clone(), duplicate_family],
8991            ),
8992            Err(ProviderContractManifestError::DuplicateProviderFamily {
8993                provider,
8994                family: AdapterFamily::PtySemantic,
8995            }) if provider == agent("codex"),
8996        ));
8997        let mut shared_adapter_id = adapter.clone();
8998        shared_adapter_id.family = AdapterFamily::History;
8999        assert!(validate_provider_contract_manifest(
9000            &[provider],
9001            &[adapter, shared_adapter_id],
9002        )
9003        .is_ok());
9004    }
9005
9006    #[test]
9007    fn provider_contract_manifest_negotiates_all_current_provider_family_tuples() {
9008        let providers = [
9009            (agent("claude"), "claude-code", "claude.2026-08"),
9010            (agent("codex"), "codex-cli", "codex.2026-08"),
9011            (agent("kimi"), "kimi-cli", "kimi.2026-08"),
9012        ];
9013        let families = [
9014            AdapterFamily::PtySemantic,
9015            AdapterFamily::Pipe,
9016            AdapterFamily::OneShot,
9017            AdapterFamily::Acp,
9018            AdapterFamily::Hook,
9019            AdapterFamily::ManagedHook,
9020            AdapterFamily::History,
9021            AdapterFamily::Resume,
9022            AdapterFamily::SessionOptions,
9023            AdapterFamily::CapabilityProbe,
9024        ];
9025        let provider_contracts = providers
9026            .iter()
9027            .map(|(provider, _, revision)| ProviderContractSupport {
9028                provider: provider.clone(),
9029                revision: ProviderContractRevision::new(*revision).unwrap(),
9030            })
9031            .collect::<Vec<_>>();
9032        let provider_adapter_contracts = providers
9033            .iter()
9034            .flat_map(|(provider, adapter_id, _)| {
9035                families
9036                    .iter()
9037                    .map(move |family| ProviderAdapterContractSupport {
9038                        provider: provider.clone(),
9039                        family: *family,
9040                        adapter_id: AdapterId::new(*adapter_id).unwrap(),
9041                        revision: AdapterContractRevision::new("adapter-contract-v1").unwrap(),
9042                    })
9043            })
9044            .collect::<Vec<_>>();
9045        assert_eq!(provider_contracts.len(), 3);
9046        assert_eq!(provider_adapter_contracts.len(), 30);
9047        assert!(provider_adapter_contracts.len() <= MAX_PROVIDER_ADAPTER_CONTRACTS);
9048
9049        let manifest_capability =
9050            CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap();
9051        let mut support = portable_node_support();
9052        support.capabilities.push(manifest_capability.clone());
9053        support.provider_contracts = provider_contracts;
9054        support.provider_adapter_contracts = provider_adapter_contracts;
9055        let selected = support
9056            .negotiate(&ClientCompatibilityOffer {
9057                build_stamp: BUILD_STAMP.to_owned(),
9058                capabilities: vec![manifest_capability],
9059                state_schema: None,
9060            })
9061            .unwrap();
9062        assert_eq!(selected.provider_contracts.len(), 3);
9063        assert_eq!(selected.provider_adapter_contracts.len(), 30);
9064    }
9065
9066    #[test]
9067    fn sixteen_provider_manifest_headroom_fits_the_authenticated_handshake() {
9068        assert_eq!(MAX_PROVIDER_IDENTITIES, 16);
9069        let providers = (0..MAX_PROVIDER_IDENTITIES)
9070            .map(|index| agent(&format!("provider-{index}")))
9071            .collect::<Vec<_>>();
9072        let mut support = portable_node_support();
9073        support.capabilities.extend([
9074            CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap(),
9075            CapabilityId::new(NODE_PROVIDER_ID_OPEN_CAPABILITY).unwrap(),
9076        ]);
9077        support.provider_contracts = providers
9078            .iter()
9079            .map(|provider| ProviderContractSupport {
9080                provider: provider.clone(),
9081                revision: ProviderContractRevision::new(format!(
9082                    "{}.2026-08",
9083                    provider.as_str(),
9084                ))
9085                .unwrap(),
9086            })
9087            .collect();
9088        support.provider_adapter_contracts = providers
9089            .iter()
9090            .map(|provider| ProviderAdapterContractSupport {
9091                provider: provider.clone(),
9092                family: AdapterFamily::PtySemantic,
9093                adapter_id: AdapterId::new(format!("{}-cli", provider.as_str())).unwrap(),
9094                revision: AdapterContractRevision::new("pty-semantic-v1").unwrap(),
9095            })
9096            .collect();
9097
9098        validate_node_negotiated_handshake_capacity(&support).unwrap();
9099    }
9100
9101    #[test]
9102    fn adapter_contract_revision_is_bounded_printable_ascii() {
9103        assert!(AdapterContractRevision::new("history-jsonl-v1").is_ok());
9104        assert!(AdapterContractRevision::new("").is_err());
9105        assert!(AdapterContractRevision::new("revision with spaces").is_err());
9106        assert!(AdapterContractRevision::new(
9107            "x".repeat(MAX_ADAPTER_CONTRACT_REVISION_BYTES + 1),
9108        )
9109        .is_err());
9110    }
9111
9112    #[test]
9113    fn protocol_ranges_reject_invalid_and_disjoint_inputs() {
9114        assert!(matches!(
9115            ProtocolRange::new(0, 8),
9116            Err(ProtocolNegotiationError::InvalidRange { minimum: 0, maximum: 8 }),
9117        ));
9118        assert!(matches!(
9119            ProtocolRange::new(9, 8),
9120            Err(ProtocolNegotiationError::InvalidRange { minimum: 9, maximum: 8 }),
9121        ));
9122        let error = ProtocolRange::new(7, 8)
9123            .unwrap()
9124            .highest_common(ProtocolRange::new(9, 10).unwrap())
9125            .unwrap_err();
9126        assert!(matches!(error, ProtocolNegotiationError::Disjoint { .. }));
9127        assert!(serde_json::from_str::<ProtocolRange>(
9128            r#"{"minimum":10,"maximum":9}"#,
9129        )
9130        .is_err());
9131        assert!(ProviderContractRevision::new(
9132            "gate4agent-inline/codex-cli-0.144/v1",
9133        )
9134        .is_ok());
9135        assert!(ProviderContractRevision::new(
9136            "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42",
9137        )
9138        .is_ok());
9139    }
9140
9141    #[test]
9142    fn foreign_path_metadata_round_trips_without_normalization() {
9143        #[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
9144        struct ForeignPath {
9145            raw: String,
9146            semantics: PathSemantics,
9147        }
9148
9149        let foreign = ForeignPath {
9150            raw: r"C:\Users\operator\repo\src\lib.rs".to_owned(),
9151            semantics: portable_node_support().path_semantics,
9152        };
9153        let json = serde_json::to_string(&foreign).unwrap();
9154        assert!(json.contains(r#""raw":"C:\\Users\\operator\\repo\\src\\lib.rs""#));
9155        assert_eq!(serde_json::from_str::<ForeignPath>(&json).unwrap(), foreign);
9156    }
9157
9158    #[test]
9159    fn opaque_host_path_utf8_preserves_legacy_json_string_shape() {
9160        let path = host_path(r"C:\Users\operator\repo");
9161        assert_eq!(path.byte_len(), 22);
9162        assert_eq!(path.as_utf8(), Some(r"C:\Users\operator\repo"));
9163        assert_eq!(path.as_unix_bytes(), None);
9164        assert_eq!(path.display_text(), r"C:\Users\operator\repo");
9165
9166        let json = serde_json::to_string(&path).unwrap();
9167        assert_eq!(json, r#""C:\\Users\\operator\\repo""#);
9168        assert_eq!(serde_json::from_str::<OpaqueHostPath>(&json).unwrap(), path);
9169    }
9170
9171    #[test]
9172    fn opaque_host_path_unix_bytes_has_strict_bounded_tagged_wire_shape() {
9173        let raw = vec![b'/', b'r', b'e', b'p', b'o', b'/', 0xff];
9174        let path = OpaqueHostPath::unix_bytes(raw.clone()).unwrap();
9175        assert_eq!(path.byte_len(), raw.len());
9176        assert_eq!(path.as_utf8(), None);
9177        assert_eq!(path.as_unix_bytes(), Some(raw.as_slice()));
9178
9179        let json = serde_json::to_string(&path).unwrap();
9180        assert_eq!(
9181            json,
9182            r#"{"kind":"unix-bytes","bytes":[47,114,101,112,111,47,255]}"#,
9183        );
9184        assert_eq!(serde_json::from_str::<OpaqueHostPath>(&json).unwrap(), path);
9185
9186        for invalid in [
9187            r#"{"kind":"future","bytes":[47]}"#,
9188            r#"{"kind":"unix-bytes","bytes":[47],"extra":true}"#,
9189            r#"{"kind":"unix-bytes"}"#,
9190            r#"{"bytes":[47]}"#,
9191            r#"{"kind":"unix-bytes","bytes":[]}"#,
9192            r#"{"kind":"unix-bytes","bytes":[47,0]}"#,
9193        ] {
9194            assert!(serde_json::from_str::<OpaqueHostPath>(invalid).is_err(), "{invalid}");
9195        }
9196        assert!(OpaqueHostPath::utf8(String::new()).is_err());
9197        assert!(OpaqueHostPath::utf8("a\0b".to_owned()).is_err());
9198        assert!(OpaqueHostPath::utf8("x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1)).is_err());
9199        assert!(OpaqueHostPath::unix_bytes(vec![b'x'; MAX_WORKSPACE_ROOT_BYTES + 1]).is_err());
9200    }
9201
9202    #[test]
9203    fn repository_path_utf8_preserves_legacy_json_string_shape_and_typed_components() {
9204        let path = repository_path(r"src\literal-name/lib.rs");
9205        assert_eq!(path.byte_len(), 23);
9206        assert_eq!(path.as_utf8(), Some(r"src\literal-name/lib.rs"));
9207        assert_eq!(path.as_unix_bytes(), None);
9208        assert_eq!(path.display_text(), r"src\literal-name/lib.rs");
9209        assert_eq!(path.component_count(), 2);
9210        assert_eq!(path.depth(), 1);
9211        assert_eq!(path.file_name_bytes(), b"lib.rs");
9212        assert_eq!(path.file_name_display_text(), "lib.rs");
9213        assert!(path.is_descendant_of(&repository_path(r"src\literal-name")));
9214        assert!(!path.is_descendant_of(&repository_path("src")));
9215
9216        let json = serde_json::to_string(&path).unwrap();
9217        assert_eq!(json, r#""src\\literal-name/lib.rs""#);
9218        assert_eq!(serde_json::from_str::<RepositoryPath>(&json).unwrap(), path);
9219    }
9220
9221    #[test]
9222    fn repository_path_unix_bytes_has_strict_bounded_tagged_wire_shape() {
9223        let raw = vec![b's', b'r', b'c', b'/', 0xff, b'/', b'f'];
9224        let path = RepositoryPath::unix_bytes(raw.clone()).unwrap();
9225        assert_eq!(path.byte_len(), raw.len());
9226        assert_eq!(path.as_bytes(), raw);
9227        assert_eq!(path.as_utf8(), None);
9228        assert_eq!(path.as_unix_bytes(), Some(raw.as_slice()));
9229        assert_eq!(path.component_count(), 3);
9230        assert_eq!(path.depth(), 2);
9231        assert_eq!(path.file_name_bytes(), b"f");
9232        assert!(path.is_descendant_of(
9233            &RepositoryPath::unix_bytes(vec![b's', b'r', b'c', b'/', 0xff]).unwrap(),
9234        ));
9235
9236        let json = serde_json::to_string(&path).unwrap();
9237        assert_eq!(
9238            json,
9239            r#"{"kind":"unix-bytes","bytes":[115,114,99,47,255,47,102]}"#,
9240        );
9241        assert_eq!(serde_json::from_str::<RepositoryPath>(&json).unwrap(), path);
9242    }
9243
9244    #[test]
9245    fn repository_path_physical_identity_ignores_wire_representation() {
9246        use std::collections::{BTreeSet, HashSet};
9247
9248        let utf8 = repository_path("src/main.rs");
9249        let tagged = RepositoryPath::unix_bytes(b"src/main.rs".to_vec()).unwrap();
9250        assert_eq!(utf8, tagged);
9251        assert_eq!(utf8.cmp(&tagged), Ordering::Equal);
9252        assert_eq!(utf8.as_unix_bytes(), None);
9253        assert_eq!(tagged.as_unix_bytes(), Some(b"src/main.rs".as_slice()));
9254        assert_eq!(serde_json::to_string(&utf8).unwrap(), r#""src/main.rs""#);
9255        assert_eq!(
9256            serde_json::to_string(&tagged).unwrap(),
9257            r#"{"kind":"unix-bytes","bytes":[115,114,99,47,109,97,105,110,46,114,115]}"#,
9258        );
9259
9260        let mut ordered = BTreeSet::new();
9261        ordered.insert(utf8.clone());
9262        ordered.insert(tagged.clone());
9263        assert_eq!(ordered.len(), 1);
9264
9265        let mut hashed = HashSet::new();
9266        hashed.insert(utf8);
9267        hashed.insert(tagged);
9268        assert_eq!(hashed.len(), 1);
9269
9270        let backslash = repository_path(r"src\main.rs");
9271        let slash = repository_path("src/main.rs");
9272        assert_ne!(backslash, slash);
9273        assert_ne!(backslash.cmp(&slash), Ordering::Equal);
9274    }
9275
9276    #[test]
9277    fn repository_path_rejects_non_canonical_or_unbounded_components() {
9278        for invalid in [
9279            "",
9280            "/",
9281            "/src",
9282            "src/",
9283            "src//lib.rs",
9284            ".",
9285            "..",
9286            "./src",
9287            "src/.",
9288            "../src",
9289            "src/..",
9290            "src\0lib.rs",
9291        ] {
9292            assert!(RepositoryPath::utf8(invalid.to_owned()).is_err(), "{invalid:?}");
9293        }
9294        assert!(RepositoryPath::utf8("x".repeat(MAX_REPOSITORY_PATH_BYTES + 1)).is_err());
9295        assert!(RepositoryPath::unix_bytes(vec![b'x'; MAX_REPOSITORY_PATH_BYTES + 1]).is_err());
9296
9297        for invalid in [
9298            r#"{"kind":"future","bytes":[115]}"#,
9299            r#"{"kind":"unix-bytes","bytes":[115],"extra":true}"#,
9300            r#"{"kind":"unix-bytes"}"#,
9301            r#"{"bytes":[115]}"#,
9302            r#"{"kind":"unix-bytes","bytes":[]}"#,
9303            r#"{"kind":"unix-bytes","bytes":[47,115]}"#,
9304            r#"{"kind":"unix-bytes","bytes":[115,47,46,46]}"#,
9305        ] {
9306            assert!(serde_json::from_str::<RepositoryPath>(invalid).is_err(), "{invalid}");
9307        }
9308    }
9309
9310    #[test]
9311    fn repository_path_fields_preserve_legacy_utf8_wire_bytes_and_default_rename_source() {
9312        let entry = WorkspaceEntry {
9313            relative_path: repository_path("src/lib.rs"),
9314            kind: WorkspaceEntryKind::File,
9315        };
9316        assert_eq!(
9317            serde_json::to_string(&entry).unwrap(),
9318            r#"{"relative_path":"src/lib.rs","kind":"file"}"#,
9319        );
9320
9321        let legacy_status =
9322            r#"{"index_status":"R","worktree_status":" ","path":"src/new.rs"}"#;
9323        let status = serde_json::from_str::<GitStatusEntry>(legacy_status).unwrap();
9324        assert_eq!(status.path, repository_path("src/new.rs"));
9325        assert_eq!(status.previous_path, None);
9326        assert_eq!(serde_json::to_string(&status).unwrap(), legacy_status);
9327
9328        let renamed = GitStatusEntry {
9329            previous_path: Some(repository_path("src/old.rs")),
9330            ..status
9331        };
9332        assert_eq!(
9333            serde_json::to_string(&renamed).unwrap(),
9334            r#"{"index_status":"R","worktree_status":" ","path":"src/new.rs","previous_path":"src/old.rs"}"#,
9335        );
9336    }
9337
9338    #[tokio::test]
9339    async fn json_frame_round_trips_a_client_hello_without_the_access_token() {
9340        let expected = ClientFrame::Hello(ClientHello::new(ClientRole::Operator, [7; NODE_AUTH_NONCE_BYTES]));
9341        let mut wire = Vec::new();
9342        write_json_frame(&mut wire, &expected).await.unwrap();
9343
9344        assert!(!String::from_utf8_lossy(&wire).contains("local-token"));
9345
9346        let actual: ClientFrame = read_json_frame(&mut wire.as_slice()).await.unwrap();
9347        assert_eq!(actual, expected);
9348    }
9349
9350    #[tokio::test]
9351    async fn reader_rejects_zero_length_before_allocating() {
9352        let bytes = 0_u32.to_le_bytes();
9353        let mut wire = bytes.as_slice();
9354        let error = read_json_frame::<_, ClientFrame>(&mut wire).await.unwrap_err();
9355        assert!(matches!(
9356            error,
9357            FrameError::InvalidLength { length: 0, max: MAX_NODE_FRAME_BYTES }
9358        ));
9359    }
9360
9361    #[tokio::test]
9362    async fn hello_reader_rejects_an_oversized_frame_before_allocating() {
9363        let declared = (MAX_NODE_HELLO_FRAME_BYTES + 1) as u32;
9364        let bytes = declared.to_le_bytes();
9365        let mut wire = bytes.as_slice();
9366        let error = read_json_frame_limited::<_, ClientFrame>(
9367            &mut wire,
9368            MAX_NODE_HELLO_FRAME_BYTES,
9369        )
9370        .await
9371        .unwrap_err();
9372        assert!(matches!(
9373            error,
9374            FrameError::InvalidLength {
9375                length,
9376                max: MAX_NODE_HELLO_FRAME_BYTES
9377            } if length == MAX_NODE_HELLO_FRAME_BYTES + 1
9378        ));
9379    }
9380
9381    #[tokio::test]
9382    async fn client_writer_enforces_the_smaller_request_limit() {
9383        let oversized = ClientFrame::Request(RequestEnvelope {
9384            request_id: 1,
9385            request: NodeRequest::Input {
9386                session: SessionAddress {
9387                    workspace_id: WorkspaceId::new("primary").unwrap(),
9388                    session: SessionKey {
9389                        instance_id: AgentInstanceId(1),
9390                        generation: SessionGeneration(1),
9391                    },
9392                },
9393                text: "x".repeat(MAX_NODE_CLIENT_FRAME_BYTES),
9394            },
9395        });
9396        let mut wire = Vec::new();
9397        let error = write_json_frame_limited(
9398            &mut wire,
9399            &oversized,
9400            MAX_NODE_CLIENT_FRAME_BYTES,
9401        )
9402        .await
9403        .unwrap_err();
9404        assert!(matches!(
9405            error,
9406            FrameError::InvalidLength {
9407                length,
9408                max: MAX_NODE_CLIENT_FRAME_BYTES
9409            } if length > MAX_NODE_CLIENT_FRAME_BYTES
9410        ));
9411        assert!(wire.is_empty());
9412    }
9413
9414    #[tokio::test]
9415    async fn maximum_node_text_fits_the_client_frame_under_worst_case_json_escaping() {
9416        let frame = ClientFrame::Request(RequestEnvelope {
9417            request_id: 2,
9418            request: NodeRequest::Paste {
9419                session: SessionAddress {
9420                    workspace_id: WorkspaceId::new("primary").unwrap(),
9421                    session: SessionKey {
9422                        instance_id: AgentInstanceId(1),
9423                        generation: SessionGeneration(1),
9424                    },
9425                },
9426                text: "\0".repeat(MAX_NODE_TEXT_BYTES),
9427            },
9428        });
9429        let mut wire = Vec::new();
9430        write_json_frame_limited(&mut wire, &frame, MAX_NODE_CLIENT_FRAME_BYTES)
9431            .await
9432            .unwrap();
9433        assert!(wire.len() <= MAX_NODE_CLIENT_FRAME_BYTES + std::mem::size_of::<u32>());
9434    }
9435
9436    #[tokio::test]
9437    async fn body_timeout_starts_after_the_bounded_length_prefix() {
9438        let (mut writer, mut reader) = tokio::io::duplex(64);
9439        writer.write_u32_le(32).await.unwrap();
9440        writer.write_all(b"{").await.unwrap();
9441        let error = read_json_frame_limited_body_timeout::<_, ClientFrame>(
9442            &mut reader,
9443            MAX_NODE_CLIENT_FRAME_BYTES,
9444            Duration::from_millis(10),
9445        )
9446        .await
9447        .unwrap_err();
9448        assert!(matches!(error, FrameError::BodyTimedOut { length: 32 }));
9449    }
9450
9451    #[tokio::test]
9452    async fn partial_length_prefix_cannot_pin_a_connection_slot() {
9453        let (mut writer, mut reader) = tokio::io::duplex(64);
9454        writer.write_all(&[32]).await.unwrap();
9455        let error = read_json_frame_limited_body_timeout::<_, ClientFrame>(
9456            &mut reader,
9457            MAX_NODE_CLIENT_FRAME_BYTES,
9458            Duration::from_millis(10),
9459        )
9460        .await
9461        .unwrap_err();
9462        assert!(matches!(error, FrameError::PrefixTimedOut));
9463    }
9464
9465    #[test]
9466    fn resume_wire_does_not_accept_a_replacement_working_directory() {
9467        let frame = ClientFrame::Request(RequestEnvelope {
9468            request_id: 9,
9469            request: NodeRequest::Resume {
9470                session: SessionAddress {
9471                    workspace_id: WorkspaceId::new("primary").unwrap(),
9472                    session: SessionKey {
9473                        instance_id: AgentInstanceId(3),
9474                        generation: SessionGeneration(2),
9475                    },
9476                },
9477                terminal_size: TerminalSize { rows: 24, columns: 80 },
9478                initial_prompt: Some("continue".to_owned()),
9479            },
9480        });
9481        let json = serde_json::to_string(&frame).unwrap();
9482        assert!(!json.contains("working_directory"));
9483
9484        let mut malicious = serde_json::to_value(match frame {
9485            ClientFrame::Request(envelope) => envelope.request,
9486            _ => unreachable!("constructed request frame"),
9487        })
9488        .unwrap();
9489        malicious
9490            .as_object_mut()
9491            .unwrap()
9492            .insert(
9493                "working_directory".to_owned(),
9494                serde_json::Value::String(r"C:\attacker-selected-root".to_owned()),
9495            );
9496        let error = serde_json::from_value::<NodeRequest>(malicious).unwrap_err();
9497        assert!(error.to_string().contains("unknown field `working_directory`"));
9498    }
9499
9500    #[test]
9501    fn history_context_pack_wire_is_bounded_path_free_and_auth_bound() {
9502        assert_eq!(NODE_HISTORY_CONTEXT_PACK_CAPABILITY, "history-context-pack-v1");
9503        let capability = CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
9504        assert!(production_node_client_compatibility_offer()
9505            .capabilities
9506            .contains(&capability));
9507        let mut support = portable_node_support();
9508        support.capabilities = vec![capability.clone()];
9509        let offer = ClientCompatibilityOffer {
9510            build_stamp: BUILD_STAMP.to_owned(),
9511            capabilities: vec![capability.clone()],
9512            state_schema: None,
9513        };
9514        let selected = support.negotiate(&offer).unwrap();
9515        assert_eq!(selected.capabilities, vec![capability]);
9516        let binding = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
9517        assert!(binding
9518            .windows(NODE_HISTORY_CONTEXT_PACK_CAPABILITY.len())
9519            .any(|window| window == NODE_HISTORY_CONTEXT_PACK_CAPABILITY.as_bytes()));
9520
9521        let request = NodeRequest::DiscoverHistory {
9522            session: session_address("primary", 3),
9523            limit: HISTORY_DISCOVERY_LIMIT_MAX,
9524        };
9525        assert_eq!(
9526            request.required_capability(),
9527            Some(NODE_HISTORY_CONTEXT_PACK_CAPABILITY),
9528        );
9529        assert!(request.history_context_pack_contract_is_valid());
9530        let json = serde_json::to_string(&request).unwrap();
9531        assert!(!json.contains("working_directory"));
9532        assert!(!json.contains("path"));
9533        assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
9534
9535        let mut invalid = serde_json::to_value(&request).unwrap();
9536        invalid["limit"] = serde_json::Value::from(0);
9537        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9538        assert!(!NodeRequest::DiscoverHistory {
9539            session: session_address("primary", 3),
9540            limit: 0,
9541        }
9542        .history_context_pack_contract_is_valid());
9543        assert!(!NodeRequest::LoadHistory {
9544            session: session_address("primary", 3),
9545            candidate_id: String::new(),
9546        }
9547        .history_context_pack_contract_is_valid());
9548        let mut invalid = serde_json::to_value(&request).unwrap();
9549        invalid["limit"] = serde_json::Value::from(u64::from(HISTORY_DISCOVERY_LIMIT_MAX) + 1);
9550        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9551        let mut invalid = serde_json::to_value(&request).unwrap();
9552        invalid["working_directory"] =
9553            serde_json::Value::String(r"C:\attacker-selected-root".to_owned());
9554        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9555
9556        let response = NodeResponse::HistoryDiscovered {
9557            session: session_address("primary", 3),
9558            candidates: vec![HistoryCandidateSummary {
9559                id: "candidate-1".to_owned(),
9560                session_id_hint: "session-1".to_owned(),
9561                modified_at_unix_ms: Some(1),
9562            }],
9563        };
9564        assert!(response.requires_history_context_pack_capability());
9565        let response_json = serde_json::to_string(&response).unwrap();
9566        assert!(!response_json.contains("messages"));
9567        assert!(!response_json.contains("working_directory"));
9568        assert!(!response_json.contains("path"));
9569        assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
9570    }
9571
9572    #[test]
9573    fn session_record_context_export_wire_is_exact_private_and_capability_bound() {
9574        assert_eq!(
9575            NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
9576            "session-record-context-export-v1",
9577        );
9578        let capability =
9579            CapabilityId::new(NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).unwrap();
9580        assert!(production_node_client_compatibility_offer()
9581            .capabilities
9582            .contains(&capability));
9583        let session = session_address("primary", 19);
9584        let record_id = SessionRecordId::new("record-19").unwrap();
9585        let request = NodeRequest::ExportContextPackForSessionRecord {
9586            record_id: record_id.clone(),
9587            session: session.clone(),
9588        };
9589        assert_eq!(
9590            request.required_capability(),
9591            Some(NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY),
9592        );
9593        assert!(request.requires_history_context_pack_capability());
9594        let request_json = serde_json::to_string(&request).unwrap();
9595        assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
9596        for private in [
9597            "candidate_id",
9598            "session_id_hint",
9599            "provider_session",
9600            "working_directory",
9601            "path",
9602            "model",
9603            "messages",
9604        ] {
9605            assert!(!request_json.contains(private), "request leaked {private}");
9606        }
9607
9608        let response = NodeResponse::ContextPackForSessionRecordExported {
9609            record_id,
9610            session: session.clone(),
9611            context: context_receipt("context-record-19", session),
9612        };
9613        assert!(response.requires_history_context_pack_capability());
9614        assert!(response.requires_session_record_context_export_capability());
9615        let response_json = serde_json::to_string(&response).unwrap();
9616        assert_eq!(
9617            serde_json::from_str::<NodeResponse>(&response_json).unwrap(),
9618            response,
9619        );
9620        for private in [
9621            "candidate_id",
9622            "session_id_hint",
9623            "provider_session",
9624            "working_directory",
9625            "path",
9626            "model",
9627            "messages",
9628        ] {
9629            assert!(!response_json.contains(private), "response leaked {private}");
9630        }
9631    }
9632
9633    #[test]
9634    fn native_session_catalog_wire_is_bounded_metadata_only() {
9635        assert_eq!(NODE_NATIVE_SESSION_CATALOG_CAPABILITY, "native-session-catalog-v2");
9636        let route = NativeSessionCatalogRoute::workspace(
9637            WorkspaceId::new("primary").unwrap(),
9638            agent("codex"),
9639        );
9640        let request = NodeRequest::CatalogNativeSessions {
9641            route: route.clone(),
9642            limit: NATIVE_SESSION_CATALOG_LIMIT_MAX,
9643        };
9644        assert_eq!(
9645            request.required_capability(),
9646            Some(NODE_NATIVE_SESSION_CATALOG_CAPABILITY)
9647        );
9648        assert!(request.native_session_catalog_contract_is_valid());
9649        let mut invalid = serde_json::to_value(&request).unwrap();
9650        invalid["limit"] = serde_json::Value::from(65);
9651        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9652
9653        let response = NodeResponse::NativeSessionsCataloged {
9654            route: route.clone(),
9655            entries: vec![NativeSessionCatalogEntry {
9656                selection_id: "hist_selection_1".to_owned(),
9657                title: Some("Review".to_owned()),
9658                modified_at_unix_ms: Some(9),
9659                model: Some("model-1".to_owned()),
9660                message_count: 4,
9661                completed_turn_count: Some(2),
9662                external_group: None,
9663                record_id: Some(SessionRecordId::new("record-1").unwrap()),
9664            }],
9665            summary: Some(NativeSessionCatalogSummary {
9666                catalog_revision: 7,
9667                recent_cutoff_unix_ms: 8,
9668                recent_total_count: 1,
9669                older_total_count: 2,
9670                recent_next_after_selection_id: None,
9671                recent_has_more: false,
9672            }),
9673        };
9674        assert!(response.requires_native_session_catalog_capability());
9675        assert!(response.native_session_catalog_contract_is_valid());
9676        let json = serde_json::to_string(&response).unwrap();
9677        for forbidden in ["session_id", "stable-session", "cwd", "candidate", "path", "messages", "tokens", "documents", "raw"] {
9678            assert!(!json.contains(forbidden));
9679        }
9680        assert_eq!(serde_json::from_str::<NodeResponse>(&json).unwrap(), response);
9681        assert!(serde_json::from_str::<NodeResponse>(
9682            r#"{"kind":"native-sessions-cataloged","workspace_id":"primary","provider":"codex","entries":[]}"#,
9683        )
9684        .is_err());
9685        let mut injected = serde_json::to_value(&response).unwrap();
9686        injected["entries"][0]["title"] = serde_json::Value::String("safe\nunsafe".to_owned());
9687        assert!(serde_json::from_value::<NodeResponse>(injected).is_err());
9688
9689        let external = NodeResponse::NativeSessionsCataloged {
9690            route: NativeSessionCatalogRoute::unregistered(agent("codex")),
9691            entries: vec![NativeSessionCatalogEntry {
9692                selection_id: "external_selection_1".to_owned(),
9693                title: None,
9694                modified_at_unix_ms: Some(9),
9695                model: None,
9696                message_count: 0,
9697                completed_turn_count: None,
9698                external_group: Some(NativeSessionExternalGroup {
9699                    group_id: "external-0001".to_owned(),
9700                    kind: gate4agent_types::NativeSessionExternalGroupKind::Project,
9701                    display_name: "shared".to_owned(),
9702                }),
9703                record_id: None,
9704            }],
9705            summary: Some(NativeSessionCatalogSummary {
9706                catalog_revision: 9,
9707                recent_cutoff_unix_ms: 8,
9708                recent_total_count: 1,
9709                older_total_count: 0,
9710                recent_next_after_selection_id: None,
9711                recent_has_more: false,
9712            }),
9713        };
9714        assert!(external.native_session_catalog_contract_is_valid());
9715        let external_json = serde_json::to_string(&external).unwrap();
9716        assert!(!external_json.contains("project-"));
9717        for hostile in [r"C:\private", "/srv/private", "..", "nested/path"] {
9718            let mut injected = serde_json::to_value(&external).unwrap();
9719            injected["entries"][0]["external_group"]["display_name"] =
9720                serde_json::Value::String(hostile.to_owned());
9721            assert!(serde_json::from_value::<NodeResponse>(injected).is_err());
9722        }
9723
9724        let page_request = NodeRequest::PageNativeSessions {
9725            route: route.clone(),
9726            window: NativeSessionCatalogWindow::Older,
9727            catalog_revision: 7,
9728            recent_cutoff_unix_ms: 8,
9729            after_selection_id: Some("hist_selection_1".to_owned()),
9730            limit: 1,
9731        };
9732        assert_eq!(
9733            page_request.required_capability(),
9734            Some(NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY),
9735        );
9736        let paged = NodeResponse::NativeSessionsPaged {
9737            route,
9738            page: NativeSessionCatalogPage {
9739                window: NativeSessionCatalogWindow::Older,
9740                revision: 7,
9741                entries: Vec::new(),
9742                next_after_selection_id: None,
9743                remaining_count: 0,
9744                has_more: false,
9745            },
9746        };
9747        assert!(paged.requires_native_session_catalog_paging_capability());
9748        assert!(paged.native_session_catalog_contract_is_valid());
9749    }
9750
9751    #[test]
9752    fn native_session_preview_wire_is_bounded_and_record_projection_is_redacted() {
9753        assert_eq!(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY, "native-session-preview-v2");
9754        let selection = NativeSessionSelection {
9755            route: NativeSessionCatalogRoute::workspace(
9756                WorkspaceId::new("primary").unwrap(),
9757                agent("claude"),
9758            ),
9759            catalog_revision: 7,
9760            recent_cutoff_unix_ms: 8,
9761            selection_id: "hist_selection_1".to_owned(),
9762        };
9763        let request = NodeRequest::PreviewNativeSession {
9764            selection: selection.clone(),
9765            message_limit: NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX,
9766        };
9767        assert_eq!(
9768            request.required_capability(),
9769            Some(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY)
9770        );
9771        assert!(request.native_session_preview_contract_is_valid());
9772        let mut invalid = serde_json::to_value(&request).unwrap();
9773        invalid["message_limit"] = serde_json::Value::from(25);
9774        assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9775
9776        let preview = NativeSessionPreview {
9777            title: Some("Review".to_owned()),
9778            modified_at_unix_ms: Some(9),
9779            model: Some("model-1".to_owned()),
9780            message_count: 8,
9781            message_count_exact: true,
9782            completed_turn_count: Some(4),
9783            total_tokens: None,
9784            truncated: true,
9785            messages: vec![gate4agent_types::NativeSessionPreviewMessage {
9786                role: gate4agent_types::HistoryMessageRole::Assistant,
9787                text: "visible answer".to_owned(),
9788            }],
9789        };
9790        let response = NodeResponse::NativeSessionPreviewed {
9791            selection,
9792            preview,
9793        };
9794        assert!(response.requires_native_session_preview_capability());
9795        let json = serde_json::to_string(&response).unwrap();
9796        for forbidden in ["native-secret-id", "cwd", "path", "tokens", "tool_result", "thinking"] {
9797            assert!(!json.contains(forbidden));
9798        }
9799        assert_eq!(serde_json::from_str::<NodeResponse>(&json).unwrap(), response);
9800    }
9801
9802    #[test]
9803    fn context_receipts_are_nonempty_and_strictly_correlated() {
9804        assert!(SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).is_ok());
9805        assert!(SpawnContextDigest::new(format!("sha256:{}", "A".repeat(64))).is_err());
9806        let context = context_receipt("context-1", session_address("primary", 7));
9807        let record = ManagedSessionRecord {
9808            record_id: SessionRecordId::new("session-1").unwrap(),
9809            display_name: "review".to_owned(),
9810            provider: agent("claude"),
9811            mode: SessionMode::Pty,
9812            state: ManagedSessionState::Dormant,
9813            workspace_id: WorkspaceId::new("primary").unwrap(),
9814            canonical_root: host_path(r"C:\repo"),
9815            provider_session: None,
9816            active_session: None,
9817            environment_profile: None,
9818            bundle: None,
9819            context_id: Some(context.id.clone()),
9820            context: Some(context.clone()),
9821            exported_context: None,
9822            task_binding: None,
9823            created_at_unix_ms: 1,
9824            updated_at_unix_ms: 2,
9825            last_error: None,
9826        };
9827        assert!(record.context_binding_is_valid());
9828        let json = serde_json::to_value(&record).unwrap();
9829        assert_eq!(serde_json::from_value::<ManagedSessionRecord>(json.clone()).unwrap(), record);
9830
9831        let mut invalid_task_revision = json.clone();
9832        invalid_task_revision["task_binding"] = serde_json::json!({
9833            "revision": 0,
9834            "task_id": "task-00112233445566778899aaff",
9835            "changed_at_unix_ms": 1,
9836        });
9837        assert!(serde_json::from_value::<ManagedSessionRecord>(invalid_task_revision).is_err());
9838        let mut invalid_task_timestamp = json.clone();
9839        invalid_task_timestamp["task_binding"] = serde_json::json!({
9840            "revision": 1,
9841            "task_id": "task-00112233445566778899aaff",
9842            "changed_at_unix_ms": 3,
9843        });
9844        assert!(serde_json::from_value::<ManagedSessionRecord>(invalid_task_timestamp).is_err());
9845
9846        let mut metadata_without_id = json.clone();
9847        metadata_without_id["context_id"] = serde_json::Value::Null;
9848        assert!(serde_json::from_value::<ManagedSessionRecord>(metadata_without_id).is_err());
9849        let mut id_without_metadata = json.clone();
9850        id_without_metadata["context"] = serde_json::Value::Null;
9851        assert!(serde_json::from_value::<ManagedSessionRecord>(id_without_metadata).is_err());
9852        let mut mismatched = json.clone();
9853        mismatched["context_id"] = serde_json::Value::String("context-2".to_owned());
9854        assert!(serde_json::from_value::<ManagedSessionRecord>(mismatched).is_err());
9855        let mut missing_truncation = json.clone();
9856        missing_truncation["context"]["truncated"] = serde_json::Value::Bool(false);
9857        assert!(serde_json::from_value::<ManagedSessionRecord>(missing_truncation).is_err());
9858        let mut false_truncation = json.clone();
9859        false_truncation["context"]["retained_message_count"] =
9860            false_truncation["context"]["source_message_count"].clone();
9861        assert!(serde_json::from_value::<ManagedSessionRecord>(false_truncation).is_err());
9862        let mut empty = json;
9863        empty["context"]["retained_message_count"] = serde_json::Value::from(0);
9864        assert!(serde_json::from_value::<ManagedSessionRecord>(empty).is_err());
9865
9866        let mut with_exported_context = record.clone();
9867        with_exported_context.exported_context = Some(context.clone());
9868        assert!(with_exported_context.exported_context_is_valid());
9869        let exported_json = serde_json::to_value(&with_exported_context).unwrap();
9870        assert_eq!(
9871            serde_json::from_value::<ManagedSessionRecord>(exported_json.clone()).unwrap(),
9872            with_exported_context,
9873        );
9874        let mut mismatched_provider = exported_json;
9875        mismatched_provider["exported_context"]["lineage"]["source_provider"] =
9876            serde_json::Value::String("codex".to_owned());
9877        assert!(serde_json::from_value::<ManagedSessionRecord>(mismatched_provider).is_err());
9878
9879        let receipt = ResolvedSpawnReceipt {
9880            incarnation_id: NodeIncarnationId::from_bytes([9; NODE_INCARNATION_ID_BYTES]),
9881            session: session_address("primary", 8),
9882            target: SpawnTarget {
9883                node_id: NodeId::new("node-a").unwrap(),
9884                workspace_id: WorkspaceId::new("primary").unwrap(),
9885                worktree_id: None,
9886            },
9887            profile_id: SpawnProfileId::new("default").unwrap(),
9888            profile_revision: SpawnProfileRevision::new("default.r1").unwrap(),
9889            provider: agent("claude"),
9890            mode: SessionMode::Pty,
9891            terminal_size: TerminalSize { rows: 24, columns: 80 },
9892            prompt: SpawnPromptMetadata { present: false, byte_len: 0 },
9893            bundle_id: None,
9894            bundle: None,
9895            context_id: Some(context.id.clone()),
9896            context: Some(context),
9897            environment_profile: None,
9898            deadline_ms: SpawnDeadlineMs::new(5_000).unwrap(),
9899            idempotency_key: SpawnIdempotencyKey::new("spawn-1").unwrap(),
9900            required_capabilities: SpawnRequiredCapabilities::default(),
9901            provenance: SpawnResolutionProvenance {
9902                provider: SpawnFieldProvenance::Profile,
9903                mode: SpawnFieldProvenance::Profile,
9904                terminal_size: SpawnFieldProvenance::Profile,
9905                prompt: SpawnFieldProvenance::Profile,
9906                bundle_id: SpawnFieldProvenance::Profile,
9907                context_id: SpawnFieldProvenance::Profile,
9908                environment_profile_id: SpawnFieldProvenance::Profile,
9909            },
9910            harness_mcp_proxy: None,
9911        };
9912        assert!(receipt.context_binding_is_valid());
9913        let mut mismatched = serde_json::to_value(&receipt).unwrap();
9914        mismatched["context_id"] = serde_json::Value::String("context-2".to_owned());
9915        assert!(serde_json::from_value::<ResolvedSpawnReceipt>(mismatched).is_err());
9916    }
9917
9918    #[test]
9919    fn protocol_v9_workspace_and_worktree_mutations_have_exact_bounded_wire_shapes() {
9920        assert_eq!(MAX_WORKSPACE_ROOT_BYTES, gate4agent_types::WORKING_DIRECTORY_MAX_BYTES);
9921
9922        let register = NodeRequest::RegisterWorkspace {
9923            workspace_id: WorkspaceId::new("repo-2").unwrap(),
9924            root: host_path(r"C:\repo-2"),
9925        };
9926        let register_json = serde_json::to_string(&register).unwrap();
9927        assert_eq!(
9928            register_json,
9929            r#"{"kind":"register-workspace","workspace_id":"repo-2","root":"C:\\repo-2"}"#,
9930        );
9931        assert_eq!(serde_json::from_str::<NodeRequest>(&register_json).unwrap(), register);
9932
9933        let standalone = NodeRequest::CreateStandaloneWorkspace {
9934            workspace_id: WorkspaceId::new("independent").unwrap(),
9935            root: host_path(r"C:\independent"),
9936            initial_branch: Some("main".to_owned()),
9937        };
9938        let standalone_json = serde_json::to_string(&standalone).unwrap();
9939        assert_eq!(
9940            standalone_json,
9941            r#"{"kind":"create-standalone-workspace","workspace_id":"independent","root":"C:\\independent","initial_branch":"main"}"#,
9942        );
9943        assert_eq!(
9944            serde_json::from_str::<NodeRequest>(&standalone_json).unwrap(),
9945            standalone,
9946        );
9947        assert_eq!(
9948            standalone.required_capability(),
9949            Some(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY),
9950        );
9951        let oversized_branch = format!(
9952            r#"{{"kind":"create-standalone-workspace","workspace_id":"independent","root":"C:\\independent","initial_branch":"{}"}}"#,
9953            "x".repeat(MAX_REPOSITORY_PATH_BYTES + 1),
9954        );
9955        assert!(serde_json::from_str::<NodeRequest>(&oversized_branch).is_err());
9956
9957        let unregister = NodeRequest::UnregisterWorkspace {
9958            workspace_id: WorkspaceId::new("repo-2").unwrap(),
9959        };
9960        let unregister_json = serde_json::to_string(&unregister).unwrap();
9961        assert_eq!(
9962            unregister_json,
9963            r#"{"kind":"unregister-workspace","workspace_id":"repo-2"}"#,
9964        );
9965        assert_eq!(serde_json::from_str::<NodeRequest>(&unregister_json).unwrap(), unregister);
9966
9967        let create = NodeRequest::CreateWorktree {
9968            source_workspace_id: WorkspaceId::new("primary").unwrap(),
9969            workspace_id: WorkspaceId::new("topic-one").unwrap(),
9970            target_root: host_path(r"C:\trees\topic-one"),
9971            branch: "codex/topic-one".to_owned(),
9972            base: Some("main".to_owned()),
9973        };
9974        let create_json = serde_json::to_string(&create).unwrap();
9975        assert_eq!(
9976            create_json,
9977            r#"{"kind":"create-worktree","source_workspace_id":"primary","workspace_id":"topic-one","target_root":"C:\\trees\\topic-one","branch":"codex/topic-one","base":"main"}"#,
9978        );
9979        assert_eq!(serde_json::from_str::<NodeRequest>(&create_json).unwrap(), create);
9980
9981        let remove = NodeRequest::RemoveWorktree {
9982            source_workspace_id: WorkspaceId::new("primary").unwrap(),
9983            target_root: host_path(r"C:\trees\topic-one"),
9984        };
9985        let remove_json = serde_json::to_string(&remove).unwrap();
9986        assert_eq!(
9987            remove_json,
9988            r#"{"kind":"remove-worktree","source_workspace_id":"primary","target_root":"C:\\trees\\topic-one"}"#,
9989        );
9990        assert_eq!(serde_json::from_str::<NodeRequest>(&remove_json).unwrap(), remove);
9991    }
9992
9993    #[test]
9994    fn incarnation_id_and_cursor_have_exact_lowercase_hex_wire_shapes() {
9995        let incarnation_id = NodeIncarnationId::from_bytes([
9996            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
9997            0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
9998        ]);
9999        assert_eq!(
10000            incarnation_id.to_string(),
10001            "00112233445566778899aabbccddeeff",
10002        );
10003        let json = serde_json::to_string(&incarnation_id).unwrap();
10004        assert_eq!(json, r#""00112233445566778899aabbccddeeff""#);
10005        assert_eq!(
10006            serde_json::from_str::<NodeIncarnationId>(&json).unwrap(),
10007            incarnation_id,
10008        );
10009        assert!("00112233445566778899AABBCCDDEEFF"
10010            .parse::<NodeIncarnationId>()
10011            .is_err());
10012        assert!("00112233445566778899aabbccddeef"
10013            .parse::<NodeIncarnationId>()
10014            .is_err());
10015        assert!("00112233445566778899aabbccddeefg"
10016            .parse::<NodeIncarnationId>()
10017            .is_err());
10018
10019        let cursor = NodeCursor {
10020            incarnation_id,
10021            sequence: 17,
10022        };
10023        let cursor_json = serde_json::to_string(&cursor).unwrap();
10024        assert_eq!(
10025            cursor_json,
10026            r#"{"incarnation_id":"00112233445566778899aabbccddeeff","sequence":17}"#,
10027        );
10028        assert_eq!(serde_json::from_str::<NodeCursor>(&cursor_json).unwrap(), cursor);
10029    }
10030
10031    #[test]
10032    fn node_hello_carries_the_incarnation_sequence_domain_with_the_build_stamp() {
10033        let hello = NodeHello {
10034            build_stamp: BUILD_STAMP.to_owned(),
10035            incarnation_id: NodeIncarnationId::from_bytes([0; NODE_INCARNATION_ID_BYTES]),
10036            connection_id: 42,
10037            role: ClientRole::Observer,
10038            event_sequence: 9,
10039            controller: None,
10040            snapshot: NodeSnapshot {
10041                node_id: NodeId::new("fixture-node").unwrap(),
10042                enabled_providers: Vec::new(),
10043                provider_runtime_statuses: ProviderRuntimeStatuses::default(),
10044                workspaces: Vec::new(),
10045                session_records: Vec::new(),
10046                managed_worktrees: Vec::new(),
10047                launch_inventory: None,
10048                agent_progress: Vec::new(),
10049            },
10050            compatibility: None,
10051        };
10052        let json = serde_json::to_string(&hello).unwrap();
10053        assert_eq!(
10054            json,
10055            format!(
10056                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":[]}}}}"#,
10057            ),
10058        );
10059        assert_eq!(serde_json::from_str::<NodeHello>(&json).unwrap(), hello);
10060    }
10061
10062    #[test]
10063    fn agent_progress_v1_rejects_oversize_and_controls() {
10064        let address = serde_json::json!({
10065            "workspace_id": "primary",
10066            "session": { "instance_id": 7, "generation": 3 }
10067        });
10068        let valid_progress = serde_json::json!({
10069            "provider_sequence": 11,
10070            "activity": "working",
10071            "completed_turns": 2,
10072            "usage": {
10073                "input_tokens": 10,
10074                "output_tokens": 20,
10075                "cache_read_tokens": 30,
10076                "cache_write_tokens": 40,
10077                "reasoning_tokens": 50
10078            },
10079            "current": "working",
10080            "active_tool_labels": ["Read"],
10081            "active_tool_count": 1,
10082            "attention": null,
10083            "subagent_count": 0,
10084            "last_event_kind": "tool-started",
10085            "gap_count": 0,
10086            "stale": false,
10087            "truncated": false
10088        });
10089        let mut controlled = valid_progress.clone();
10090        controlled["active_tool_labels"] = serde_json::json!(["unsafe\u{0000}tool"]);
10091        assert!(serde_json::from_value::<AgentProgressV1>(controlled.clone()).is_err());
10092
10093        let mut path_like = valid_progress.clone();
10094        path_like["active_tool_labels"] = serde_json::json!([r"Read C:\private\secret"]);
10095        assert!(serde_json::from_value::<AgentProgressV1>(path_like).is_err());
10096
10097        let mut oversized_label = valid_progress.clone();
10098        oversized_label["active_tool_labels"] =
10099            serde_json::json!(["x".repeat(MAX_AGENT_PROGRESS_TOOL_LABEL_BYTES + 1)]);
10100        assert!(serde_json::from_value::<AgentProgressV1>(oversized_label.clone()).is_err());
10101
10102        let oversized_entry = serde_json::json!({
10103            "address": address.clone(),
10104            "progress": valid_progress,
10105            "padding": "x".repeat(MAX_AGENT_PROGRESS_ENTRY_BYTES)
10106        });
10107        assert!(serde_json::to_vec(&oversized_entry).unwrap().len()
10108            > MAX_AGENT_PROGRESS_ENTRY_BYTES);
10109        let snapshot = serde_json::json!({
10110            "node_id": "fixture-node",
10111            "enabled_providers": [],
10112            "workspaces": [],
10113            "session_records": [],
10114            "agent_progress": [
10115                { "address": address.clone(), "progress": controlled },
10116                { "address": address, "progress": oversized_label },
10117                oversized_entry
10118            ]
10119        });
10120        let decoded = serde_json::from_value::<NodeSnapshot>(snapshot).unwrap();
10121        assert!(decoded.agent_progress.is_empty());
10122    }
10123
10124    #[test]
10125    fn launch_inventory_preserves_legacy_absence_and_authoritative_empty() {
10126        let legacy = serde_json::from_str::<NodeSnapshot>(
10127            r#"{"node_id":"fixture-node","enabled_providers":[],"workspaces":[],"session_records":[]}"#,
10128        )
10129        .unwrap();
10130        assert!(legacy.launch_inventory.is_none());
10131        assert!(serde_json::from_str::<LaunchInventory>(r#"{}"#).is_err());
10132
10133        let current = LaunchInventory {
10134            spawn_profiles: Some(Vec::new()),
10135            bundles: Some(Vec::new()),
10136            network_allowlists: None,
10137        };
10138        assert_eq!(
10139            serde_json::to_string(&current).unwrap(),
10140            r#"{"spawn_profiles":[],"bundles":[]}"#,
10141        );
10142        assert_eq!(
10143            serde_json::from_str::<LaunchInventory>(
10144                r#"{"spawn_profiles":[],"bundles":[]}"#,
10145            )
10146            .unwrap(),
10147            current,
10148        );
10149
10150        let legacy_workspace = serde_json::from_str::<WorkspaceSnapshot>(
10151            r#"{"workspace_id":"repo","canonical_root":"fixture-root","sessions":[]}"#,
10152        )
10153        .unwrap();
10154        assert!(legacy_workspace.managed_worktree_profiles.is_none());
10155        let current_workspace = WorkspaceSnapshot {
10156            managed_worktree_profiles: Some(WorktreeProfileInventory {
10157                profiles: Vec::new(),
10158            }),
10159            ..legacy_workspace
10160        };
10161        assert!(serde_json::to_string(&current_workspace)
10162            .unwrap()
10163            .contains(r#""managed_worktree_profiles":[]"#));
10164    }
10165
10166    #[test]
10167    fn spawn_profile_environment_authority_is_optional_safe_and_capability_bound() {
10168        let environment_profile = ResolvedEnvironmentProfileReceipt {
10169            profile_id: SpawnEnvironmentProfileId::new("local-claude").unwrap(),
10170            profile_revision: SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap(),
10171            network_allowlist: None,
10172            browser_profile_id: None,
10173        };
10174        let summary = SpawnProfileSummary {
10175            id: SpawnProfileId::new("default").unwrap(),
10176            revision: SpawnProfileRevision::new("v1").unwrap(),
10177            environment_profile: Some(environment_profile.clone()),
10178        };
10179        let encoded = serde_json::to_string(&summary).unwrap();
10180        assert_eq!(
10181            encoded,
10182            r#"{"id":"default","revision":"v1","environment_profile":{"profile_id":"local-claude","profile_revision":"local-claude-r1"}}"#,
10183        );
10184        assert!(!encoded.contains("provider"));
10185        assert!(!encoded.contains("mode"));
10186        let legacy = serde_json::from_str::<SpawnProfileSummary>(
10187            r#"{"id":"default","revision":"v1"}"#,
10188        )
10189        .unwrap();
10190        assert!(legacy.environment_profile.is_none());
10191
10192        let snapshot = serde_json::from_value::<NodeSnapshot>(serde_json::json!({
10193            "node_id": "fixture-node",
10194            "enabled_providers": [],
10195            "workspaces": [],
10196            "launch_inventory": {
10197                "spawn_profiles": [summary],
10198            },
10199        }))
10200        .unwrap();
10201        assert!(snapshot.requires_child_environment_profile_capability());
10202        assert_eq!(
10203            snapshot.launch_inventory.unwrap().spawn_profiles.unwrap()[0]
10204                .environment_profile,
10205            Some(environment_profile),
10206        );
10207    }
10208
10209    #[test]
10210    fn launch_inventory_rejects_duplicate_and_overflow_identities() {
10211        let duplicate_profiles = r#"{"spawn_profiles":[{"id":"default","revision":"v1"},{"id":"default","revision":"v2"}]}"#;
10212        assert!(serde_json::from_str::<LaunchInventory>(duplicate_profiles).is_err());
10213
10214        let digest = format!("sha256:{}", "0".repeat(64));
10215        let duplicate_bundles = serde_json::json!({
10216            "bundles": [
10217                { "id": "review", "revision": "v1", "digest": digest },
10218                { "id": "review", "revision": "v2", "digest": format!("sha256:{}", "1".repeat(64)) },
10219            ],
10220        });
10221        assert!(serde_json::from_value::<LaunchInventory>(duplicate_bundles).is_err());
10222
10223        let duplicate_worktrees = r#"[{"id":"default","revision":"v1","retention":"retain"},{"id":"default","revision":"v2","retention":"remove-when-released"}]"#;
10224        assert!(serde_json::from_str::<WorktreeProfileInventory>(duplicate_worktrees).is_err());
10225
10226        let profiles = (0..=MAX_SPAWN_PROFILES)
10227            .map(|index| serde_json::json!({
10228                "id": format!("profile-{index}"),
10229                "revision": "v1",
10230            }))
10231            .collect::<Vec<_>>();
10232        let overflow = serde_json::json!({ "spawn_profiles": profiles });
10233        assert!(serde_json::from_value::<LaunchInventory>(overflow).is_err());
10234
10235        let bundles = (0..=MAX_LAUNCH_BUNDLES)
10236            .map(|index| serde_json::json!({
10237                "id": format!("bundle-{index}"),
10238                "revision": "v1",
10239                "digest": format!("sha256:{}", "2".repeat(64)),
10240            }))
10241            .collect::<Vec<_>>();
10242        assert!(serde_json::from_value::<LaunchInventory>(
10243            serde_json::json!({ "bundles": bundles }),
10244        )
10245        .is_err());
10246
10247        let worktrees = (0..=MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE)
10248            .map(|index| serde_json::json!({
10249                "id": format!("profile-{index}"),
10250                "revision": "v1",
10251                "retention": "retain",
10252            }))
10253            .collect::<Vec<_>>();
10254        assert!(serde_json::from_value::<WorktreeProfileInventory>(
10255            serde_json::Value::Array(worktrees),
10256        )
10257        .is_err());
10258    }
10259
10260    #[test]
10261    fn managed_worktree_contract_is_bounded_dual_gated_and_path_free() {
10262        assert_eq!(
10263            NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
10264            "managed-worktree-lifecycle-v1",
10265        );
10266        assert_eq!(NODE_STATE_SCHEMA_V4, 4);
10267        assert_eq!(NODE_STATE_SCHEMA_V5, 5);
10268        assert_eq!(NODE_STATE_SCHEMA_V6, 6);
10269        assert_eq!(NODE_STATE_SCHEMA_V10, 10);
10270        assert!(WorktreeProfileId::new("p".repeat(MAX_WORKTREE_PROFILE_ID_BYTES)).is_ok());
10271        assert!(WorktreeProfileId::new("p".repeat(MAX_WORKTREE_PROFILE_ID_BYTES + 1)).is_err());
10272        assert!(WorktreeProfileRevision::new(
10273            "r".repeat(MAX_WORKTREE_PROFILE_REVISION_BYTES),
10274        )
10275        .is_ok());
10276        assert!(ManagedWorktreeLeaseId::new(
10277            "l".repeat(MAX_MANAGED_WORKTREE_LEASE_ID_BYTES + 1),
10278        )
10279        .is_err());
10280
10281        let request = NodeRequest::SpawnManagedWorktree {
10282            request: ManagedWorktreeSpawnRequest {
10283                spawn_spec: SpawnSpec {
10284                    target: SpawnTarget {
10285                        node_id: NodeId::new("node-a").unwrap(),
10286                        workspace_id: WorkspaceId::new("primary").unwrap(),
10287                        worktree_id: None,
10288                    },
10289                    profile_id: SpawnProfileId::new("default").unwrap(),
10290                    expected_profile_revision: SpawnProfileRevision::new("default.r1").unwrap(),
10291                    overrides: SpawnOverrides::default(),
10292                    deadline_ms: SpawnDeadlineMs::new(30_000).unwrap(),
10293                    idempotency_key: SpawnIdempotencyKey::new("managed-1").unwrap(),
10294                    required_capabilities: SpawnRequiredCapabilities::default(),
10295                },
10296                worktree_profile_id: WorktreeProfileId::new("review").unwrap(),
10297            },
10298        };
10299        assert_eq!(
10300            request.required_capability(),
10301            Some(NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY),
10302        );
10303        assert!(request.requires_worktree_selection_capability());
10304        let json = serde_json::to_string(&request).unwrap();
10305        for forbidden in ["canonical", "target_root", "gitdir", "branch", "base_commit", "diagnostic"] {
10306            assert!(!json.contains(forbidden), "managed request leaked {forbidden}");
10307        }
10308
10309        let legacy = match request {
10310            NodeRequest::SpawnManagedWorktree { request } => request,
10311            _ => unreachable!(),
10312        };
10313        let legacy_json = serde_json::to_string(&legacy).unwrap();
10314        assert!(serde_json::from_str::<ManagedWorktreeSpawnRequest>(&legacy_json).is_ok());
10315        assert!(serde_json::from_str::<ManagedWorktreeSpawnRequestV2>(&legacy_json).is_err());
10316        let v2 = NodeRequest::SpawnManagedWorktreeV2 {
10317            request: ManagedWorktreeSpawnRequestV2 {
10318                spawn_spec: legacy.spawn_spec,
10319                worktree_profile_id: legacy.worktree_profile_id,
10320                expected_profile_revision: WorktreeProfileRevision::new("review.r1").unwrap(),
10321            },
10322        };
10323        assert_eq!(
10324            v2.required_capability(),
10325            Some(NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY),
10326        );
10327        assert!(v2.requires_worktree_selection_capability());
10328    }
10329
10330    #[test]
10331    fn managed_worktree_snapshot_rejects_invalid_time_duplicate_identity_and_overflow() {
10332        fn lease(lease_id: &str, workspace_id: &str) -> serde_json::Value {
10333            serde_json::json!({
10334                "lease_id": lease_id,
10335                "source_workspace_id": "primary",
10336                "workspace_id": workspace_id,
10337                "profile_id": "review",
10338                "profile_revision": "review.r1",
10339                "retention": "remove-when-released",
10340                "state": "ready",
10341                "active_session_count": 0,
10342                "managed_record_count": 0,
10343                "cleanup_failure": null,
10344                "created_at_unix_ms": 1,
10345                "updated_at_unix_ms": 2
10346            })
10347        }
10348        fn snapshot(leases: Vec<serde_json::Value>) -> serde_json::Value {
10349            serde_json::json!({
10350                "node_id": "node-a",
10351                "enabled_providers": [],
10352                "workspaces": [],
10353                "session_records": [],
10354                "managed_worktrees": leases
10355            })
10356        }
10357
10358        let mut reversed = lease("lease-a", "managed-a");
10359        reversed["updated_at_unix_ms"] = serde_json::json!(0);
10360        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![reversed])).is_err());
10361        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![lease(
10362            "lease-a",
10363            "primary",
10364        )]))
10365        .is_err());
10366
10367        for (state, active, records, failure) in [
10368            ("ready", 1, 0, serde_json::Value::Null),
10369            ("in-use", 0, 0, serde_json::Value::Null),
10370            ("in-use", 1, 0, serde_json::json!("busy")),
10371            ("cleanup-blocked", 0, 0, serde_json::Value::Null),
10372            ("cleanup-blocked", 1, 0, serde_json::json!("busy")),
10373            ("recovery-required", 0, 0, serde_json::Value::Null),
10374            ("removed", 0, 1, serde_json::Value::Null),
10375        ] {
10376            let mut malformed = lease("lease-a", "managed-a");
10377            malformed["state"] = serde_json::json!(state);
10378            malformed["active_session_count"] = serde_json::json!(active);
10379            malformed["managed_record_count"] = serde_json::json!(records);
10380            malformed["cleanup_failure"] = failure;
10381            assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![malformed])).is_err());
10382        }
10383        let mut recovery_with_holder = lease("lease-a", "managed-a");
10384        recovery_with_holder["state"] = serde_json::json!("recovery-required");
10385        recovery_with_holder["managed_record_count"] = serde_json::json!(1);
10386        recovery_with_holder["cleanup_failure"] = serde_json::json!("ownership-conflict");
10387        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![recovery_with_holder])).is_ok());
10388
10389        let mut spawn_lease = lease("lease-a", "managed-a");
10390        spawn_lease["state"] = serde_json::json!("in-use");
10391        spawn_lease["active_session_count"] = serde_json::json!(1);
10392        spawn_lease["managed_record_count"] = serde_json::json!(1);
10393        let spawn = serde_json::json!({
10394            "incarnation_id": "00000000000000000000000000000000",
10395            "session": {
10396                "workspace_id": "managed-a",
10397                "session": { "instance_id": 1, "generation": 1 }
10398            },
10399            "target": {
10400                "node_id": "node-a",
10401                "workspace_id": "primary",
10402                "worktree_id": "managed-a"
10403            },
10404            "profile_id": "default",
10405            "profile_revision": "default.r1",
10406            "provider": "claude",
10407            "mode": "pty",
10408            "terminal_size": { "rows": 24, "columns": 80 },
10409            "prompt": { "present": false, "byte_len": 0 },
10410            "bundle_id": null,
10411            "context_id": null,
10412            "environment_profile_id": null,
10413            "deadline_ms": 5000,
10414            "idempotency_key": "managed-1",
10415            "required_capabilities": [],
10416            "provenance": {
10417                "provider": "profile",
10418                "mode": "profile",
10419                "terminal_size": "profile",
10420                "prompt": "profile",
10421                "bundle_id": "profile",
10422                "context_id": "profile",
10423                "environment_profile_id": "profile"
10424            }
10425        });
10426        let valid_receipt = serde_json::json!({ "spawn": spawn, "lease": spawn_lease });
10427        assert!(serde_json::from_value::<ManagedWorktreeSpawnReceipt>(valid_receipt.clone()).is_ok());
10428        let mut wrong_source = valid_receipt.clone();
10429        wrong_source["lease"]["source_workspace_id"] = serde_json::json!("other");
10430        assert!(serde_json::from_value::<ManagedWorktreeSpawnReceipt>(wrong_source).is_err());
10431        let mut wrong_session = valid_receipt;
10432        wrong_session["spawn"]["session"]["workspace_id"] = serde_json::json!("other");
10433        assert!(serde_json::from_value::<ManagedWorktreeSpawnReceipt>(wrong_session).is_err());
10434
10435        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![
10436            lease("lease-a", "managed-a"),
10437            lease("lease-a", "managed-b"),
10438        ]))
10439        .is_err());
10440        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![
10441            lease("lease-a", "managed-a"),
10442            lease("lease-b", "managed-a"),
10443        ]))
10444        .is_err());
10445
10446        let leases = (0..=MAX_MANAGED_WORKTREE_LEASES)
10447            .map(|index| lease(&format!("lease-{index}"), &format!("managed-{index}")))
10448            .collect();
10449        assert!(serde_json::from_value::<NodeSnapshot>(snapshot(leases)).is_err());
10450    }
10451
10452    #[test]
10453    fn terminal_bytes_round_trip_as_an_exact_byte_array() {
10454        let request = NodeRequest::TerminalBytes {
10455            session: SessionAddress {
10456                workspace_id: WorkspaceId::new("primary").unwrap(),
10457                session: SessionKey {
10458                    instance_id: AgentInstanceId(7),
10459                    generation: SessionGeneration(3),
10460                },
10461            },
10462            bytes: b"\x1b[1;5D".to_vec(),
10463        };
10464        let json = serde_json::to_string(&request).unwrap();
10465        assert_eq!(
10466            json,
10467            r#"{"kind":"terminal-bytes","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":3}},"bytes":[27,91,49,59,53,68]}"#,
10468        );
10469        assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
10470    }
10471
10472    #[test]
10473    fn terminal_frame_event_wire_contract_is_exact() {
10474        let event = NodeEvent::TerminalFrame {
10475            address: SessionAddress {
10476                workspace_id: WorkspaceId::new("primary").unwrap(),
10477                session: SessionKey {
10478                    instance_id: AgentInstanceId(7),
10479                    generation: SessionGeneration(3),
10480                },
10481            },
10482            frame: TerminalFrame {
10483                sequence: 11,
10484                size: TerminalSize { rows: 24, columns: 80 },
10485                cursor_row: 2,
10486                cursor_column: 4,
10487                contents: "ready".to_owned(),
10488                formatted: b"ready".to_vec(),
10489                scrollback_formatted: vec![b"previous".to_vec()],
10490                alternate_screen: false,
10491                mouse_protocol_enabled: false,
10492                mouse_protocol_encoding:
10493                    gate4agent_types::TerminalMouseProtocolEncoding::Default,
10494                produced_at_unix_ms: 0,
10495                screen_state: gate4agent_types::PtyScreenState::default(),
10496                bracketed_paste: None,
10497            },
10498        };
10499        let json = serde_json::to_string(&event).unwrap();
10500        assert_eq!(
10501            json,
10502            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"}}}"#,
10503        );
10504        assert_eq!(serde_json::from_str::<NodeEvent>(&json).unwrap(), event);
10505    }
10506
10507    #[test]
10508    fn terminal_frame_events_capability_is_optional_and_auth_bound_exactly() {
10509        assert_eq!(
10510            NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
10511            "terminal-frame-events-v1",
10512        );
10513        assert!(production_node_client_compatibility_offer()
10514            .capabilities
10515            .iter()
10516            .any(|capability| capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY));
10517
10518        let capability = CapabilityId::new(NODE_TERMINAL_FRAME_EVENTS_CAPABILITY).unwrap();
10519        let mut support = portable_node_support();
10520        support.capabilities = vec![capability.clone()];
10521        let legacy = ClientCompatibilityOffer::local();
10522        assert!(support
10523            .negotiate(&legacy)
10524            .unwrap()
10525            .capabilities
10526            .is_empty());
10527
10528        let offer = ClientCompatibilityOffer {
10529            build_stamp: BUILD_STAMP.to_owned(),
10530            capabilities: vec![capability.clone()],
10531            state_schema: None,
10532        };
10533        let selected = support
10534            .negotiate(&offer)
10535            .unwrap();
10536        assert_eq!(selected.capabilities, vec![capability]);
10537        assert_eq!(
10538            String::from_utf8(
10539                encode_node_compatibility_auth_binding(&offer, &selected).unwrap(),
10540            )
10541            .unwrap(),
10542            format!(
10543                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":[]}}}}"#,
10544            ),
10545        );
10546    }
10547
10548    #[test]
10549    fn child_environment_profile_capability_is_optional_and_auth_bound_exactly() {
10550        assert_eq!(
10551            NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
10552            "child-environment-profile-v1",
10553        );
10554        assert!(production_node_client_compatibility_offer()
10555            .capabilities
10556            .iter()
10557            .any(|capability| {
10558                capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
10559            }));
10560
10561        let capability =
10562            CapabilityId::new(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).unwrap();
10563        let mut support = portable_node_support();
10564        support.capabilities = vec![capability.clone()];
10565        let legacy = ClientCompatibilityOffer::local();
10566        assert!(support
10567            .negotiate(&legacy)
10568            .unwrap()
10569            .capabilities
10570            .is_empty());
10571
10572        let offer = ClientCompatibilityOffer {
10573            build_stamp: BUILD_STAMP.to_owned(),
10574            capabilities: vec![capability.clone()],
10575            state_schema: None,
10576        };
10577        let selected = support
10578            .negotiate(&offer)
10579            .unwrap();
10580        assert_eq!(selected.capabilities, vec![capability]);
10581        let bound = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
10582        assert!(bound
10583            .windows(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.len())
10584            .any(|window| {
10585                window == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.as_bytes()
10586            }));
10587    }
10588
10589    #[test]
10590    fn worktree_selection_capability_is_optional_and_auth_bound_exactly() {
10591        assert_eq!(NODE_WORKTREE_SELECTION_CAPABILITY, "worktree-selection-v1");
10592        assert!(production_node_client_compatibility_offer()
10593            .capabilities
10594            .iter()
10595            .any(|capability| capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY));
10596
10597        let capability = CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
10598        let mut support = portable_node_support();
10599        support.capabilities = vec![capability.clone()];
10600        let legacy = ClientCompatibilityOffer::local();
10601        assert!(support
10602            .negotiate(&legacy)
10603            .unwrap()
10604            .capabilities
10605            .is_empty());
10606
10607        let offer = ClientCompatibilityOffer {
10608            build_stamp: BUILD_STAMP.to_owned(),
10609            capabilities: vec![capability.clone()],
10610            state_schema: None,
10611        };
10612        let selected = support.negotiate(&offer).unwrap();
10613        assert_eq!(selected.capabilities, vec![capability]);
10614        assert_eq!(
10615            String::from_utf8(
10616                encode_node_compatibility_auth_binding(&offer, &selected).unwrap(),
10617            )
10618            .unwrap(),
10619            format!(
10620                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":[]}}}}"#,
10621            ),
10622        );
10623    }
10624
10625    #[test]
10626    fn legacy_node_event_bytes_remain_exact_after_terminal_frame_addition() {
10627        assert_eq!(
10628            serde_json::to_vec(&NodeEvent::ResyncRequired {
10629                oldest_available_sequence: 7,
10630            })
10631            .unwrap(),
10632            br#"{"kind":"resync-required","oldest_available_sequence":7}"#,
10633        );
10634    }
10635
10636    #[test]
10637    fn workspace_responses_and_events_round_trip_without_client_only_state() {
10638        let workspace = WorkspaceSnapshot {
10639            workspace_id: WorkspaceId::new("repo-2").unwrap(),
10640            canonical_root: host_path(r"C:\repo-2"),
10641            sessions: Vec::new(),
10642            worktree_service_mode: None,
10643            managed_worktree_profiles: None,
10644        };
10645        let registered = NodeResponse::WorkspaceRegistered {
10646            workspace: workspace.clone(),
10647        };
10648        let registered_json = serde_json::to_string(&registered).unwrap();
10649        assert_eq!(
10650            serde_json::from_str::<NodeResponse>(&registered_json).unwrap(),
10651            registered,
10652        );
10653        let standalone = NodeResponse::StandaloneWorkspaceCreated {
10654            workspace: workspace.clone(),
10655        };
10656        let standalone_json = serde_json::to_string(&standalone).unwrap();
10657        assert_eq!(
10658            serde_json::from_str::<NodeResponse>(&standalone_json).unwrap(),
10659            standalone,
10660        );
10661        let added = NodeEventEnvelope {
10662            sequence: 19,
10663            event: NodeEvent::WorkspaceAdded {
10664                workspace: workspace.clone(),
10665            },
10666        };
10667        let added_json = serde_json::to_string(&added).unwrap();
10668        assert_eq!(
10669            serde_json::from_str::<NodeEventEnvelope>(&added_json).unwrap(),
10670            added,
10671        );
10672        let removed = NodeEventEnvelope {
10673            sequence: 20,
10674            event: NodeEvent::WorkspaceRemoved {
10675                workspace_id: workspace.workspace_id.clone(),
10676            },
10677        };
10678        let removed_json = serde_json::to_string(&removed).unwrap();
10679        assert_eq!(
10680            serde_json::from_str::<NodeEventEnvelope>(&removed_json).unwrap(),
10681            removed,
10682        );
10683
10684        let created = NodeResponse::WorktreeCreated {
10685            worktree: GitWorktreeSnapshot {
10686                path: host_path(r"C:\trees\topic-one"),
10687                head: "abc1234".to_owned(),
10688                branch: Some("codex/topic-one".to_owned()),
10689                is_bare: false,
10690                is_main: false,
10691                locked: false,
10692                lock_reason: None,
10693                prunable: false,
10694                prunable_reason: None,
10695                workspace_id: Some(workspace.workspace_id.clone()),
10696            },
10697            workspace: workspace.clone(),
10698        };
10699        let created_json = serde_json::to_string(&created).unwrap();
10700        assert_eq!(serde_json::from_str::<NodeResponse>(&created_json).unwrap(), created);
10701        let removed = NodeResponse::WorktreeRemoved {
10702            target_root: host_path(r"C:\trees\topic-one"),
10703            workspace_id: Some(workspace.workspace_id),
10704        };
10705        let removed_json = serde_json::to_string(&removed).unwrap();
10706        assert_eq!(serde_json::from_str::<NodeResponse>(&removed_json).unwrap(), removed);
10707    }
10708
10709    #[test]
10710    fn workspace_inspection_round_trips_as_a_workspace_scoped_read_only_request() {
10711        let workspace_id = WorkspaceId::new("primary").unwrap();
10712        let request = NodeRequest::InspectWorkspace {
10713            workspace_id: workspace_id.clone(),
10714        };
10715        let request_json = serde_json::to_string(&request).unwrap();
10716        assert_eq!(
10717            request_json,
10718            r#"{"kind":"inspect-workspace","workspace_id":"primary"}"#,
10719        );
10720        assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
10721
10722        let response = NodeResponse::WorkspaceInspected {
10723            inspection: WorkspaceInspection {
10724                workspace_id,
10725                entries: vec![
10726                    WorkspaceEntry {
10727                        relative_path: repository_path("src"),
10728                        kind: WorkspaceEntryKind::Directory,
10729                    },
10730                    WorkspaceEntry {
10731                        relative_path: repository_path("src/lib.rs"),
10732                        kind: WorkspaceEntryKind::File,
10733                    },
10734                ],
10735                tree_truncated: false,
10736                git: GitSnapshot {
10737                    is_repository: true,
10738                    branch: Some("main".to_owned()),
10739                    status: vec![GitStatusEntry {
10740                        index_status: " ".to_owned(),
10741                        worktree_status: "M".to_owned(),
10742                        path: repository_path("src/lib.rs"),
10743                        previous_path: None,
10744                    }],
10745                    recent_commits: vec![GitCommitSummary {
10746                        id: "abc1234".to_owned(),
10747                        summary: "bounded summary".to_owned(),
10748                    }],
10749                    worktrees: vec![GitWorktreeSnapshot {
10750                        path: host_path(r"C:\repo"),
10751                        head: "abc1234".to_owned(),
10752                        branch: Some("main".to_owned()),
10753                        is_bare: false,
10754                        is_main: true,
10755                        locked: false,
10756                        lock_reason: None,
10757                        prunable: false,
10758                        prunable_reason: None,
10759                        workspace_id: Some(WorkspaceId::new("primary").unwrap()),
10760                    }],
10761                    managed_worktree: None,
10762                    truncated: false,
10763                    diagnostic: None,
10764                },
10765                truncation: None,
10766            },
10767        };
10768        let response_json = serde_json::to_string(&response).unwrap();
10769        assert!(!response_json.contains("managed_worktree"));
10770        assert!(!response_json.contains("truncation"));
10771        assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
10772    }
10773
10774    #[test]
10775    fn workspace_inspection_truncation_is_additive_and_defaults_on_old_payloads() {
10776        let workspace_id = WorkspaceId::new("primary").unwrap();
10777        let old_payload = serde_json::json!({
10778            "workspace_id": workspace_id.as_str(),
10779            "entries": [],
10780            "tree_truncated": false,
10781            "git": {
10782                "is_repository": false,
10783                "branch": null,
10784                "status": [],
10785                "recent_commits": [],
10786                "worktrees": [],
10787                "truncated": false,
10788                "diagnostic": null,
10789            },
10790        });
10791        let parsed: WorkspaceInspection = serde_json::from_value(old_payload).unwrap();
10792        assert_eq!(parsed.truncation, None);
10793
10794        let truncated = WorkspaceInspectionTruncationV1 {
10795            walk_time_budget_exceeded: true,
10796            walk_entry_cap_exceeded: false,
10797            git_time_budget_exceeded: true,
10798            entries_visited: 50_000,
10799            elapsed_ms: 8_000,
10800        };
10801        let inspection = WorkspaceInspection {
10802            workspace_id,
10803            entries: Vec::new(),
10804            tree_truncated: true,
10805            git: GitSnapshot {
10806                is_repository: false,
10807                branch: None,
10808                status: Vec::new(),
10809                recent_commits: Vec::new(),
10810                worktrees: Vec::new(),
10811                managed_worktree: None,
10812                truncated: true,
10813                diagnostic: Some("git inspection skipped: workspace inspection time budget exhausted".to_owned()),
10814            },
10815            truncation: Some(truncated),
10816        };
10817        let json = serde_json::to_string(&inspection).unwrap();
10818        let round_tripped: WorkspaceInspection = serde_json::from_str(&json).unwrap();
10819        assert_eq!(round_tripped.truncation, Some(truncated));
10820    }
10821
10822    #[test]
10823    fn managed_worktree_git_scope_is_optional_bounded_and_round_trips() {
10824        let scope = ManagedWorktreeGitScope {
10825            lease_id: ManagedWorktreeLeaseId::new("mw-scope").unwrap(),
10826            source_workspace_id: WorkspaceId::new("primary").unwrap(),
10827            branch: "gate4agent/mw-scope".to_owned(),
10828            base_commit: GitObjectId::new(
10829                "0123456789abcdef0123456789abcdef01234567".to_owned(),
10830            )
10831            .unwrap(),
10832            active_session_count: 1,
10833            managed_record_count: 1,
10834        };
10835        let json = serde_json::to_string(&scope).unwrap();
10836        assert_eq!(serde_json::from_str::<ManagedWorktreeGitScope>(&json).unwrap(), scope);
10837
10838        let oversized = format!(
10839            r#"{{"lease_id":"mw-scope","source_workspace_id":"primary","branch":"{}","base_commit":"0123456789abcdef0123456789abcdef01234567","active_session_count":0,"managed_record_count":0}}"#,
10840            "x".repeat(MAX_REPOSITORY_PATH_BYTES + 1),
10841        );
10842        assert!(serde_json::from_str::<ManagedWorktreeGitScope>(&oversized).is_err());
10843    }
10844
10845    #[test]
10846    fn workspace_inspection_rejects_inconsistent_managed_git_scope() {
10847        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}}"#;
10848        assert!(serde_json::from_str::<WorkspaceInspection>(valid).is_ok());
10849        for invalid in [
10850            valid.replace("\"is_repository\":true", "\"is_repository\":false"),
10851            valid.replacen("\"branch\":\"gate4agent/a\"", "\"branch\":\"gate4agent/b\"", 1),
10852            valid.replace("\"source_workspace_id\":\"primary\"", "\"source_workspace_id\":\"managed-a\""),
10853            valid.replace("\"active_session_count\":1", "\"active_session_count\":0"),
10854        ] {
10855            assert!(serde_json::from_str::<WorkspaceInspection>(&invalid).is_err());
10856        }
10857    }
10858
10859    #[test]
10860    fn workspace_file_read_has_an_exact_capability_gated_wire_contract() {
10861        let workspace_id = WorkspaceId::new("primary").unwrap();
10862        let path = repository_path("src/lib.rs");
10863        let request = NodeRequest::ReadWorkspaceFile {
10864            workspace_id: workspace_id.clone(),
10865            path: path.clone(),
10866        };
10867        let request_json = serde_json::to_string(&request).unwrap();
10868        assert_eq!(
10869            request_json,
10870            r#"{"kind":"read-workspace-file","workspace_id":"primary","path":"src/lib.rs"}"#,
10871        );
10872        assert_eq!(request.required_capability(), Some(NODE_WORKSPACE_FILE_READ_CAPABILITY));
10873        assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
10874
10875        let response = NodeResponse::WorkspaceFileRead {
10876            file: WorkspaceFileRead {
10877                workspace_id,
10878                path,
10879                content: WorkspaceFileContent::Utf8 {
10880                    text: "fn main() {}\n".to_owned(),
10881                    byte_len: 13,
10882                },
10883                revision: None,
10884            },
10885        };
10886        let response_json = serde_json::to_string(&response).unwrap();
10887        assert_eq!(
10888            response_json,
10889            r#"{"kind":"workspace-file-read","file":{"workspace_id":"primary","path":"src/lib.rs","content":{"kind":"utf8","text":"fn main() {}\n","byte_len":13}}}"#,
10890        );
10891        assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
10892    }
10893
10894    #[test]
10895    fn workspace_entry_create_has_exact_capability_gated_wire_contracts() {
10896        assert!(production_node_client_compatibility_offer()
10897            .capabilities
10898            .iter()
10899            .any(|capability| capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY));
10900        let workspace_id = WorkspaceId::new("primary").unwrap();
10901        let file_path = repository_path("src/new.rs");
10902        let directory_path = repository_path("src/new");
10903        let create_file = NodeRequest::CreateWorkspaceFile {
10904            workspace_id: workspace_id.clone(),
10905            path: file_path.clone(),
10906        };
10907        let create_directory = NodeRequest::CreateWorkspaceDirectory {
10908            workspace_id: workspace_id.clone(),
10909            path: directory_path.clone(),
10910        };
10911        assert_eq!(
10912            serde_json::to_string(&create_file).unwrap(),
10913            r#"{"kind":"create-workspace-file","workspace_id":"primary","path":"src/new.rs"}"#,
10914        );
10915        assert_eq!(
10916            serde_json::to_string(&create_directory).unwrap(),
10917            r#"{"kind":"create-workspace-directory","workspace_id":"primary","path":"src/new"}"#,
10918        );
10919        for request in [create_file, create_directory] {
10920            assert_eq!(
10921                request.required_capability(),
10922                Some(NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY),
10923            );
10924            let encoded = serde_json::to_string(&request).unwrap();
10925            assert_eq!(serde_json::from_str::<NodeRequest>(&encoded).unwrap(), request);
10926        }
10927
10928        let file_response = NodeResponse::WorkspaceFileCreated {
10929            file: WorkspaceFileRead {
10930                workspace_id: workspace_id.clone(),
10931                path: file_path,
10932                content: WorkspaceFileContent::Utf8 {
10933                    text: String::new(),
10934                    byte_len: 0,
10935                },
10936                revision: Some(WorkspaceFileRevision::new("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned()).unwrap()),
10937            },
10938        };
10939        let directory_response = NodeResponse::WorkspaceDirectoryCreated {
10940            workspace_id,
10941            entry: WorkspaceEntry {
10942                relative_path: directory_path,
10943                kind: WorkspaceEntryKind::Directory,
10944            },
10945        };
10946        for response in [file_response, directory_response] {
10947            let encoded = serde_json::to_string(&response).unwrap();
10948            assert_eq!(serde_json::from_str::<NodeResponse>(&encoded).unwrap(), response);
10949        }
10950    }
10951
10952    #[test]
10953    fn workspace_file_read_worst_case_json_stays_within_the_server_frame_limit() {
10954        let response = NodeResponse::WorkspaceFileRead {
10955            file: WorkspaceFileRead {
10956                workspace_id: WorkspaceId::new("primary").unwrap(),
10957                path: repository_path(&"x".repeat(MAX_REPOSITORY_PATH_BYTES)),
10958                content: WorkspaceFileContent::Utf8 {
10959                    text: "\0".repeat(MAX_WORKSPACE_FILE_BYTES),
10960                    byte_len: u32::try_from(MAX_WORKSPACE_FILE_BYTES).unwrap(),
10961                },
10962                revision: None,
10963            },
10964        };
10965        let encoded = serde_json::to_vec(&response).unwrap();
10966        assert!(encoded.len() <= MAX_NODE_FRAME_BYTES, "{}", encoded.len());
10967    }
10968
10969    #[test]
10970    fn read_context_pack_has_an_exact_capability_gated_wire_contract() {
10971        let digest = SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).unwrap();
10972        let request = NodeRequest::ReadContextPack {
10973            digest: digest.clone(),
10974        };
10975        let request_json = serde_json::to_string(&request).unwrap();
10976        assert_eq!(
10977            request_json,
10978            format!(
10979                r#"{{"kind":"read-context-pack","digest":"sha256:{}"}}"#,
10980                "a".repeat(64),
10981            ),
10982        );
10983        assert_eq!(
10984            request.required_capability(),
10985            Some(NODE_HISTORY_CONTEXT_PACK_CAPABILITY),
10986        );
10987        assert!(request.history_context_pack_contract_is_valid());
10988        assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
10989
10990        let id = SpawnContextId::new(format!("ctx-{}", "a".repeat(64))).unwrap();
10991        let response = NodeResponse::ContextPackBytesRead {
10992            pack: ContextPackBytesRead {
10993                digest: digest.clone(),
10994                id: id.clone(),
10995                byte_len: 11,
10996                bytes: b"pack-bytes!".to_vec(),
10997            },
10998        };
10999        assert!(response.requires_history_context_pack_capability());
11000        let response_json = serde_json::to_string(&response).unwrap();
11001        assert_eq!(
11002            response_json,
11003            format!(
11004                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]}}}}"#,
11005                "a".repeat(64),
11006                id.as_str(),
11007            ),
11008        );
11009        assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
11010    }
11011
11012    #[test]
11013    fn context_pack_bytes_read_deserialization_rejects_inconsistent_or_oversized_lengths() {
11014        let digest = SpawnContextDigest::new(format!("sha256:{}", "b".repeat(64))).unwrap();
11015        let id = SpawnContextId::new(format!("ctx-{}", "b".repeat(64))).unwrap();
11016
11017        let valid = ContextPackBytesRead {
11018            digest: digest.clone(),
11019            id: id.clone(),
11020            byte_len: 4,
11021            bytes: b"test".to_vec(),
11022        };
11023        let encoded = serde_json::to_string(&valid).unwrap();
11024        assert_eq!(serde_json::from_str::<ContextPackBytesRead>(&encoded).unwrap(), valid);
11025
11026        let declares_more_than_it_carries = ContextPackBytesRead {
11027            digest: digest.clone(),
11028            id: id.clone(),
11029            byte_len: 5,
11030            bytes: b"test".to_vec(),
11031        };
11032        let encoded = serde_json::to_string(&declares_more_than_it_carries).unwrap();
11033        assert!(serde_json::from_str::<ContextPackBytesRead>(&encoded).is_err());
11034
11035        let oversized_len = MAX_CONTEXT_PACK_BYTES + 1;
11036        let oversized = ContextPackBytesRead {
11037            digest,
11038            id,
11039            byte_len: oversized_len,
11040            bytes: vec![0u8; oversized_len as usize],
11041        };
11042        let encoded = serde_json::to_string(&oversized).unwrap();
11043        assert!(serde_json::from_str::<ContextPackBytesRead>(&encoded).is_err());
11044    }
11045
11046    /// Pins `context_pack_digest`'s formula (`SHA256(domain || JSON(lineage)
11047    /// || 0x00 || bytes)`, rendered `sha256:<hex>`) to a literal so a future
11048    /// edit to the hashing, the domain tag, the separator byte, or the
11049    /// lineage's own JSON shape shows up here as a changed digest rather than
11050    /// drifting silently between the node (which computes it on export) and
11051    /// the harness (which recomputes it to check a fetched pack).
11052    #[test]
11053    fn context_pack_digest_formula_is_pinned() {
11054        let lineage = ContextPackLineageReceipt {
11055            source_node_id: NodeId::new("node-pin").unwrap(),
11056            source_session: session_address("pin-workspace", 42),
11057            source_provider: agent("claude"),
11058        };
11059        let digest = context_pack_digest(&lineage, b"pinned-context-pack-bytes");
11060        assert_eq!(
11061            digest.as_str(),
11062            "sha256:d35f4bd15866f03ca3621129bd661862a307a46002fd90e1ff2ca988c72a7035",
11063        );
11064    }
11065
11066    /// Pins `sha256_hex`'s formula (plain `SHA256(bytes)`, bare lowercase hex,
11067    /// no prefix and no domain separation) to a literal so a future edit
11068    /// shows up here as a changed digest rather than drifting silently
11069    /// between the node (which stamps `WorkspaceFileRevision` with it on
11070    /// read) and the harness (which recomputes it to check a mailed
11071    /// `WorkspacePath` ref's bytes).
11072    #[test]
11073    fn sha256_hex_formula_is_pinned() {
11074        assert_eq!(
11075            sha256_hex(b"gate4agent"),
11076            "dfcb966151fe0d63481517c76a7ff6237aeaa5e24f7d76dba7fa862a3a3c5b6b",
11077        );
11078    }
11079
11080    #[test]
11081    fn workspace_write_and_git_reads_have_exact_capability_gated_contracts() {
11082        let workspace_id = WorkspaceId::new("primary").unwrap();
11083        let path = repository_path("src/lib.rs");
11084        let revision = WorkspaceFileRevision::new("a".repeat(64)).unwrap();
11085        let write = NodeRequest::WriteWorkspaceFile {
11086            workspace_id: workspace_id.clone(),
11087            path: path.clone(),
11088            expected_revision: revision,
11089            text: "updated\n".to_owned(),
11090        };
11091        assert_eq!(write.required_capability(), Some(NODE_WORKSPACE_FILE_WRITE_CAPABILITY));
11092        let encoded = serde_json::to_string(&write).unwrap();
11093        assert_eq!(serde_json::from_str::<NodeRequest>(&encoded).unwrap(), write);
11094
11095        let history = NodeRequest::ReadGitHistory {
11096            workspace_id: workspace_id.clone(),
11097            path: Some(path.clone()),
11098            before: Some(GitObjectId::new("b".repeat(40)).unwrap()),
11099            limit: MAX_GIT_HISTORY_COMMITS,
11100        };
11101        let diff = NodeRequest::ReadGitDiff {
11102            workspace_id,
11103            request: GitDiffRequest {
11104                mode: GitDiffMode::Commit {
11105                    revision: GitObjectId::new("c".repeat(40)).unwrap(),
11106                },
11107                path: Some(path),
11108            },
11109        };
11110        for request in [history, diff] {
11111            assert_eq!(request.required_capability(), Some(NODE_GIT_READ_CAPABILITY));
11112            let encoded = serde_json::to_string(&request).unwrap();
11113            assert_eq!(serde_json::from_str::<NodeRequest>(&encoded).unwrap(), request);
11114        }
11115    }
11116
11117    #[test]
11118    fn workspace_file_content_deserialization_rejects_unbounded_or_inconsistent_lengths() {
11119        let valid = [
11120            WorkspaceFileContent::Utf8 {
11121                text: "тест".to_owned(),
11122                byte_len: 8,
11123            },
11124            WorkspaceFileContent::NonUtf8 { byte_len: 17 },
11125            WorkspaceFileContent::TooLarge {
11126                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
11127            },
11128        ];
11129        for content in valid {
11130            let encoded = serde_json::to_string(&content).unwrap();
11131            assert_eq!(
11132                serde_json::from_str::<WorkspaceFileContent>(&encoded).unwrap(),
11133                content,
11134            );
11135        }
11136
11137        let inconsistent = r#"{"kind":"utf8","text":"hello","byte_len":4}"#;
11138        assert!(serde_json::from_str::<WorkspaceFileContent>(inconsistent).is_err());
11139
11140        let oversized_non_utf8 = format!(
11141            r#"{{"kind":"non-utf8","byte_len":{}}}"#,
11142            MAX_WORKSPACE_FILE_BYTES + 1,
11143        );
11144        assert!(serde_json::from_str::<WorkspaceFileContent>(&oversized_non_utf8).is_err());
11145
11146        let wrong_limit = r#"{"kind":"too-large","limit_bytes":1}"#;
11147        assert!(serde_json::from_str::<WorkspaceFileContent>(wrong_limit).is_err());
11148
11149        let oversized_utf8 = WorkspaceFileContent::Utf8 {
11150            text: "x".repeat(MAX_WORKSPACE_FILE_BYTES + 1),
11151            byte_len: u32::try_from(MAX_WORKSPACE_FILE_BYTES + 1).unwrap(),
11152        };
11153        let encoded = serde_json::to_string(&oversized_utf8).unwrap();
11154        assert!(serde_json::from_str::<WorkspaceFileContent>(&encoded).is_err());
11155    }
11156
11157    #[test]
11158    fn legacy_requests_do_not_acquire_a_new_required_capability() {
11159        let legacy_requests = [
11160            NodeRequest::Snapshot,
11161            NodeRequest::Resync { after_sequence: 7 },
11162            NodeRequest::InspectWorkspace {
11163                workspace_id: WorkspaceId::new("primary").unwrap(),
11164            },
11165        ];
11166        for request in legacy_requests {
11167            assert_eq!(request.required_capability(), None);
11168        }
11169    }
11170
11171    #[test]
11172    fn host_directory_browse_wire_contract_is_bounded_and_capability_gated() {
11173        assert!(production_node_client_compatibility_offer()
11174            .capabilities
11175            .iter()
11176            .any(|capability| capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1));
11177        let directory = OpaqueHostPath::utf8(r"C:\repo".to_owned()).unwrap();
11178        let request = NodeRequest::BrowseHostDirectories {
11179            directory: Some(directory.clone()),
11180            after: None,
11181        };
11182        assert_eq!(
11183            request.required_capability(),
11184            Some(CAPABILITY_HOST_DIRECTORY_BROWSE_V1),
11185        );
11186        assert_eq!(
11187            serde_json::to_string(&request).unwrap(),
11188            r#"{"kind":"browse-host-directories","directory":"C:\\repo","after":null}"#,
11189        );
11190
11191        let response = NodeResponse::HostDirectoriesBrowsed {
11192            listing: HostDirectoryListing {
11193                directory: Some(directory.clone()),
11194                parent: Some(OpaqueHostPath::utf8(r"C:\".to_owned()).unwrap()),
11195                entries: vec![HostDirectoryEntry {
11196                    path: OpaqueHostPath::utf8(r"C:\repo\child".to_owned()).unwrap(),
11197                    display_name: "child".to_owned(),
11198                    is_link: false,
11199                }],
11200                next_after: None,
11201                incomplete: false,
11202            },
11203        };
11204        let encoded = serde_json::to_string(&response).unwrap();
11205        assert_eq!(serde_json::from_str::<NodeResponse>(&encoded).unwrap(), response);
11206
11207        let entries = (0..=MAX_HOST_DIRECTORY_ENTRIES)
11208            .map(|index| serde_json::json!({
11209                "path": format!(r"C:\directory-{index}"),
11210                "display_name": format!("directory-{index}"),
11211                "is_link": false,
11212            }))
11213            .collect::<Vec<_>>();
11214        let overflow = serde_json::json!({
11215            "directory": null,
11216            "parent": null,
11217            "entries": entries,
11218            "next_after": null,
11219            "incomplete": true,
11220        });
11221        assert!(serde_json::from_value::<HostDirectoryListing>(overflow).is_err());
11222        assert_eq!(
11223            serde_json::to_string(&NodeFailureCode::HostDirectoryReadTimedOut).unwrap(),
11224            r#""host-directory-read-timed-out""#,
11225        );
11226    }
11227
11228    #[test]
11229    fn host_directory_entry_rejects_unbounded_control_or_non_utf8_wire_values() {
11230        let path = host_path(r"C:\repo\child");
11231        assert!(HostDirectoryEntry::new(path.clone(), "child".to_owned(), false).is_ok());
11232        for display_name in [
11233            String::new(),
11234            "child\nname".to_owned(),
11235            "x".repeat(MAX_HOST_DIRECTORY_DISPLAY_NAME_BYTES + 1),
11236        ] {
11237            let encoded = serde_json::json!({
11238                "path": path,
11239                "display_name": display_name,
11240                "is_link": false,
11241            });
11242            assert!(serde_json::from_value::<HostDirectoryEntry>(encoded).is_err());
11243        }
11244        let non_utf8 = serde_json::json!({
11245            "path": { "kind": "unix-bytes", "bytes": [47, 255] },
11246            "display_name": "child",
11247            "is_link": false,
11248        });
11249        assert!(serde_json::from_value::<HostDirectoryEntry>(non_utf8).is_err());
11250    }
11251
11252    #[test]
11253    fn git_snapshot_defaults_worktrees_for_legacy_inspection_payloads() {
11254        let json = r#"{"is_repository":true,"branch":"main","status":[],"recent_commits":[],"truncated":false,"diagnostic":null}"#;
11255        let snapshot = serde_json::from_str::<GitSnapshot>(json).unwrap();
11256        assert!(snapshot.worktrees.is_empty());
11257    }
11258
11259    #[test]
11260    fn promptless_resume_round_trips_as_null() {
11261        let request = NodeRequest::Resume {
11262            session: SessionAddress {
11263                workspace_id: WorkspaceId::new("primary").unwrap(),
11264                session: SessionKey {
11265                    instance_id: AgentInstanceId(7),
11266                    generation: SessionGeneration(2),
11267                },
11268            },
11269            terminal_size: TerminalSize { rows: 24, columns: 80 },
11270            initial_prompt: None,
11271        };
11272        let json = serde_json::to_string(&request).unwrap();
11273        assert!(json.contains(r#""initial_prompt":null"#));
11274        assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
11275    }
11276
11277    #[test]
11278    fn node_and_workspace_ids_are_bounded_validated_wire_values() {
11279        assert_eq!(NodeId::new("node-1").unwrap().as_str(), "node-1");
11280        assert_eq!(WorkspaceId::new("repo_main").unwrap().as_str(), "repo_main");
11281        assert!(NodeId::new("Node-1").is_err());
11282        assert!(WorkspaceId::new("-repo").is_err());
11283        assert!(WorkspaceId::new("x".repeat(MAX_NODE_IDENTIFIER_BYTES + 1)).is_err());
11284
11285        let encoded = serde_json::to_string(&WorkspaceId::new("repo-1").unwrap()).unwrap();
11286        assert_eq!(encoded, "\"repo-1\"");
11287        assert!(serde_json::from_str::<WorkspaceId>("\"Repo-1\"").is_err());
11288    }
11289
11290    #[test]
11291    fn durable_session_wire_contract_round_trips() {
11292        assert_eq!(MAX_SESSION_DISPLAY_NAME_BYTES, 256);
11293        let record = ManagedSessionRecord {
11294            record_id: SessionRecordId::new("session-001").unwrap(),
11295            display_name: "release shepherd".to_owned(),
11296            provider: agent("claude"),
11297            mode: SessionMode::Pty,
11298            state: ManagedSessionState::Dormant,
11299            workspace_id: WorkspaceId::new("primary").unwrap(),
11300            canonical_root: host_path(r"C:\repo"),
11301            provider_session: Some(ProviderSessionIdentity {
11302                key: gate4agent_types::ProviderSessionKey::SessionId,
11303                id: "b1ef3250-47a2-42ca-9076-cc241487ea22".to_owned(),
11304                transcript_path: Some(r"C:\provider\sessions\b1ef3250.jsonl".to_owned()),
11305            }),
11306            active_session: None,
11307            environment_profile: None,
11308            bundle: None,
11309            context_id: None,
11310            context: None,
11311            exported_context: None,
11312            task_binding: None,
11313            created_at_unix_ms: 1_723_000_000_000,
11314            updated_at_unix_ms: 1_723_000_000_123,
11315            last_error: None,
11316        };
11317        assert!(!serde_json::to_string(&record)
11318            .unwrap()
11319            .contains("environment_profile"));
11320
11321        let native_selection = NativeSessionSelection {
11322            route: NativeSessionCatalogRoute::workspace(
11323                record.workspace_id.clone(),
11324                record.provider.clone(),
11325            ),
11326            catalog_revision: 7,
11327            recent_cutoff_unix_ms: 8,
11328            selection_id: "hist_selection_1".to_owned(),
11329        };
11330
11331        let requests = [
11332            NodeRequest::IndexProviderSession {
11333                workspace_id: record.workspace_id.clone(),
11334                provider: record.provider.clone(),
11335                identity: ProviderSessionIdentity {
11336                    key: gate4agent_types::ProviderSessionKey::SessionId,
11337                    id: "b1ef3250-47a2-42ca-9076-cc241487ea22".to_owned(),
11338                    transcript_path: None,
11339                },
11340                display_name: "release shepherd".to_owned(),
11341            },
11342            NodeRequest::IndexNativeSession {
11343                selection: native_selection.clone(),
11344                display_name: "release shepherd".to_owned(),
11345            },
11346            NodeRequest::RenameSessionRecord {
11347                record_id: record.record_id.clone(),
11348                display_name: "release verification".to_owned(),
11349            },
11350            NodeRequest::ResumeSessionRecord {
11351                record_id: record.record_id.clone(),
11352                terminal_size: TerminalSize { rows: 40, columns: 120 },
11353                initial_prompt: None,
11354            },
11355            NodeRequest::ForgetSessionRecord {
11356                record_id: record.record_id.clone(),
11357            },
11358        ];
11359        for request in requests {
11360            let json = serde_json::to_string(&request).unwrap();
11361            assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
11362            if matches!(request, NodeRequest::IndexProviderSession { .. }) {
11363                assert_eq!(
11364                    request.required_capability(),
11365                    Some(NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY),
11366                );
11367            }
11368            if matches!(request, NodeRequest::IndexNativeSession { .. }) {
11369                assert_eq!(
11370                    request.required_capability(),
11371                    Some(NODE_NATIVE_SESSION_INDEX_CAPABILITY),
11372                );
11373            }
11374        }
11375
11376        let native_indexed = NodeResponse::NativeSessionIndexed {
11377            selection: native_selection,
11378            record: record.clone(),
11379        };
11380        assert!(native_indexed.requires_native_session_index_capability());
11381        assert!(native_indexed.native_session_index_contract_is_valid());
11382        let responses = [
11383            NodeResponse::ProviderSessionIndexed {
11384                record: record.clone(),
11385            },
11386            native_indexed,
11387            NodeResponse::SessionRecordUpdated {
11388                record: record.clone(),
11389            },
11390            NodeResponse::SessionRecordResumed {
11391                record: record.clone(),
11392                session: SessionAddress {
11393                    workspace_id: record.workspace_id.clone(),
11394                    session: SessionKey {
11395                        instance_id: AgentInstanceId(8),
11396                        generation: SessionGeneration(2),
11397                    },
11398                },
11399            },
11400            NodeResponse::SessionRecordForgotten {
11401                record_id: record.record_id.clone(),
11402            },
11403        ];
11404        for response in responses {
11405            let json = serde_json::to_string(&response).unwrap();
11406            assert_eq!(serde_json::from_str::<NodeResponse>(&json).unwrap(), response);
11407        }
11408
11409        let events = [
11410            NodeEvent::SessionRecordUpserted {
11411                record: record.clone(),
11412            },
11413            NodeEvent::SessionRecordRemoved {
11414                record_id: record.record_id.clone(),
11415            },
11416        ];
11417        for event in events {
11418            let json = serde_json::to_string(&event).unwrap();
11419            assert_eq!(serde_json::from_str::<NodeEvent>(&json).unwrap(), event);
11420        }
11421    }
11422
11423    #[test]
11424    fn session_record_ids_are_bounded_validated_wire_values() {
11425        let record_id = SessionRecordId::new("01j4k0jta3eynt5kxef132kr39").unwrap();
11426        assert_eq!(record_id.as_str(), "01j4k0jta3eynt5kxef132kr39");
11427        assert!(SessionRecordId::new("").is_err());
11428        assert!(SessionRecordId::new("Session-1").is_err());
11429        assert!(SessionRecordId::new("-session-1").is_err());
11430        assert!(SessionRecordId::new("x".repeat(MAX_NODE_IDENTIFIER_BYTES + 1)).is_err());
11431
11432        let json = serde_json::to_string(&record_id).unwrap();
11433        assert_eq!(serde_json::from_str::<SessionRecordId>(&json).unwrap(), record_id);
11434        assert!(serde_json::from_str::<SessionRecordId>("\"Session-1\"").is_err());
11435    }
11436
11437    #[test]
11438    fn node_snapshot_defaults_managed_sessions_for_legacy_wire_payloads() {
11439        let legacy = r#"{"node_id":"fixture-node","enabled_providers":[],"workspaces":[]}"#;
11440        let snapshot = serde_json::from_str::<NodeSnapshot>(legacy).unwrap();
11441        assert!(snapshot.session_records.is_empty());
11442    }
11443
11444    fn delivery_manifest() -> DeliveryBundleManifestV2 {
11445        DeliveryBundleManifestV2 {
11446            bundle_id: SpawnBundleId::new("review-bundle").unwrap(),
11447            revision: SpawnBundleRevision::new("review-bundle.r2").unwrap(),
11448            bundle_digest: SpawnBundleDigest::new(format!("sha256:{}", "a".repeat(64)))
11449                .unwrap(),
11450            manifest_digest: DeliveryManifestDigestV2::new(format!(
11451                "sha256:{}",
11452                "b".repeat(64),
11453            ))
11454            .unwrap(),
11455            components: vec![DeliveryComponentV2 {
11456                kind: DeliveryComponentKindV2::AgentDefinition,
11457                scope: DeliveryScopeV2::Workspace,
11458                relative_path: DeliveryRelativePathV2::new("agents/reviewer.md").unwrap(),
11459                blob: DeliveryBlobReceiptV1::new(
11460                    DeliveryBlobDigestV1::new(format!("sha256:{}", "c".repeat(64))).unwrap(),
11461                    12,
11462                )
11463                .unwrap(),
11464            }],
11465        }
11466    }
11467
11468    #[test]
11469    fn delivery_wire_serialization_is_exact() {
11470        let begin = NodeRequest::BeginDeliveryStage {
11471            manifest: delivery_manifest(),
11472        };
11473        assert_eq!(
11474            serde_json::to_string(&begin).unwrap(),
11475            format!(
11476                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}}}}]}}}}"#,
11477                "a".repeat(64),
11478                "b".repeat(64),
11479                "c".repeat(64),
11480            ),
11481        );
11482
11483        let chunk = NodeRequest::PutDeliveryBlobChunk {
11484            stage_id: DeliveryStageId::new(format!(
11485                "delivery-stage-{}",
11486                "1".repeat(32),
11487            ))
11488            .unwrap(),
11489            blob_digest: DeliveryBlobDigestV1::new(format!("sha256:{}", "c".repeat(64)))
11490                .unwrap(),
11491            offset: 0,
11492            chunk_hex: DeliveryBlobChunkHexV1::new("00ff").unwrap(),
11493        };
11494        assert_eq!(
11495            serde_json::to_string(&chunk).unwrap(),
11496            format!(
11497                r#"{{"kind":"put-delivery-blob-chunk","stage_id":"delivery-stage-{}","blob_digest":"sha256:{}","offset":0,"chunk_hex":"00ff"}}"#,
11498                "1".repeat(32),
11499                "c".repeat(64),
11500            ),
11501        );
11502
11503        let committed = NodeResponse::DeliveryCommitted {
11504            receipt: DeliveryCommitReceiptV1 {
11505                bundle_id: SpawnBundleId::new("review-bundle").unwrap(),
11506                revision: SpawnBundleRevision::new("review-bundle.r2").unwrap(),
11507                bundle_digest: SpawnBundleDigest::new(format!("sha256:{}", "a".repeat(64)))
11508                    .unwrap(),
11509                manifest_digest: DeliveryManifestDigestV2::new(format!(
11510                    "sha256:{}",
11511                    "b".repeat(64),
11512                ))
11513                .unwrap(),
11514                blobs: vec![DeliveryBlobReceiptV1::new(
11515                    DeliveryBlobDigestV1::new(format!("sha256:{}", "c".repeat(64))).unwrap(),
11516                    12,
11517                )
11518                .unwrap()],
11519            },
11520        };
11521        assert_eq!(
11522            serde_json::from_str::<NodeResponse>(&serde_json::to_string(&committed).unwrap())
11523                .unwrap(),
11524            committed,
11525        );
11526    }
11527
11528    #[test]
11529    fn delivery_wire_rejects_bounds_order_and_case_fold_collisions() {
11530        assert!(DeliveryRelativePathV2::new("a".repeat(MAX_DELIVERY_RELATIVE_PATH_BYTES))
11531            .is_ok());
11532        assert!(DeliveryRelativePathV2::new("a".repeat(MAX_DELIVERY_RELATIVE_PATH_BYTES + 1))
11533            .is_err());
11534        for invalid in [
11535            "CON",
11536            "con.txt",
11537            "PRN.md",
11538            "AUX",
11539            "NUL.json",
11540            "COM1.txt",
11541            "LPT9",
11542            "trailing.",
11543            "trailing ",
11544            "bad<name",
11545            "bad>name",
11546            "bad\"name",
11547            "bad|name",
11548            "bad?name",
11549            "bad*name",
11550        ] {
11551            assert!(DeliveryRelativePathV2::new(invalid).is_err(), "{invalid}");
11552        }
11553        assert!(DeliveryBlobChunkHexV1::new("00".repeat(MAX_DELIVERY_CHUNK_RAW_BYTES)).is_ok());
11554        assert!(DeliveryBlobChunkHexV1::new("00".repeat(MAX_DELIVERY_CHUNK_RAW_BYTES + 1))
11555            .is_err());
11556        assert!(DeliveryBlobChunkHexV1::new("AA").is_err());
11557        assert!(DeliveryBlobChunkHexV1::new("").is_err());
11558
11559        let mut manifest = delivery_manifest();
11560        let mut collision = manifest.components[0].clone();
11561        collision.relative_path = DeliveryRelativePathV2::new("AGENTS/REVIEWER.MD").unwrap();
11562        manifest.components.push(collision);
11563        manifest.components.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
11564        let json = serde_json::to_string(&manifest).unwrap();
11565        assert!(serde_json::from_str::<DeliveryBundleManifestV2>(&json).is_err());
11566
11567        let mut over_total = delivery_manifest();
11568        over_total.components.clear();
11569        for index in 0..=MAX_DELIVERY_TOTAL_BYTES / MAX_DELIVERY_FILE_BYTES {
11570            over_total.components.push(DeliveryComponentV2 {
11571                kind: DeliveryComponentKindV2::File,
11572                scope: DeliveryScopeV2::Workspace,
11573                relative_path: DeliveryRelativePathV2::new(format!("file-{index:03}.txt"))
11574                    .unwrap(),
11575                blob: DeliveryBlobReceiptV1::new(
11576                    DeliveryBlobDigestV1::new(format!(
11577                        "sha256:{index:064x}",
11578                    ))
11579                    .unwrap(),
11580                    MAX_DELIVERY_FILE_BYTES as u64,
11581                )
11582                .unwrap(),
11583            });
11584        }
11585        assert!(over_total.validate().is_err());
11586        assert!(DeliveryBlobReceiptV1::new(
11587            DeliveryBlobDigestV1::new(format!("sha256:{}", "d".repeat(64))).unwrap(),
11588            MAX_DELIVERY_FILE_BYTES as u64 + 1,
11589        )
11590        .is_err());
11591
11592        let mut too_many = delivery_manifest();
11593        too_many.components = (0..=MAX_DELIVERY_FILES)
11594            .map(|index| DeliveryComponentV2 {
11595                kind: DeliveryComponentKindV2::File,
11596                scope: DeliveryScopeV2::Workspace,
11597                relative_path: DeliveryRelativePathV2::new(format!("file-{index:03}.txt"))
11598                    .unwrap(),
11599                blob: DeliveryBlobReceiptV1::new(
11600                    DeliveryBlobDigestV1::new(format!("sha256:{index:064x}")).unwrap(),
11601                    0,
11602                )
11603                .unwrap(),
11604            })
11605            .collect();
11606        assert!(serde_json::from_str::<DeliveryBundleManifestV2>(
11607            &serde_json::to_string(&too_many).unwrap(),
11608        )
11609        .is_err());
11610
11611        let digest_a = format!("sha256:{}", "a".repeat(64));
11612        let digest_b = format!("sha256:{}", "b".repeat(64));
11613        let unsorted = format!(
11614            r#"{{"kind":"delivery-stage-begun","stage_id":"delivery-stage-{}","manifest_digest":"{}","missing_blobs":["{}","{}"]}}"#,
11615            "1".repeat(32),
11616            digest_a,
11617            digest_b,
11618            digest_a,
11619        );
11620        assert!(serde_json::from_str::<NodeResponse>(&unsorted).is_err());
11621    }
11622
11623    /// `NodeRequest::is_replay_safe` covers every variant with no `_` arm
11624    /// (enforced at compile time, not by this test), so this only has to
11625    /// spot-check representative members of each side: pure reads and the
11626    /// harness-MCP read-proxy reply on the `true` side; a lifecycle
11627    /// transition, a spawn, an export, and the request-less `Shutdown` on
11628    /// the `false` side.
11629    #[test]
11630    fn replay_safety_predicate_admits_only_reads_and_the_harness_mcp_reply() {
11631        assert!(NodeRequest::Snapshot.is_replay_safe());
11632        assert!(NodeRequest::Resync { after_sequence: 0 }.is_replay_safe());
11633        assert!(NodeRequest::ReadWorkspaceFile {
11634            workspace_id: WorkspaceId::new("primary").unwrap(),
11635            path: repository_path("README.md"),
11636        }
11637        .is_replay_safe());
11638        assert!(NodeRequest::ReadContextPack {
11639            digest: SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).unwrap(),
11640        }
11641        .is_replay_safe());
11642        assert!(NodeRequest::PutHarnessMcpReplyChunk {
11643            reservation_id: HarnessMcpReservationId::new(format!(
11644                "hmcpres_{}", "a".repeat(24),
11645            )).unwrap(),
11646            activation_digest: HarnessMcpActivationDigest::new(format!(
11647                "sha256:{}", "b".repeat(64),
11648            )).unwrap(),
11649            record_id: SessionRecordId::new("record-a").unwrap(),
11650            session: session_address("primary", 1),
11651            call_id: HarnessMcpCallId::new(format!("hmcpcall_{}", "c".repeat(24))).unwrap(),
11652            offset: 0,
11653            final_chunk: true,
11654            chunk_hex: HarnessMcpReplyChunkHexV1::new("00".repeat(4)).unwrap(),
11655        }
11656        .is_replay_safe());
11657
11658        assert!(!NodeRequest::Shutdown.is_replay_safe());
11659        assert!(!NodeRequest::AcquireController { lease_ms: 1_000 }.is_replay_safe());
11660        assert!(!NodeRequest::ActivateHarnessMcpReservation {
11661            reservation_id: HarnessMcpReservationId::new(format!(
11662                "hmcpres_{}", "a".repeat(24),
11663            )).unwrap(),
11664            activation_digest: HarnessMcpActivationDigest::new(format!(
11665                "sha256:{}", "b".repeat(64),
11666            )).unwrap(),
11667            record_id: SessionRecordId::new("record-a").unwrap(),
11668            session: session_address("primary", 1),
11669        }
11670        .is_replay_safe());
11671        assert!(!NodeRequest::ExportContextPack { session: session_address("primary", 1) }
11672            .is_replay_safe());
11673    }
11674}
11675
11676#[cfg(test)]
11677mod spawn_with_options_wire_tests {
11678    use super::*;
11679
11680    #[test]
11681    fn plain_spawn_wire_is_unchanged_and_options_roundtrip() {
11682        let size = TerminalSize { rows: 24, columns: 80 };
11683        let plain = NodeRequest::Spawn {
11684            workspace_id: WorkspaceId::new("primary").unwrap(),
11685            provider: AgentId::new("codex").unwrap(),
11686            mode: SessionMode::Pty,
11687            terminal_size: size,
11688            initial_prompt: None,
11689        };
11690        let v = serde_json::to_value(&plain).unwrap();
11691        assert_eq!(v["kind"], "spawn");
11692        assert!(v.get("options").is_none());
11693        let with = NodeRequest::SpawnWithOptions {
11694            workspace_id: WorkspaceId::new("primary").unwrap(),
11695            provider: AgentId::new("codex").unwrap(),
11696            mode: SessionMode::Pty,
11697            terminal_size: size,
11698            initial_prompt: None,
11699            options: SpawnSessionOptions { model: Some("m-1".into()), effort: Some("high".into()), ..Default::default() },
11700        };
11701        let v = serde_json::to_value(&with).unwrap();
11702        assert_eq!(v["kind"], "spawn-with-options");
11703        assert_eq!(serde_json::from_value::<NodeRequest>(v).unwrap(), with);
11704        assert!(SpawnSessionOptions { model: Some("bad model".into()), ..Default::default() }.validate().is_err());
11705    }
11706}