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