1pub 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";
53pub const NODE_AGENT_STREAM_EVENTS_CAPABILITY: &str = "agent-stream-events-v1";
59pub 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";
93pub 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
127pub 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;
157pub 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;
177pub const MAX_ACP_CONTROL_ID_BYTES: usize = gate4agent_types::PROVIDER_EVENT_ID_MAX_BYTES;
183pub const MAX_ACP_CONTROL_TEXT_BYTES: usize = gate4agent_types::PROVIDER_EVENT_TEXT_MAX_BYTES;
188pub const MAX_ACP_CORRELATION_ID_BYTES: usize = 64;
191pub const MAX_ACP_BLOCKED_REASON_BYTES: usize = 1_024;
193pub const MAX_ACP_BLOCKED_HELP_BYTES: usize = 2_048;
197pub const MAX_ACP_INTERACTION_OPTIONS: usize =
200 gate4agent_types::PROVIDER_CONFIG_OPTION_CHOICES_MAX;
201pub 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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
512#[serde(transparent)]
513pub struct SpawnNetworkAllowlistId(String);
514
515#[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#[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 #[serde(skip_serializing_if = "Option::is_none")]
1050 pub approval_level: Option<ApprovalLevel>,
1051 #[serde(default, skip_serializing_if = "Option::is_none")]
1059 pub network_allowlist: Option<SpawnNetworkAllowlistId>,
1060 #[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 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 pub approval_level: ApprovalLevel,
1329 pub network_allowlist: Option<SpawnNetworkAllowlistId>,
1335 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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
1408 pub network_allowlist: Option<SpawnNetworkAllowlistId>,
1409 #[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 #[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
1721const CONTEXT_PACK_DIGEST_DOMAIN: &[u8] = b"g4a-context-pack-v1\0";
1725
1726pub 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
1750pub 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
1912pub const MAX_HARNESS_MCP_LAUNCH_NAME_BYTES: usize = 64;
1914pub const MAX_HARNESS_MCP_LAUNCH_ARGS: usize = 8;
1916pub const MAX_HARNESS_MCP_LAUNCH_ARG_BYTES: usize = 128;
1918pub const MAX_HARNESS_MCP_LAUNCH_SCRUB_ENV: usize = 8;
1920
1921#[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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1940#[serde(deny_unknown_fields)]
1941pub struct HarnessMcpLaunchV1 {
1942 pub server_name: String,
1944 pub args: Vec<String>,
1946 pub endpoint_env: String,
1948 pub token_env: String,
1950 pub program_env: String,
1952 pub trace: Option<HarnessMcpLaunchTraceV1>,
1954 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 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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
2020#[serde(rename_all = "kebab-case")]
2021pub enum HarnessMcpContentTypeV1 {
2022 HarnessReadRequestJsonV1,
2023 HarnessReadResponseJsonV1,
2024}
2025
2026#[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#[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 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
4120pub struct SessionRecordRetentionConfig {
4121 pub age_ms: u64,
4124 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
4482pub struct WorkspaceInspectionTruncationV1 {
4483 #[serde(default)]
4486 pub walk_time_budget_exceeded: bool,
4487 #[serde(default)]
4490 pub walk_entry_cap_exceeded: bool,
4491 #[serde(default)]
4495 pub git_time_budget_exceeded: bool,
4496 #[serde(default)]
4499 pub entries_visited: u64,
4500 #[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 pub incomplete: bool,
4621}
4622
4623fn deserialize_host_directory_entries<'de, D>(
4624 deserializer: D,
4625) -> Result<Vec<HostDirectoryEntry>, D::Error>
4626where
4627 D: Deserializer<'de>,
4628{
4629 struct HostDirectoryEntriesVisitor;
4630
4631 impl<'de> Visitor<'de> for HostDirectoryEntriesVisitor {
4632 type Value = Vec<HostDirectoryEntry>;
4633
4634 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
4635 write!(formatter, "at most {MAX_HOST_DIRECTORY_ENTRIES} host directory entries")
4636 }
4637
4638 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
4639 where
4640 A: SeqAccess<'de>,
4641 {
4642 let mut entries = Vec::with_capacity(
4643 sequence
4644 .size_hint()
4645 .unwrap_or(0)
4646 .min(MAX_HOST_DIRECTORY_ENTRIES),
4647 );
4648 while let Some(entry) = sequence.next_element::<HostDirectoryEntry>()? {
4649 if entries.len() == MAX_HOST_DIRECTORY_ENTRIES {
4650 return Err(serde::de::Error::invalid_length(entries.len() + 1, &self));
4651 }
4652 entries.push(entry);
4653 }
4654 Ok(entries)
4655 }
4656 }
4657
4658 deserializer.deserialize_seq(HostDirectoryEntriesVisitor)
4659}
4660
4661#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4662pub struct WorkspaceFileRead {
4663 pub workspace_id: WorkspaceId,
4664 pub path: RepositoryPath,
4665 pub content: WorkspaceFileContent,
4666 #[serde(default, skip_serializing_if = "Option::is_none")]
4667 pub revision: Option<WorkspaceFileRevision>,
4668}
4669
4670#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
4671#[serde(transparent)]
4672pub struct WorkspaceFileRevision(String);
4673
4674impl WorkspaceFileRevision {
4675 pub fn new(value: String) -> Result<Self, WorkspaceFileRevisionError> {
4676 if value.len() != 64
4677 || !value.bytes().all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
4678 {
4679 return Err(WorkspaceFileRevisionError);
4680 }
4681 Ok(Self(value))
4682 }
4683
4684 pub fn as_str(&self) -> &str {
4685 &self.0
4686 }
4687}
4688
4689impl<'de> Deserialize<'de> for WorkspaceFileRevision {
4690 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4691 where
4692 D: Deserializer<'de>,
4693 {
4694 Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
4695 }
4696}
4697
4698#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
4699#[error("workspace file revision must be a 64-character lowercase SHA-256 digest")]
4700pub struct WorkspaceFileRevisionError;
4701
4702#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
4703#[serde(tag = "kind", rename_all = "kebab-case")]
4704pub enum WorkspaceFileContent {
4705 Utf8 { text: String, byte_len: u32 },
4706 NonUtf8 { byte_len: u32 },
4707 TooLarge { limit_bytes: u32 },
4708}
4709
4710#[derive(Deserialize)]
4711#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
4712enum WorkspaceFileContentWire {
4713 Utf8 { text: String, byte_len: u32 },
4714 NonUtf8 { byte_len: u32 },
4715 TooLarge { limit_bytes: u32 },
4716}
4717
4718impl<'de> Deserialize<'de> for WorkspaceFileContent {
4719 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4720 where
4721 D: Deserializer<'de>,
4722 {
4723 let wire = WorkspaceFileContentWire::deserialize(deserializer)?;
4724 let limit_bytes = MAX_WORKSPACE_FILE_BYTES as u32;
4725 match wire {
4726 WorkspaceFileContentWire::Utf8 { text, byte_len } => {
4727 let actual_bytes = text.len();
4728 if actual_bytes > MAX_WORKSPACE_FILE_BYTES {
4729 return Err(serde::de::Error::custom(format!(
4730 "workspace file content length {actual_bytes} exceeds the {MAX_WORKSPACE_FILE_BYTES}-byte limit",
4731 )));
4732 }
4733 if u64::from(byte_len) != actual_bytes as u64 {
4734 return Err(serde::de::Error::custom(format!(
4735 "workspace file content declares {byte_len} bytes but contains {actual_bytes}",
4736 )));
4737 }
4738 Ok(Self::Utf8 { text, byte_len })
4739 }
4740 WorkspaceFileContentWire::NonUtf8 { byte_len } => {
4741 if byte_len > limit_bytes {
4742 return Err(serde::de::Error::custom(format!(
4743 "workspace non-UTF-8 file length {byte_len} exceeds the {MAX_WORKSPACE_FILE_BYTES}-byte limit",
4744 )));
4745 }
4746 Ok(Self::NonUtf8 { byte_len })
4747 }
4748 WorkspaceFileContentWire::TooLarge {
4749 limit_bytes: declared_limit,
4750 } => {
4751 if declared_limit != limit_bytes {
4752 return Err(serde::de::Error::custom(format!(
4753 "workspace file limit {declared_limit} does not match protocol limit {limit_bytes}",
4754 )));
4755 }
4756 Ok(Self::TooLarge { limit_bytes })
4757 }
4758 }
4759 }
4760}
4761
4762#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4763#[serde(rename_all = "kebab-case")]
4764pub enum AgentProgressCurrentV1 {
4765 Idle,
4766 Working,
4767 WaitingForInput,
4768 Blocked,
4769}
4770
4771impl From<ProviderActivity> for AgentProgressCurrentV1 {
4772 fn from(activity: ProviderActivity) -> Self {
4773 match activity {
4774 ProviderActivity::Idle => Self::Idle,
4775 ProviderActivity::Working => Self::Working,
4776 ProviderActivity::WaitingForInput => Self::WaitingForInput,
4777 ProviderActivity::Blocked => Self::Blocked,
4778 }
4779 }
4780}
4781
4782#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4783#[serde(rename_all = "kebab-case")]
4784pub enum AgentProgressAttentionKindV1 {
4785 Approval,
4786 Question,
4787}
4788
4789#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4790#[serde(deny_unknown_fields)]
4791pub struct AgentProgressAttentionV1 {
4792 pub kind: AgentProgressAttentionKindV1,
4793 #[serde(default, skip_serializing_if = "Option::is_none")]
4794 pub tool_label: Option<String>,
4795}
4796
4797#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4798#[serde(rename_all = "kebab-case")]
4799pub enum AgentProgressEventKindV1 {
4800 SessionStarted,
4801 SessionIdentityObserved,
4802 TurnStarted,
4803 WorkingObserved,
4804 Text,
4805 Thinking,
4806 ToolStarted,
4807 ToolCompleted,
4808 TurnCompleted,
4809 TurnInterrupted,
4810 SessionEnded,
4811 Error,
4812 Ready,
4813 InteractionRequested,
4814 InteractionResolved,
4815 SubagentStarted,
4816 SubagentStopped,
4817 RateLimited,
4818 HostRequestObserved,
4819 UnrecognizedNotification,
4820}
4821
4822#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
4823#[serde(deny_unknown_fields)]
4824pub struct AgentProgressUsageV1 {
4825 pub input_tokens: u64,
4826 pub output_tokens: u64,
4827 pub cache_read_tokens: u64,
4828 pub cache_write_tokens: u64,
4829 pub reasoning_tokens: u64,
4830}
4831
4832#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
4833#[serde(deny_unknown_fields)]
4834pub struct AgentProgressV1 {
4835 pub provider_sequence: u64,
4836 pub activity: ProviderActivity,
4837 pub completed_turns: u64,
4838 #[serde(default, skip_serializing_if = "Option::is_none")]
4839 pub usage: Option<AgentProgressUsageV1>,
4840 pub current: AgentProgressCurrentV1,
4841 pub active_tool_labels: Vec<String>,
4842 pub active_tool_count: u32,
4843 #[serde(default, skip_serializing_if = "Option::is_none")]
4844 pub attention: Option<AgentProgressAttentionV1>,
4845 pub subagent_count: u32,
4846 #[serde(default, skip_serializing_if = "Option::is_none")]
4847 pub last_event_kind: Option<AgentProgressEventKindV1>,
4848 pub gap_count: u64,
4849 pub stale: bool,
4850 pub truncated: bool,
4851}
4852
4853impl<'de> Deserialize<'de> for AgentProgressV1 {
4854 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
4855 where
4856 D: Deserializer<'de>,
4857 {
4858 #[derive(Deserialize)]
4859 #[serde(deny_unknown_fields)]
4860 struct WireProgress {
4861 provider_sequence: u64,
4862 activity: ProviderActivity,
4863 completed_turns: u64,
4864 #[serde(default)]
4865 usage: Option<AgentProgressUsageV1>,
4866 current: AgentProgressCurrentV1,
4867 active_tool_labels: Vec<String>,
4868 active_tool_count: u32,
4869 #[serde(default)]
4870 attention: Option<AgentProgressAttentionV1>,
4871 subagent_count: u32,
4872 #[serde(default)]
4873 last_event_kind: Option<AgentProgressEventKindV1>,
4874 gap_count: u64,
4875 stale: bool,
4876 truncated: bool,
4877 }
4878
4879 let wire = WireProgress::deserialize(deserializer)?;
4880 if wire.active_tool_labels.len() > MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
4881 return Err(serde::de::Error::custom(
4882 "agent progress contains too many active tool labels",
4883 ));
4884 }
4885 if usize::try_from(wire.active_tool_count).unwrap_or(usize::MAX)
4886 < wire.active_tool_labels.len()
4887 {
4888 return Err(serde::de::Error::custom(
4889 "agent progress active tool count is smaller than its labels",
4890 ));
4891 }
4892 for label in &wire.active_tool_labels {
4893 validate_agent_progress_tool_label(label)
4894 .map_err(serde::de::Error::custom)?;
4895 }
4896 if let Some(label) = wire
4897 .attention
4898 .as_ref()
4899 .and_then(|attention| attention.tool_label.as_deref())
4900 {
4901 validate_agent_progress_tool_label(label)
4902 .map_err(serde::de::Error::custom)?;
4903 }
4904 if wire.current != AgentProgressCurrentV1::from(wire.activity) {
4905 return Err(serde::de::Error::custom(
4906 "agent progress current state conflicts with provider activity",
4907 ));
4908 }
4909 Ok(Self {
4910 provider_sequence: wire.provider_sequence,
4911 activity: wire.activity,
4912 completed_turns: wire.completed_turns,
4913 usage: wire.usage,
4914 current: wire.current,
4915 active_tool_labels: wire.active_tool_labels,
4916 active_tool_count: wire.active_tool_count,
4917 attention: wire.attention,
4918 subagent_count: wire.subagent_count,
4919 last_event_kind: wire.last_event_kind,
4920 gap_count: wire.gap_count,
4921 stale: wire.stale,
4922 truncated: wire.truncated,
4923 })
4924 }
4925}
4926
4927fn validate_agent_progress_tool_label(label: &str) -> Result<(), &'static str> {
4928 if !matches!(
4929 label,
4930 "Read" | "Write" | "Edit" | "Shell" | "Search" | "Browse" | "Git"
4931 | "Ask" | "Task" | "Tool"
4932 ) {
4933 return Err("agent progress tool label is outside the safe class vocabulary");
4934 }
4935 Ok(())
4936}
4937
4938#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4939#[serde(deny_unknown_fields)]
4940pub struct SessionAgentProgress {
4941 pub address: SessionAddress,
4942 pub progress: AgentProgressV1,
4943}
4944
4945#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
4946pub struct NodeSnapshot {
4947 pub node_id: NodeId,
4948 pub enabled_providers: Vec<AgentId>,
4949 #[serde(default, skip_serializing_if = "ProviderRuntimeStatuses::is_empty")]
4950 pub provider_runtime_statuses: ProviderRuntimeStatuses,
4951 pub workspaces: Vec<WorkspaceSnapshot>,
4952 #[serde(default)]
4953 pub session_records: Vec<ManagedSessionRecord>,
4954 #[serde(
4955 default,
4956 skip_serializing_if = "Vec::is_empty",
4957 deserialize_with = "deserialize_managed_worktree_leases"
4958 )]
4959 pub managed_worktrees: Vec<ManagedWorktreeLeaseSnapshot>,
4960 #[serde(default, skip_serializing_if = "Option::is_none")]
4961 pub launch_inventory: Option<LaunchInventory>,
4962 #[serde(
4963 default,
4964 skip_serializing_if = "Vec::is_empty",
4965 deserialize_with = "deserialize_agent_progress_entries"
4966 )]
4967 pub agent_progress: Vec<SessionAgentProgress>,
4968}
4969
4970impl NodeSnapshot {
4971 pub fn requires_child_environment_profile_capability(&self) -> bool {
4972 self.session_records
4973 .iter()
4974 .any(|record| record.environment_profile.is_some())
4975 || self.launch_inventory.as_ref().is_some_and(|inventory| {
4976 inventory.spawn_profiles.as_ref().is_some_and(|profiles| {
4977 profiles
4978 .iter()
4979 .any(|profile| profile.environment_profile.is_some())
4980 })
4981 })
4982 }
4983
4984 pub fn requires_session_bundle_materialization_capability(&self) -> bool {
4985 self.session_records
4986 .iter()
4987 .any(|record| record.bundle.is_some())
4988 }
4989
4990 pub fn requires_history_context_pack_capability(&self) -> bool {
4991 self.session_records.iter().any(|record| {
4992 record.context_id.is_some() || record.context.is_some()
4993 })
4994 }
4995}
4996
4997fn deserialize_agent_progress_entries<'de, D>(
4998 deserializer: D,
4999) -> Result<Vec<SessionAgentProgress>, D::Error>
5000where
5001 D: Deserializer<'de>,
5002{
5003 struct AgentProgressEntriesVisitor;
5004
5005 impl<'de> Visitor<'de> for AgentProgressEntriesVisitor {
5006 type Value = Vec<SessionAgentProgress>;
5007
5008 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5009 write!(
5010 formatter,
5011 "at most {MAX_AGENT_PROGRESS_ENTRIES} bounded agent progress entries",
5012 )
5013 }
5014
5015 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
5016 where
5017 A: SeqAccess<'de>,
5018 {
5019 let mut entries = Vec::with_capacity(
5020 sequence
5021 .size_hint()
5022 .unwrap_or(0)
5023 .min(MAX_AGENT_PROGRESS_ENTRIES),
5024 );
5025 while let Some(value) = sequence.next_element::<serde_json::Value>()? {
5026 if entries.len() == MAX_AGENT_PROGRESS_ENTRIES {
5027 continue;
5028 }
5029 let Ok(encoded) = serde_json::to_vec(&value) else {
5030 continue;
5031 };
5032 if encoded.len() > MAX_AGENT_PROGRESS_ENTRY_BYTES {
5033 continue;
5034 }
5035 let Ok(entry) = serde_json::from_value::<SessionAgentProgress>(value) else {
5036 continue;
5037 };
5038 if entries.iter().any(|existing: &SessionAgentProgress| {
5039 existing.address == entry.address
5040 }) {
5041 continue;
5042 }
5043 entries.push(entry);
5044 }
5045 Ok(entries)
5046 }
5047 }
5048
5049 deserializer.deserialize_seq(AgentProgressEntriesVisitor)
5050}
5051
5052fn deserialize_history_discovery_limit<'de, D>(deserializer: D) -> Result<u16, D::Error>
5053where
5054 D: Deserializer<'de>,
5055{
5056 let limit = u16::deserialize(deserializer)?;
5057 if !(1..=HISTORY_DISCOVERY_LIMIT_MAX).contains(&limit) {
5058 return Err(serde::de::Error::custom(
5059 "history discovery limit is outside the supported bounded range",
5060 ));
5061 }
5062 Ok(limit)
5063}
5064
5065fn deserialize_native_session_catalog_limit<'de, D>(deserializer: D) -> Result<u16, D::Error>
5066where
5067 D: Deserializer<'de>,
5068{
5069 let limit = u16::deserialize(deserializer)?;
5070 if !(1..=NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
5071 return Err(serde::de::Error::custom(
5072 "native session catalog limit is outside the supported bounded range",
5073 ));
5074 }
5075 Ok(limit)
5076}
5077
5078fn deserialize_native_session_catalog_entries<'de, D>(
5079 deserializer: D,
5080) -> Result<Vec<NativeSessionCatalogEntry>, D::Error>
5081where
5082 D: Deserializer<'de>,
5083{
5084 let entries = Vec::<NativeSessionCatalogEntry>::deserialize(deserializer)?;
5085 if entries.len() > usize::from(NATIVE_SESSION_CATALOG_LIMIT_MAX) {
5086 return Err(serde::de::Error::custom(
5087 "native session catalog exceeds the supported bounded range",
5088 ));
5089 }
5090 for (index, entry) in entries.iter().enumerate() {
5091 entry.validate().map_err(serde::de::Error::custom)?;
5092 if entries[..index]
5093 .iter()
5094 .any(|existing| existing.selection_id == entry.selection_id)
5095 {
5096 return Err(serde::de::Error::custom(
5097 "native session catalog contains a duplicate selection ID",
5098 ));
5099 }
5100 }
5101 Ok(entries)
5102}
5103
5104fn validate_native_session_catalog_entries(
5105 route: &NativeSessionCatalogRoute,
5106 entries: &[NativeSessionCatalogEntry],
5107) -> Result<(), &'static str> {
5108 if entries.len() > usize::from(NATIVE_SESSION_CATALOG_LIMIT_MAX) {
5109 return Err("native session catalog exceeds the bounded entry limit");
5110 }
5111 for (index, entry) in entries.iter().enumerate() {
5112 entry.validate_for_route(route)?;
5113 if entries[..index]
5114 .iter()
5115 .any(|existing| existing.selection_id == entry.selection_id)
5116 {
5117 return Err("native session catalog contains duplicate selections");
5118 }
5119 if entry.record_id.as_ref().is_some_and(|record_id| {
5120 entries[..index]
5121 .iter()
5122 .any(|existing| existing.record_id.as_ref() == Some(record_id))
5123 }) {
5124 return Err("native session catalog contains duplicate managed records");
5125 }
5126 }
5127 Ok(())
5128}
5129
5130fn deserialize_optional_native_session_catalog_summary<'de, D>(
5131 deserializer: D,
5132) -> Result<Option<NativeSessionCatalogSummary>, D::Error>
5133where
5134 D: Deserializer<'de>,
5135{
5136 let summary = Option::<NativeSessionCatalogSummary>::deserialize(deserializer)?;
5137 if let Some(summary) = summary.as_ref() {
5138 summary.validate().map_err(serde::de::Error::custom)?;
5139 }
5140 Ok(summary)
5141}
5142
5143fn deserialize_native_session_catalog_page<'de, D>(
5144 deserializer: D,
5145) -> Result<NativeSessionCatalogPage, D::Error>
5146where
5147 D: Deserializer<'de>,
5148{
5149 let page = NativeSessionCatalogPage::deserialize(deserializer)?;
5150 page.validate().map_err(serde::de::Error::custom)?;
5151 Ok(page)
5152}
5153
5154fn deserialize_native_session_preview_limit<'de, D>(deserializer: D) -> Result<u16, D::Error>
5155where
5156 D: Deserializer<'de>,
5157{
5158 let limit = u16::deserialize(deserializer)?;
5159 if !(1..=NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX).contains(&limit) {
5160 return Err(serde::de::Error::custom(
5161 "native session preview limit is outside the supported bounded range",
5162 ));
5163 }
5164 Ok(limit)
5165}
5166
5167fn deserialize_native_session_preview<'de, D>(
5168 deserializer: D,
5169) -> Result<NativeSessionPreview, D::Error>
5170where
5171 D: Deserializer<'de>,
5172{
5173 let preview = NativeSessionPreview::deserialize(deserializer)?;
5174 preview.validate().map_err(serde::de::Error::custom)?;
5175 Ok(preview)
5176}
5177
5178fn deserialize_session_record_preview<'de, D>(
5179 deserializer: D,
5180) -> Result<SessionRecordPreview, D::Error>
5181where
5182 D: Deserializer<'de>,
5183{
5184 let preview = SessionRecordPreview::deserialize(deserializer)?;
5185 preview.validate().map_err(serde::de::Error::custom)?;
5186 Ok(preview)
5187}
5188
5189fn deserialize_history_candidate_id<'de, D>(deserializer: D) -> Result<String, D::Error>
5190where
5191 D: Deserializer<'de>,
5192{
5193 let candidate_id = String::deserialize(deserializer)?;
5194 gate4agent_types::validate_candidate_id(&candidate_id)
5195 .map_err(serde::de::Error::custom)?;
5196 Ok(candidate_id)
5197}
5198
5199fn deserialize_optional_history_candidate_id<'de, D>(
5200 deserializer: D,
5201) -> Result<Option<String>, D::Error>
5202where
5203 D: Deserializer<'de>,
5204{
5205 let candidate_id = Option::<String>::deserialize(deserializer)?;
5206 if let Some(candidate_id) = candidate_id.as_deref() {
5207 gate4agent_types::validate_candidate_id(candidate_id)
5208 .map_err(serde::de::Error::custom)?;
5209 }
5210 Ok(candidate_id)
5211}
5212
5213fn deserialize_history_session_id<'de, D>(deserializer: D) -> Result<String, D::Error>
5214where
5215 D: Deserializer<'de>,
5216{
5217 let session_id = String::deserialize(deserializer)?;
5218 let validation = gate4agent_types::HistorySessionRecord {
5219 session_id: session_id.clone(),
5220 title: None,
5221 cwd: None,
5222 model: None,
5223 message_count: 0,
5224 completed_turn_count: None,
5225 total_tokens: 0,
5226 messages: Vec::new(),
5227 };
5228 validation.validate().map_err(serde::de::Error::custom)?;
5229 Ok(session_id)
5230}
5231
5232fn deserialize_history_candidates<'de, D>(
5233 deserializer: D,
5234) -> Result<Vec<HistoryCandidateSummary>, D::Error>
5235where
5236 D: Deserializer<'de>,
5237{
5238 let candidates = Vec::<HistoryCandidateSummary>::deserialize(deserializer)?;
5239 if candidates.len() > usize::from(HISTORY_DISCOVERY_LIMIT_MAX) {
5240 return Err(serde::de::Error::custom(
5241 "history candidate count exceeds the discovery limit",
5242 ));
5243 }
5244 for (index, candidate) in candidates.iter().enumerate() {
5245 candidate.validate().map_err(serde::de::Error::custom)?;
5246 if candidates[..index]
5247 .iter()
5248 .any(|existing| existing.id == candidate.id)
5249 {
5250 return Err(serde::de::Error::custom(
5251 "history candidates contain a duplicate candidate ID",
5252 ));
5253 }
5254 }
5255 Ok(candidates)
5256}
5257
5258fn deserialize_managed_worktree_leases<'de, D>(
5259 deserializer: D,
5260) -> Result<Vec<ManagedWorktreeLeaseSnapshot>, D::Error>
5261where
5262 D: Deserializer<'de>,
5263{
5264 struct ManagedWorktreeLeasesVisitor;
5265
5266 impl<'de> Visitor<'de> for ManagedWorktreeLeasesVisitor {
5267 type Value = Vec<ManagedWorktreeLeaseSnapshot>;
5268
5269 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5270 write!(
5271 formatter,
5272 "at most {MAX_MANAGED_WORKTREE_LEASES} managed worktree leases",
5273 )
5274 }
5275
5276 fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
5277 where
5278 A: SeqAccess<'de>,
5279 {
5280 let mut leases = Vec::with_capacity(
5281 sequence
5282 .size_hint()
5283 .unwrap_or(0)
5284 .min(MAX_MANAGED_WORKTREE_LEASES),
5285 );
5286 while let Some(lease) = sequence.next_element::<ManagedWorktreeLeaseSnapshot>()? {
5287 if leases.len() == MAX_MANAGED_WORKTREE_LEASES {
5288 return Err(serde::de::Error::invalid_length(leases.len() + 1, &self));
5289 }
5290 if leases.iter().any(|existing: &ManagedWorktreeLeaseSnapshot| {
5291 existing.lease_id == lease.lease_id
5292 }) {
5293 return Err(serde::de::Error::custom(
5294 "managed worktree snapshot contains a duplicate lease ID",
5295 ));
5296 }
5297 if leases.iter().any(|existing: &ManagedWorktreeLeaseSnapshot| {
5298 existing.workspace_id == lease.workspace_id
5299 }) {
5300 return Err(serde::de::Error::custom(
5301 "managed worktree snapshot contains a duplicate workspace ID",
5302 ));
5303 }
5304 leases.push(lease);
5305 }
5306 Ok(leases)
5307 }
5308 }
5309
5310 deserializer.deserialize_seq(ManagedWorktreeLeasesVisitor)
5311}
5312
5313#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5314pub struct ClientHello {
5315 pub build_stamp: String,
5316 pub role: ClientRole,
5317 pub client_nonce: [u8; NODE_AUTH_NONCE_BYTES],
5318 #[serde(default, skip_serializing_if = "Option::is_none")]
5319 pub compatibility: Option<ClientCompatibilityOffer>,
5320}
5321
5322impl ClientHello {
5323 pub fn new(role: ClientRole, client_nonce: [u8; NODE_AUTH_NONCE_BYTES]) -> Self {
5324 Self {
5325 build_stamp: BUILD_STAMP.to_owned(),
5326 role,
5327 client_nonce,
5328 compatibility: None,
5329 }
5330 }
5331
5332 pub fn negotiating(
5333 role: ClientRole,
5334 client_nonce: [u8; NODE_AUTH_NONCE_BYTES],
5335 compatibility: ClientCompatibilityOffer,
5336 ) -> Self {
5337 Self {
5338 build_stamp: BUILD_STAMP.to_owned(),
5339 role,
5340 client_nonce,
5341 compatibility: Some(compatibility),
5342 }
5343 }
5344}
5345
5346#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5347pub struct ServerChallenge {
5348 pub build_stamp: String,
5349 pub server_nonce: [u8; NODE_AUTH_NONCE_BYTES],
5350 pub server_proof: [u8; NODE_AUTH_PROOF_BYTES],
5351 #[serde(default, skip_serializing_if = "Option::is_none")]
5352 pub compatibility: Option<NegotiatedNodeCompatibility>,
5353}
5354
5355#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5356pub struct ClientAuthentication {
5357 pub client_proof: [u8; NODE_AUTH_PROOF_BYTES],
5358}
5359
5360#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5361pub struct ControllerState {
5362 pub connection_id: u64,
5363 pub lease_remaining_ms: u64,
5364}
5365
5366#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5367#[serde(transparent)]
5368pub struct DeliveryBlobDigestV1(String);
5369
5370#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5371#[serde(transparent)]
5372pub struct DeliveryManifestDigestV2(String);
5373
5374macro_rules! delivery_digest_impl {
5375 ($type:ident, $label:literal) => {
5376 impl $type {
5377 pub fn new(value: impl Into<String>) -> Result<Self, DeliveryDigestError> {
5378 let value = value.into();
5379 let hex = value.strip_prefix("sha256:").ok_or(DeliveryDigestError($label))?;
5380 if hex.len() != 64
5381 || !hex.bytes().all(|byte| {
5382 byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
5383 })
5384 {
5385 return Err(DeliveryDigestError($label));
5386 }
5387 Ok(Self(value))
5388 }
5389
5390 pub fn as_str(&self) -> &str {
5391 &self.0
5392 }
5393 }
5394
5395 impl fmt::Display for $type {
5396 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5397 formatter.write_str(self.as_str())
5398 }
5399 }
5400
5401 impl FromStr for $type {
5402 type Err = DeliveryDigestError;
5403
5404 fn from_str(value: &str) -> Result<Self, Self::Err> {
5405 Self::new(value)
5406 }
5407 }
5408
5409 impl<'de> Deserialize<'de> for $type {
5410 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5411 where
5412 D: Deserializer<'de>,
5413 {
5414 Self::new(String::deserialize(deserializer)?)
5415 .map_err(serde::de::Error::custom)
5416 }
5417 }
5418 };
5419}
5420
5421delivery_digest_impl!(DeliveryBlobDigestV1, "delivery blob");
5422delivery_digest_impl!(DeliveryManifestDigestV2, "delivery manifest");
5423
5424#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
5425#[error("{0} digest must be sha256: followed by exactly 64 lowercase hexadecimal characters")]
5426pub struct DeliveryDigestError(&'static str);
5427
5428#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5429#[serde(transparent)]
5430pub struct DeliveryStageId(String);
5431
5432impl DeliveryStageId {
5433 pub fn new(value: impl Into<String>) -> Result<Self, DeliveryStageIdError> {
5434 let value = value.into();
5435 let hex = value
5436 .strip_prefix("delivery-stage-")
5437 .ok_or(DeliveryStageIdError)?;
5438 if hex.len() != DELIVERY_STAGE_NONCE_BYTES * 2
5439 || !hex.bytes().all(|byte| {
5440 byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
5441 })
5442 {
5443 return Err(DeliveryStageIdError);
5444 }
5445 Ok(Self(value))
5446 }
5447
5448 pub fn from_nonce(nonce: [u8; DELIVERY_STAGE_NONCE_BYTES]) -> Self {
5449 let mut value = String::with_capacity(15 + DELIVERY_STAGE_NONCE_BYTES * 2);
5450 value.push_str("delivery-stage-");
5451 for byte in nonce {
5452 use std::fmt::Write as _;
5453 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
5454 }
5455 Self(value)
5456 }
5457
5458 pub fn as_str(&self) -> &str {
5459 &self.0
5460 }
5461}
5462
5463impl fmt::Display for DeliveryStageId {
5464 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5465 formatter.write_str(self.as_str())
5466 }
5467}
5468
5469impl<'de> Deserialize<'de> for DeliveryStageId {
5470 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5471 where
5472 D: Deserializer<'de>,
5473 {
5474 Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
5475 }
5476}
5477
5478#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
5479#[error("delivery stage ID must be delivery-stage- followed by exactly 32 lowercase hexadecimal characters")]
5480pub struct DeliveryStageIdError;
5481
5482#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5483#[serde(transparent)]
5484pub struct DeliveryRelativePathV2(String);
5485
5486impl DeliveryRelativePathV2 {
5487 pub fn new(value: impl Into<String>) -> Result<Self, DeliveryRelativePathError> {
5488 let value = value.into();
5489 if value.is_empty()
5490 || value.len() > MAX_DELIVERY_RELATIVE_PATH_BYTES
5491 || value.starts_with('/')
5492 || value.ends_with('/')
5493 || value.contains('\\')
5494 || value.chars().any(char::is_control)
5495 || value
5496 .split('/')
5497 .any(|part| !delivery_path_component_is_portable(part))
5498 {
5499 return Err(DeliveryRelativePathError);
5500 }
5501 Ok(Self(value))
5502 }
5503
5504 pub fn as_str(&self) -> &str {
5505 &self.0
5506 }
5507}
5508
5509fn delivery_path_component_is_portable(component: &str) -> bool {
5510 let invalid_character = component.chars().any(|character| {
5511 character <= '\u{1f}'
5512 || matches!(character, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
5513 });
5514 let stem = component.split('.').next().unwrap_or(component);
5515 let uppercase_stem = stem.to_ascii_uppercase();
5516 let reserved = matches!(uppercase_stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
5517 || delivery_reserved_numbered_name(&uppercase_stem, "COM")
5518 || delivery_reserved_numbered_name(&uppercase_stem, "LPT");
5519 !component.is_empty()
5520 && component != "."
5521 && component != ".."
5522 && !component.ends_with('.')
5523 && !component.ends_with(' ')
5524 && !invalid_character
5525 && !reserved
5526}
5527
5528fn delivery_reserved_numbered_name(value: &str, prefix: &str) -> bool {
5529 value.strip_prefix(prefix).is_some_and(|suffix| {
5530 suffix.len() == 1 && matches!(suffix.as_bytes()[0], b'1'..=b'9')
5531 })
5532}
5533
5534impl fmt::Display for DeliveryRelativePathV2 {
5535 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
5536 formatter.write_str(self.as_str())
5537 }
5538}
5539
5540impl<'de> Deserialize<'de> for DeliveryRelativePathV2 {
5541 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5542 where
5543 D: Deserializer<'de>,
5544 {
5545 Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
5546 }
5547}
5548
5549#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
5550#[error("delivery path must be a safe forward-slash relative path of at most 512 UTF-8 bytes")]
5551pub struct DeliveryRelativePathError;
5552
5553#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5554#[serde(rename_all = "kebab-case")]
5555pub enum DeliveryScopeV2 {
5556 Workspace,
5557 Session,
5558}
5559
5560#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5561#[serde(rename_all = "kebab-case")]
5562pub enum DeliveryComponentKindV2 {
5563 Skill,
5564 PluginManifest,
5565 Prompt,
5566 Instructions,
5567 AgentDefinition,
5568 Command,
5569 File,
5570 Template,
5571 McpDeclaration,
5572}
5573
5574#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
5575#[serde(deny_unknown_fields)]
5576pub struct DeliveryBlobReceiptV1 {
5577 pub digest: DeliveryBlobDigestV1,
5578 pub byte_len: u64,
5579}
5580
5581impl DeliveryBlobReceiptV1 {
5582 pub fn new(
5583 digest: DeliveryBlobDigestV1,
5584 byte_len: u64,
5585 ) -> Result<Self, DeliveryContractError> {
5586 if byte_len > MAX_DELIVERY_FILE_BYTES as u64 {
5587 return Err(DeliveryContractError::FileTooLarge);
5588 }
5589 Ok(Self { digest, byte_len })
5590 }
5591}
5592
5593impl<'de> Deserialize<'de> for DeliveryBlobReceiptV1 {
5594 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5595 where
5596 D: Deserializer<'de>,
5597 {
5598 #[derive(Deserialize)]
5599 #[serde(deny_unknown_fields)]
5600 struct Wire {
5601 digest: DeliveryBlobDigestV1,
5602 byte_len: u64,
5603 }
5604
5605 let wire = Wire::deserialize(deserializer)?;
5606 Self::new(wire.digest, wire.byte_len).map_err(serde::de::Error::custom)
5607 }
5608}
5609
5610#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5611#[serde(deny_unknown_fields)]
5612pub struct DeliveryComponentV2 {
5613 pub kind: DeliveryComponentKindV2,
5614 pub scope: DeliveryScopeV2,
5615 pub relative_path: DeliveryRelativePathV2,
5616 pub blob: DeliveryBlobReceiptV1,
5617}
5618
5619#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
5620#[serde(deny_unknown_fields)]
5621pub struct DeliveryBundleManifestV2 {
5622 pub bundle_id: SpawnBundleId,
5623 pub revision: SpawnBundleRevision,
5624 pub bundle_digest: SpawnBundleDigest,
5625 pub manifest_digest: DeliveryManifestDigestV2,
5626 pub components: Vec<DeliveryComponentV2>,
5627}
5628
5629impl DeliveryBundleManifestV2 {
5630 pub fn validate(&self) -> Result<(), DeliveryContractError> {
5631 if self.components.is_empty() {
5632 return Err(DeliveryContractError::EmptyManifest);
5633 }
5634 if self.components.len() > MAX_DELIVERY_FILES {
5635 return Err(DeliveryContractError::TooManyFiles);
5636 }
5637 let mut total = 0_u64;
5638 let mut previous_path: Option<&str> = None;
5639 let mut folded_paths = std::collections::BTreeSet::new();
5640 for component in &self.components {
5641 let path = component.relative_path.as_str();
5642 if previous_path.is_some_and(|previous| previous >= path) {
5643 return Err(DeliveryContractError::ComponentsNotOrdered);
5644 }
5645 previous_path = Some(path);
5646 let folded = path.to_lowercase();
5647 if !folded_paths.insert(folded) {
5648 return Err(DeliveryContractError::CaseFoldPathCollision);
5649 }
5650 total = total
5651 .checked_add(component.blob.byte_len)
5652 .ok_or(DeliveryContractError::TotalTooLarge)?;
5653 if total > MAX_DELIVERY_TOTAL_BYTES as u64 {
5654 return Err(DeliveryContractError::TotalTooLarge);
5655 }
5656 }
5657 Ok(())
5658 }
5659
5660 pub fn canonical_manifest_digest_material(&self) -> Vec<u8> {
5661 fn push_field(material: &mut Vec<u8>, value: &[u8]) {
5662 material.extend_from_slice(&(value.len() as u32).to_be_bytes());
5663 material.extend_from_slice(value);
5664 }
5665
5666 let mut material = Vec::new();
5667 material.extend_from_slice(b"g4a-delivery-manifest-v2\0");
5668 push_field(&mut material, self.bundle_id.as_str().as_bytes());
5669 push_field(&mut material, self.revision.as_str().as_bytes());
5670 push_field(&mut material, self.bundle_digest.as_str().as_bytes());
5671 material.extend_from_slice(&(self.components.len() as u32).to_be_bytes());
5672 for component in &self.components {
5673 material.push(component.kind.canonical_tag());
5674 material.push(component.scope.canonical_tag());
5675 push_field(&mut material, component.relative_path.as_str().as_bytes());
5676 push_field(&mut material, component.blob.digest.as_str().as_bytes());
5677 material.extend_from_slice(&component.blob.byte_len.to_be_bytes());
5678 }
5679 material
5680 }
5681}
5682
5683impl DeliveryComponentKindV2 {
5684 pub fn canonical_tag(self) -> u8 {
5685 match self {
5686 Self::Skill => 1,
5687 Self::PluginManifest => 2,
5688 Self::Prompt => 3,
5689 Self::Instructions => 4,
5690 Self::AgentDefinition => 5,
5691 Self::Command => 6,
5692 Self::File => 7,
5693 Self::Template => 8,
5694 Self::McpDeclaration => 9,
5695 }
5696 }
5697}
5698
5699impl DeliveryScopeV2 {
5700 pub fn canonical_tag(self) -> u8 {
5701 match self {
5702 Self::Workspace => 1,
5703 Self::Session => 2,
5704 }
5705 }
5706}
5707
5708impl<'de> Deserialize<'de> for DeliveryBundleManifestV2 {
5709 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5710 where
5711 D: Deserializer<'de>,
5712 {
5713 #[derive(Deserialize)]
5714 #[serde(deny_unknown_fields)]
5715 struct Wire {
5716 bundle_id: SpawnBundleId,
5717 revision: SpawnBundleRevision,
5718 bundle_digest: SpawnBundleDigest,
5719 manifest_digest: DeliveryManifestDigestV2,
5720 components: Vec<DeliveryComponentV2>,
5721 }
5722
5723 let wire = Wire::deserialize(deserializer)?;
5724 let manifest = Self {
5725 bundle_id: wire.bundle_id,
5726 revision: wire.revision,
5727 bundle_digest: wire.bundle_digest,
5728 manifest_digest: wire.manifest_digest,
5729 components: wire.components,
5730 };
5731 manifest.validate().map_err(serde::de::Error::custom)?;
5732 Ok(manifest)
5733 }
5734}
5735
5736#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
5737#[serde(transparent)]
5738pub struct DeliveryBlobChunkHexV1(String);
5739
5740impl DeliveryBlobChunkHexV1 {
5741 pub fn new(value: impl Into<String>) -> Result<Self, DeliveryContractError> {
5742 let value = value.into();
5743 if value.is_empty()
5744 || value.len() > MAX_DELIVERY_CHUNK_RAW_BYTES * 2
5745 || value.len() % 2 != 0
5746 || !value.bytes().all(|byte| {
5747 byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')
5748 })
5749 {
5750 return Err(DeliveryContractError::InvalidChunkHex);
5751 }
5752 Ok(Self(value))
5753 }
5754
5755 pub fn as_str(&self) -> &str {
5756 &self.0
5757 }
5758
5759 pub fn raw_len(&self) -> usize {
5760 self.0.len() / 2
5761 }
5762
5763 pub fn decode(&self) -> Vec<u8> {
5764 fn nibble(value: u8) -> u8 {
5765 match value {
5766 b'0'..=b'9' => value - b'0',
5767 b'a'..=b'f' => value - b'a' + 10,
5768 _ => unreachable!("validated delivery chunk contains only lowercase hex"),
5769 }
5770 }
5771
5772 self.0
5773 .as_bytes()
5774 .chunks_exact(2)
5775 .map(|pair| (nibble(pair[0]) << 4) | nibble(pair[1]))
5776 .collect()
5777 }
5778}
5779
5780impl<'de> Deserialize<'de> for DeliveryBlobChunkHexV1 {
5781 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
5782 where
5783 D: Deserializer<'de>,
5784 {
5785 Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
5786 }
5787}
5788
5789#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
5790pub enum DeliveryContractError {
5791 #[error("delivery manifest must contain at least one component")]
5792 EmptyManifest,
5793 #[error("delivery manifest exceeds the file count limit")]
5794 TooManyFiles,
5795 #[error("delivery file exceeds the byte limit")]
5796 FileTooLarge,
5797 #[error("delivery manifest exceeds the total byte limit")]
5798 TotalTooLarge,
5799 #[error("delivery components must be strictly ordered by relative path")]
5800 ComponentsNotOrdered,
5801 #[error("delivery component paths collide under case folding")]
5802 CaseFoldPathCollision,
5803 #[error("delivery blob receipts must be strictly ordered and unique by digest")]
5804 BlobReceiptsNotOrdered,
5805 #[error("delivery chunk must encode 1 through 49152 raw bytes as lowercase hexadecimal")]
5806 InvalidChunkHex,
5807 #[error("delivery chunk exceeds the declared file bounds")]
5808 ChunkOutOfBounds,
5809}
5810
5811fn validate_delivery_blob_receipts(
5812 blobs: &[DeliveryBlobReceiptV1],
5813) -> Result<(), DeliveryContractError> {
5814 if blobs.len() > MAX_DELIVERY_FILES {
5815 return Err(DeliveryContractError::TooManyFiles);
5816 }
5817 let mut previous: Option<&DeliveryBlobDigestV1> = None;
5818 for blob in blobs {
5819 if previous.is_some_and(|digest| digest >= &blob.digest) {
5820 return Err(DeliveryContractError::BlobReceiptsNotOrdered);
5821 }
5822 previous = Some(&blob.digest);
5823 }
5824 Ok(())
5825}
5826
5827fn deserialize_delivery_blob_receipts<'de, D>(
5828 deserializer: D,
5829) -> Result<Vec<DeliveryBlobReceiptV1>, D::Error>
5830where
5831 D: Deserializer<'de>,
5832{
5833 let blobs = Vec::<DeliveryBlobReceiptV1>::deserialize(deserializer)?;
5834 validate_delivery_blob_receipts(&blobs).map_err(serde::de::Error::custom)?;
5835 Ok(blobs)
5836}
5837
5838fn deserialize_delivery_blob_digests<'de, D>(
5839 deserializer: D,
5840) -> Result<Vec<DeliveryBlobDigestV1>, D::Error>
5841where
5842 D: Deserializer<'de>,
5843{
5844 let digests = Vec::<DeliveryBlobDigestV1>::deserialize(deserializer)?;
5845 if digests.len() > MAX_DELIVERY_FILES
5846 || digests.windows(2).any(|pair| pair[0] >= pair[1])
5847 {
5848 return Err(serde::de::Error::custom(
5849 "delivery blob digests must be bounded, strictly ordered, and unique",
5850 ));
5851 }
5852 Ok(digests)
5853}
5854
5855#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5856#[serde(deny_unknown_fields)]
5857pub struct DeliveryCommitReceiptV1 {
5858 pub bundle_id: SpawnBundleId,
5859 pub revision: SpawnBundleRevision,
5860 pub bundle_digest: SpawnBundleDigest,
5861 pub manifest_digest: DeliveryManifestDigestV2,
5862 #[serde(deserialize_with = "deserialize_delivery_blob_receipts")]
5863 pub blobs: Vec<DeliveryBlobReceiptV1>,
5864}
5865
5866#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
5867pub struct NodeCursor {
5868 pub incarnation_id: NodeIncarnationId,
5869 pub sequence: u64,
5870}
5871
5872#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5873pub struct NodeHello {
5874 pub build_stamp: String,
5875 pub incarnation_id: NodeIncarnationId,
5876 pub connection_id: u64,
5877 pub role: ClientRole,
5878 pub event_sequence: u64,
5879 pub controller: Option<ControllerState>,
5880 pub snapshot: NodeSnapshot,
5881 #[serde(default, skip_serializing_if = "Option::is_none")]
5882 pub compatibility: Option<NegotiatedNodeCompatibility>,
5883}
5884
5885#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5886pub struct RequestEnvelope {
5887 pub request_id: u64,
5888 pub request: NodeRequest,
5889}
5890
5891#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
5892#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
5893pub enum NodeRequest {
5894 Snapshot,
5895 Resync { after_sequence: u64 },
5896 ArmHarnessMcpReservation {
5897 reservation_id: HarnessMcpReservationId,
5898 activation_digest: HarnessMcpActivationDigest,
5899 spawn_spec: SpawnSpec,
5900 launch: HarnessMcpLaunchV1,
5901 expires_at_unix_ms: u64,
5902 },
5903 SpawnSpecWithHarnessMcp {
5904 reservation_id: HarnessMcpReservationId,
5905 activation_digest: HarnessMcpActivationDigest,
5906 spec: SpawnSpec,
5907 deadline_unix_ms: u64,
5908 },
5909 ActivateHarnessMcpReservation {
5910 reservation_id: HarnessMcpReservationId,
5911 activation_digest: HarnessMcpActivationDigest,
5912 record_id: SessionRecordId,
5913 session: SessionAddress,
5914 },
5915 AbortHarnessMcpReservation {
5916 reservation_id: HarnessMcpReservationId,
5917 activation_digest: HarnessMcpActivationDigest,
5918 },
5919 PutHarnessMcpReplyChunk {
5920 reservation_id: HarnessMcpReservationId,
5921 activation_digest: HarnessMcpActivationDigest,
5922 record_id: SessionRecordId,
5923 session: SessionAddress,
5924 call_id: HarnessMcpCallId,
5925 offset: u32,
5926 final_chunk: bool,
5927 chunk_hex: HarnessMcpReplyChunkHexV1,
5928 },
5929 RejectHarnessMcpCall {
5930 reservation_id: HarnessMcpReservationId,
5931 activation_digest: HarnessMcpActivationDigest,
5932 record_id: SessionRecordId,
5933 session: SessionAddress,
5934 call_id: HarnessMcpCallId,
5935 reason: HarnessMcpRejectReasonV1,
5936 },
5937 BeginDeliveryStage {
5938 manifest: DeliveryBundleManifestV2,
5939 },
5940 PutDeliveryBlobChunk {
5941 stage_id: DeliveryStageId,
5942 blob_digest: DeliveryBlobDigestV1,
5943 offset: u64,
5944 chunk_hex: DeliveryBlobChunkHexV1,
5945 },
5946 CommitDeliveryStage {
5947 stage_id: DeliveryStageId,
5948 },
5949 AbortDeliveryStage {
5950 stage_id: DeliveryStageId,
5951 },
5952 BrowseHostDirectories {
5953 directory: Option<OpaqueHostPath>,
5954 after: Option<OpaqueHostPath>,
5955 },
5956 InspectWorkspace { workspace_id: WorkspaceId },
5957 ReadWorkspaceFile {
5958 workspace_id: WorkspaceId,
5959 path: RepositoryPath,
5960 },
5961 WriteWorkspaceFile {
5962 workspace_id: WorkspaceId,
5963 path: RepositoryPath,
5964 expected_revision: WorkspaceFileRevision,
5965 #[serde(deserialize_with = "deserialize_workspace_file_text")]
5966 text: String,
5967 },
5968 CreateWorkspaceFile {
5969 workspace_id: WorkspaceId,
5970 path: RepositoryPath,
5971 },
5972 CreateWorkspaceDirectory {
5973 workspace_id: WorkspaceId,
5974 path: RepositoryPath,
5975 },
5976 ReadGitHistory {
5977 workspace_id: WorkspaceId,
5978 #[serde(default, skip_serializing_if = "Option::is_none")]
5979 path: Option<RepositoryPath>,
5980 before: Option<GitObjectId>,
5981 #[serde(deserialize_with = "deserialize_git_history_limit")]
5982 limit: u16,
5983 },
5984 ReadGitDiff {
5985 workspace_id: WorkspaceId,
5986 request: GitDiffRequest,
5987 },
5988 AcquireController { lease_ms: u64 },
5989 ReleaseController,
5990 RegisterWorkspace {
5991 workspace_id: WorkspaceId,
5992 root: OpaqueHostPath,
5993 },
5994 CreateStandaloneWorkspace {
5995 workspace_id: WorkspaceId,
5996 root: OpaqueHostPath,
5997 #[serde(default, deserialize_with = "deserialize_optional_initial_branch")]
5998 initial_branch: Option<String>,
5999 },
6000 UnregisterWorkspace {
6001 workspace_id: WorkspaceId,
6002 },
6003 CreateWorktree {
6004 source_workspace_id: WorkspaceId,
6005 workspace_id: WorkspaceId,
6006 target_root: OpaqueHostPath,
6007 branch: String,
6008 base: Option<String>,
6009 },
6010 RemoveWorktree {
6011 source_workspace_id: WorkspaceId,
6012 target_root: OpaqueHostPath,
6013 },
6014 Spawn {
6015 workspace_id: WorkspaceId,
6016 provider: AgentId,
6017 mode: SessionMode,
6018 terminal_size: TerminalSize,
6019 initial_prompt: Option<String>,
6020 },
6021 SpawnSpec {
6022 spec: SpawnSpec,
6023 },
6024 SpawnManagedWorktree {
6025 request: ManagedWorktreeSpawnRequest,
6026 },
6027 SpawnManagedWorktreeV2 {
6028 request: ManagedWorktreeSpawnRequestV2,
6029 },
6030 CleanupManagedWorktree {
6031 lease_id: ManagedWorktreeLeaseId,
6032 },
6033 Resume {
6034 session: SessionAddress,
6035 terminal_size: TerminalSize,
6036 initial_prompt: Option<String>,
6037 },
6038 RenameSessionRecord {
6039 record_id: SessionRecordId,
6040 display_name: String,
6041 },
6042 SetSessionTask {
6043 record_id: SessionRecordId,
6044 expected_revision: u64,
6045 target: SessionTaskTargetV1,
6046 },
6047 IndexProviderSession {
6048 workspace_id: WorkspaceId,
6049 provider: AgentId,
6050 identity: ProviderSessionIdentity,
6051 display_name: String,
6052 },
6053 IndexNativeSession {
6054 selection: NativeSessionSelection,
6055 display_name: String,
6056 },
6057 ResumeSessionRecord {
6058 record_id: SessionRecordId,
6059 terminal_size: TerminalSize,
6060 initial_prompt: Option<String>,
6061 },
6062 ForgetSessionRecord {
6063 record_id: SessionRecordId,
6064 },
6065 CatalogNativeSessions {
6066 route: NativeSessionCatalogRoute,
6067 #[serde(deserialize_with = "deserialize_native_session_catalog_limit")]
6068 limit: u16,
6069 },
6070 PageNativeSessions {
6071 route: NativeSessionCatalogRoute,
6072 window: NativeSessionCatalogWindow,
6073 catalog_revision: u64,
6074 recent_cutoff_unix_ms: u64,
6075 #[serde(default, deserialize_with = "deserialize_optional_history_candidate_id")]
6076 after_selection_id: Option<String>,
6077 #[serde(deserialize_with = "deserialize_native_session_catalog_limit")]
6078 limit: u16,
6079 },
6080 PreviewNativeSession {
6081 selection: NativeSessionSelection,
6082 #[serde(deserialize_with = "deserialize_native_session_preview_limit")]
6083 message_limit: u16,
6084 },
6085 PreviewSessionRecord {
6086 record_id: SessionRecordId,
6087 #[serde(deserialize_with = "deserialize_native_session_preview_limit")]
6088 message_limit: u16,
6089 },
6090 DiscoverHistory {
6091 session: SessionAddress,
6092 #[serde(deserialize_with = "deserialize_history_discovery_limit")]
6093 limit: u16,
6094 },
6095 LoadHistory {
6096 session: SessionAddress,
6097 #[serde(deserialize_with = "deserialize_history_candidate_id")]
6098 candidate_id: String,
6099 },
6100 ExportContextPackForSessionRecord {
6101 record_id: SessionRecordId,
6102 session: SessionAddress,
6103 },
6104 ExportContextPack {
6105 session: SessionAddress,
6106 },
6107 ForgetContextPack {
6108 context_id: SpawnContextId,
6109 },
6110 ResolveDurableContextPack {
6111 context_id: SpawnContextId,
6112 },
6113 ReadContextPack {
6114 digest: SpawnContextDigest,
6115 },
6116 Prompt { session: SessionAddress, text: String },
6117 Paste { session: SessionAddress, text: String },
6118 Input { session: SessionAddress, text: String },
6119 TerminalBytes { session: SessionAddress, bytes: Vec<u8> },
6120 TerminalControl { session: SessionAddress, control: TerminalControl },
6121 Resize { session: SessionAddress, size: TerminalSize },
6122 Interrupt { session: SessionAddress },
6123 Stop { session: SessionAddress, force: bool },
6124 Remove { session: SessionAddress },
6125 ResolveInteraction {
6131 session: SessionAddress,
6132 #[serde(deserialize_with = "deserialize_acp_correlation_id")]
6133 correlation_id: String,
6134 response: ProviderInteractionResponse,
6135 },
6136 SetSessionMode {
6137 session: SessionAddress,
6138 #[serde(deserialize_with = "deserialize_acp_control_id")]
6139 mode_id: String,
6140 },
6141 SetSessionConfigOption {
6142 session: SessionAddress,
6143 #[serde(deserialize_with = "deserialize_acp_control_id")]
6144 option_id: String,
6145 #[serde(deserialize_with = "deserialize_acp_config_value_json")]
6146 value_json: String,
6147 },
6148 SetSessionModel {
6149 session: SessionAddress,
6150 #[serde(deserialize_with = "deserialize_acp_control_id")]
6151 model_id: String,
6152 },
6153 Shutdown,
6154}
6155
6156fn deserialize_workspace_file_text<'de, D>(deserializer: D) -> Result<String, D::Error>
6157where
6158 D: Deserializer<'de>,
6159{
6160 let text = String::deserialize(deserializer)?;
6161 if text.len() > MAX_WORKSPACE_FILE_BYTES {
6162 return Err(serde::de::Error::custom("workspace file text exceeds the byte limit"));
6163 }
6164 Ok(text)
6165}
6166
6167fn deserialize_git_history_limit<'de, D>(deserializer: D) -> Result<u16, D::Error>
6168where
6169 D: Deserializer<'de>,
6170{
6171 let limit = u16::deserialize(deserializer)?;
6172 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
6173 return Err(serde::de::Error::custom("git history limit is invalid"));
6174 }
6175 Ok(limit)
6176}
6177
6178fn deserialize_optional_initial_branch<'de, D>(
6179 deserializer: D,
6180) -> Result<Option<String>, D::Error>
6181where
6182 D: Deserializer<'de>,
6183{
6184 let branch = Option::<String>::deserialize(deserializer)?;
6185 if let Some(branch) = branch.as_deref() {
6186 if branch.is_empty()
6187 || branch.len() > MAX_REPOSITORY_PATH_BYTES
6188 || branch.starts_with('-')
6189 || branch.chars().any(char::is_control)
6190 {
6191 return Err(serde::de::Error::custom(
6192 "initial branch must be bounded, non-empty, option-safe, and free of control characters",
6193 ));
6194 }
6195 }
6196 Ok(branch)
6197}
6198
6199fn contains_unsafe_control_bytes(value: &str) -> bool {
6206 value
6207 .chars()
6208 .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
6209}
6210
6211fn deserialize_acp_correlation_id<'de, D>(deserializer: D) -> Result<String, D::Error>
6212where
6213 D: Deserializer<'de>,
6214{
6215 let value = String::deserialize(deserializer)?;
6216 if value.is_empty()
6217 || value.len() > MAX_ACP_CORRELATION_ID_BYTES
6218 || value.chars().any(char::is_control)
6219 {
6220 return Err(serde::de::Error::custom(
6221 "interaction correlation id must be non-empty, bounded, and free of control characters",
6222 ));
6223 }
6224 Ok(value)
6225}
6226
6227fn deserialize_acp_control_id<'de, D>(deserializer: D) -> Result<String, D::Error>
6228where
6229 D: Deserializer<'de>,
6230{
6231 let value = String::deserialize(deserializer)?;
6232 if value.is_empty()
6233 || value.len() > MAX_ACP_CONTROL_ID_BYTES
6234 || value.chars().any(char::is_control)
6235 {
6236 return Err(serde::de::Error::custom(
6237 "acp control id must be non-empty, bounded, and free of control characters",
6238 ));
6239 }
6240 Ok(value)
6241}
6242
6243fn deserialize_acp_config_value_json<'de, D>(deserializer: D) -> Result<String, D::Error>
6244where
6245 D: Deserializer<'de>,
6246{
6247 let value = String::deserialize(deserializer)?;
6248 if value.is_empty()
6249 || value.len() > MAX_ACP_CONTROL_TEXT_BYTES
6250 || contains_unsafe_control_bytes(&value)
6251 {
6252 return Err(serde::de::Error::custom(
6253 "acp config option value must be non-empty, bounded, and free of unsafe control characters",
6254 ));
6255 }
6256 if let Err(error) = serde_json::from_str::<serde_json::Value>(&value) {
6263 return Err(serde::de::Error::custom(format!(
6264 "acp config option value must be valid JSON: {error}"
6265 )));
6266 }
6267 Ok(value)
6268}
6269
6270impl NodeRequest {
6271 pub fn harness_mcp_contract_is_valid_at(&self, now_unix_ms: u64) -> bool {
6272 match self {
6273 Self::ArmHarnessMcpReservation { expires_at_unix_ms, launch, .. } => {
6274 launch.validate().is_ok()
6275 && *expires_at_unix_ms > now_unix_ms
6276 && expires_at_unix_ms.saturating_sub(now_unix_ms)
6277 <= MAX_HARNESS_MCP_RESERVATION_TTL_MS
6278 }
6279 Self::SpawnSpecWithHarnessMcp { deadline_unix_ms, .. } => {
6280 *deadline_unix_ms > now_unix_ms
6281 && deadline_unix_ms.saturating_sub(now_unix_ms)
6282 <= MAX_HARNESS_MCP_SPAWN_RELAY_DEADLINE_MS
6283 }
6284 Self::PutHarnessMcpReplyChunk { offset, chunk_hex, .. } => {
6285 usize::try_from(*offset).ok()
6286 .and_then(|offset| offset.checked_add(chunk_hex.raw_len()))
6287 .is_some_and(|end| end <= MAX_HARNESS_MCP_AGGREGATE_REPLY_BYTES)
6288 }
6289 _ => true,
6290 }
6291 }
6292
6293 pub fn native_session_catalog_contract_is_valid(&self) -> bool {
6294 match self {
6295 Self::CatalogNativeSessions { route, limit } => {
6296 route.validate().is_ok()
6297 && (1..=NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(limit)
6298 }
6299 Self::PageNativeSessions { route, after_selection_id, limit, .. } => {
6300 route.validate().is_ok()
6301 && (1..=NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(limit)
6302 && after_selection_id
6303 .as_deref()
6304 .map_or(true, |cursor| {
6305 gate4agent_types::validate_candidate_id(cursor).is_ok()
6306 })
6307 }
6308 _ => true,
6309 }
6310 }
6311
6312 pub fn native_session_preview_contract_is_valid(&self) -> bool {
6313 match self {
6314 Self::PreviewNativeSession { selection, message_limit } => {
6315 (1..=NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX).contains(message_limit)
6316 && selection.validate().is_ok()
6317 }
6318 Self::PreviewSessionRecord { message_limit, .. } => {
6319 (1..=NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX).contains(message_limit)
6320 }
6321 _ => true,
6322 }
6323 }
6324
6325 pub fn history_context_pack_contract_is_valid(&self) -> bool {
6326 match self {
6327 Self::DiscoverHistory { limit, .. } => {
6328 (1..=HISTORY_DISCOVERY_LIMIT_MAX).contains(limit)
6329 }
6330 Self::LoadHistory { candidate_id, .. } => {
6331 gate4agent_types::validate_candidate_id(candidate_id).is_ok()
6332 }
6333 Self::Snapshot
6334 | Self::Resync { .. }
6335 | Self::ArmHarnessMcpReservation { .. }
6336 | Self::SpawnSpecWithHarnessMcp { .. }
6337 | Self::ActivateHarnessMcpReservation { .. }
6338 | Self::AbortHarnessMcpReservation { .. }
6339 | Self::PutHarnessMcpReplyChunk { .. }
6340 | Self::RejectHarnessMcpCall { .. }
6341 | Self::BeginDeliveryStage { .. }
6342 | Self::PutDeliveryBlobChunk { .. }
6343 | Self::CommitDeliveryStage { .. }
6344 | Self::AbortDeliveryStage { .. }
6345 | Self::BrowseHostDirectories { .. }
6346 | Self::InspectWorkspace { .. }
6347 | Self::ReadWorkspaceFile { .. }
6348 | Self::WriteWorkspaceFile { .. }
6349 | Self::CreateWorkspaceFile { .. }
6350 | Self::CreateWorkspaceDirectory { .. }
6351 | Self::ReadGitHistory { .. }
6352 | Self::ReadGitDiff { .. }
6353 | Self::AcquireController { .. }
6354 | Self::ReleaseController
6355 | Self::RegisterWorkspace { .. }
6356 | Self::CreateStandaloneWorkspace { .. }
6357 | Self::UnregisterWorkspace { .. }
6358 | Self::CreateWorktree { .. }
6359 | Self::RemoveWorktree { .. }
6360 | Self::Spawn { .. }
6361 | Self::SpawnSpec { .. }
6362 | Self::SpawnManagedWorktree { .. }
6363 | Self::SpawnManagedWorktreeV2 { .. }
6364 | Self::CleanupManagedWorktree { .. }
6365 | Self::Resume { .. }
6366 | Self::RenameSessionRecord { .. }
6367 | Self::SetSessionTask { .. }
6368 | Self::IndexProviderSession { .. }
6369 | Self::IndexNativeSession { .. }
6370 | Self::ResumeSessionRecord { .. }
6371 | Self::ForgetSessionRecord { .. }
6372 | Self::CatalogNativeSessions { .. }
6373 | Self::PageNativeSessions { .. }
6374 | Self::PreviewNativeSession { .. }
6375 | Self::PreviewSessionRecord { .. }
6376 | Self::ExportContextPackForSessionRecord { .. }
6377 | Self::ExportContextPack { .. }
6378 | Self::ForgetContextPack { .. }
6379 | Self::ResolveDurableContextPack { .. }
6380 | Self::ReadContextPack { .. }
6381 | Self::Prompt { .. }
6382 | Self::Paste { .. }
6383 | Self::Input { .. }
6384 | Self::TerminalBytes { .. }
6385 | Self::TerminalControl { .. }
6386 | Self::Resize { .. }
6387 | Self::Interrupt { .. }
6388 | Self::Stop { .. }
6389 | Self::Remove { .. }
6390 | Self::ResolveInteraction { .. }
6391 | Self::SetSessionMode { .. }
6392 | Self::SetSessionConfigOption { .. }
6393 | Self::SetSessionModel { .. }
6394 | Self::Shutdown => true,
6395 }
6396 }
6397
6398 pub fn required_capability(&self) -> Option<&'static str> {
6399 match self {
6400 Self::ArmHarnessMcpReservation { .. }
6401 | Self::SpawnSpecWithHarnessMcp { .. }
6402 | Self::ActivateHarnessMcpReservation { .. }
6403 | Self::AbortHarnessMcpReservation { .. }
6404 | Self::PutHarnessMcpReplyChunk { .. }
6405 | Self::RejectHarnessMcpCall { .. } => {
6406 Some(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
6407 }
6408 Self::BeginDeliveryStage { .. }
6409 | Self::PutDeliveryBlobChunk { .. }
6410 | Self::CommitDeliveryStage { .. }
6411 | Self::AbortDeliveryStage { .. } => {
6412 Some(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
6413 }
6414 Self::BrowseHostDirectories { .. } => Some(CAPABILITY_HOST_DIRECTORY_BROWSE_V1),
6415 Self::CreateStandaloneWorkspace { .. } => {
6416 Some(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
6417 }
6418 Self::ReadWorkspaceFile { .. } => Some(NODE_WORKSPACE_FILE_READ_CAPABILITY),
6419 Self::WriteWorkspaceFile { .. } => Some(NODE_WORKSPACE_FILE_WRITE_CAPABILITY),
6420 Self::CreateWorkspaceFile { .. } | Self::CreateWorkspaceDirectory { .. } => {
6421 Some(NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY)
6422 }
6423 Self::ReadGitHistory { .. } | Self::ReadGitDiff { .. } => {
6424 Some(NODE_GIT_READ_CAPABILITY)
6425 }
6426 Self::IndexProviderSession { .. } => {
6427 Some(NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY)
6428 }
6429 Self::SetSessionTask { .. } => Some(NODE_SESSION_TASK_CORRELATION_CAPABILITY),
6430 Self::IndexNativeSession { .. } => Some(NODE_NATIVE_SESSION_INDEX_CAPABILITY),
6431 Self::CatalogNativeSessions { .. } => Some(NODE_NATIVE_SESSION_CATALOG_CAPABILITY),
6432 Self::PageNativeSessions { .. } => {
6433 Some(NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY)
6434 }
6435 Self::PreviewNativeSession { .. } => Some(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY),
6436 Self::PreviewSessionRecord { .. } => Some(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY),
6437 Self::SpawnSpec { .. } => Some(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY),
6438 Self::SpawnManagedWorktree { .. }
6439 | Self::CleanupManagedWorktree { .. } => {
6440 Some(NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY)
6441 }
6442 Self::SpawnManagedWorktreeV2 { .. } => {
6443 Some(NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY)
6444 }
6445 Self::ExportContextPackForSessionRecord { .. } => {
6446 Some(NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY)
6447 }
6448 Self::DiscoverHistory { .. }
6449 | Self::LoadHistory { .. }
6450 | Self::ExportContextPack { .. }
6451 | Self::ForgetContextPack { .. }
6452 | Self::ResolveDurableContextPack { .. }
6453 | Self::ReadContextPack { .. } => {
6454 Some(NODE_HISTORY_CONTEXT_PACK_CAPABILITY)
6455 }
6456 Self::ResolveInteraction { .. }
6457 | Self::SetSessionMode { .. }
6458 | Self::SetSessionConfigOption { .. }
6459 | Self::SetSessionModel { .. } => Some(NODE_ACP_CONTROL_CAPABILITY),
6460 Self::Snapshot
6461 | Self::Resync { .. }
6462 | Self::InspectWorkspace { .. }
6463 | Self::AcquireController { .. }
6464 | Self::ReleaseController
6465 | Self::RegisterWorkspace { .. }
6466 | Self::UnregisterWorkspace { .. }
6467 | Self::CreateWorktree { .. }
6468 | Self::RemoveWorktree { .. }
6469 | Self::Spawn { .. }
6470 | Self::Resume { .. }
6471 | Self::RenameSessionRecord { .. }
6472 | Self::ResumeSessionRecord { .. }
6473 | Self::ForgetSessionRecord { .. }
6474 | Self::Prompt { .. }
6475 | Self::Paste { .. }
6476 | Self::Input { .. }
6477 | Self::TerminalBytes { .. }
6478 | Self::TerminalControl { .. }
6479 | Self::Resize { .. }
6480 | Self::Interrupt { .. }
6481 | Self::Stop { .. }
6482 | Self::Remove { .. }
6483 | Self::Shutdown => None,
6484 }
6485 }
6486
6487 pub fn requires_worktree_selection_capability(&self) -> bool {
6488 matches!(self, Self::SpawnSpec { spec } if spec.target.worktree_id.is_some())
6489 || matches!(self,
6490 Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6491 | Self::SpawnSpecWithHarnessMcp { spec, .. }
6492 if spec.target.worktree_id.is_some())
6493 || matches!(
6494 self,
6495 Self::SpawnManagedWorktree { .. } | Self::CleanupManagedWorktree { .. }
6496 | Self::SpawnManagedWorktreeV2 { .. }
6497 )
6498 }
6499
6500 pub fn requires_spawn_spec_defaults_overrides_capability(&self) -> bool {
6501 matches!(
6502 self,
6503 Self::SpawnSpec { .. }
6504 | Self::SpawnManagedWorktree { .. }
6505 | Self::SpawnManagedWorktreeV2 { .. }
6506 | Self::ArmHarnessMcpReservation { .. }
6507 | Self::SpawnSpecWithHarnessMcp { .. }
6508 )
6509 }
6510
6511 pub fn requires_spawn_profile_revision_capability(&self) -> bool {
6512 matches!(
6513 self,
6514 Self::SpawnSpec { .. }
6515 | Self::SpawnManagedWorktree { .. }
6516 | Self::SpawnManagedWorktreeV2 { .. }
6517 | Self::ArmHarnessMcpReservation { .. }
6518 | Self::SpawnSpecWithHarnessMcp { .. }
6519 )
6520 }
6521
6522 pub fn requires_child_environment_profile_capability(&self) -> bool {
6523 let spec = match self {
6524 Self::SpawnSpec { spec } => spec,
6525 Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6526 | Self::SpawnSpecWithHarnessMcp { spec, .. } => spec,
6527 Self::SpawnManagedWorktree { request } => &request.spawn_spec,
6528 Self::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
6529 _ => return false,
6530 };
6531 matches!(
6532 spec.overrides.environment_profile_id,
6533 SpawnOverride::Set { .. }
6534 )
6535 }
6536
6537
6538 pub fn requires_session_bundle_materialization_capability(&self) -> bool {
6539 let spec = match self {
6540 Self::SpawnSpec { spec } => spec,
6541 Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6542 | Self::SpawnSpecWithHarnessMcp { spec, .. } => spec,
6543 Self::SpawnManagedWorktree { request } => &request.spawn_spec,
6544 Self::SpawnManagedWorktreeV2 { request } => &request.spawn_spec,
6545 _ => return false,
6546 };
6547 !matches!(spec.overrides.bundle_id, SpawnOverride::Clear)
6548 }
6549
6550 pub fn requires_history_context_pack_capability(&self) -> bool {
6551 match self {
6552 Self::DiscoverHistory { .. }
6553 | Self::LoadHistory { .. }
6554 | Self::ExportContextPackForSessionRecord { .. }
6555 | Self::ExportContextPack { .. }
6556 | Self::ForgetContextPack { .. } => true,
6557 Self::SpawnSpec { spec } => {
6558 !matches!(spec.overrides.context_id, SpawnOverride::Clear)
6559 }
6560 Self::ArmHarnessMcpReservation { spawn_spec: spec, .. }
6561 | Self::SpawnSpecWithHarnessMcp { spec, .. } => {
6562 !matches!(spec.overrides.context_id, SpawnOverride::Clear)
6563 }
6564 Self::SpawnManagedWorktree { request } => {
6565 !matches!(request.spawn_spec.overrides.context_id, SpawnOverride::Clear)
6566 }
6567 Self::SpawnManagedWorktreeV2 { request } => {
6568 !matches!(request.spawn_spec.overrides.context_id, SpawnOverride::Clear)
6569 }
6570 _ => false,
6571 }
6572 }
6573
6574 pub fn is_replay_safe(&self) -> bool {
6599 match self {
6600 Self::Snapshot
6601 | Self::Resync { .. }
6602 | Self::PutHarnessMcpReplyChunk { .. }
6603 | Self::RejectHarnessMcpCall { .. }
6604 | Self::BrowseHostDirectories { .. }
6605 | Self::InspectWorkspace { .. }
6606 | Self::ReadWorkspaceFile { .. }
6607 | Self::ReadGitHistory { .. }
6608 | Self::ReadGitDiff { .. }
6609 | Self::CatalogNativeSessions { .. }
6610 | Self::PageNativeSessions { .. }
6611 | Self::PreviewNativeSession { .. }
6612 | Self::PreviewSessionRecord { .. }
6613 | Self::DiscoverHistory { .. }
6614 | Self::ResolveDurableContextPack { .. }
6615 | Self::ReadContextPack { .. } => true,
6616 Self::ArmHarnessMcpReservation { .. }
6617 | Self::SpawnSpecWithHarnessMcp { .. }
6618 | Self::ActivateHarnessMcpReservation { .. }
6619 | Self::AbortHarnessMcpReservation { .. }
6620 | Self::BeginDeliveryStage { .. }
6621 | Self::PutDeliveryBlobChunk { .. }
6622 | Self::CommitDeliveryStage { .. }
6623 | Self::AbortDeliveryStage { .. }
6624 | Self::WriteWorkspaceFile { .. }
6625 | Self::CreateWorkspaceFile { .. }
6626 | Self::CreateWorkspaceDirectory { .. }
6627 | Self::AcquireController { .. }
6628 | Self::ReleaseController
6629 | Self::RegisterWorkspace { .. }
6630 | Self::CreateStandaloneWorkspace { .. }
6631 | Self::UnregisterWorkspace { .. }
6632 | Self::CreateWorktree { .. }
6633 | Self::RemoveWorktree { .. }
6634 | Self::Spawn { .. }
6635 | Self::SpawnSpec { .. }
6636 | Self::SpawnManagedWorktree { .. }
6637 | Self::SpawnManagedWorktreeV2 { .. }
6638 | Self::CleanupManagedWorktree { .. }
6639 | Self::Resume { .. }
6640 | Self::RenameSessionRecord { .. }
6641 | Self::SetSessionTask { .. }
6642 | Self::IndexProviderSession { .. }
6643 | Self::IndexNativeSession { .. }
6644 | Self::ResumeSessionRecord { .. }
6645 | Self::ForgetSessionRecord { .. }
6646 | Self::LoadHistory { .. }
6647 | Self::ExportContextPackForSessionRecord { .. }
6648 | Self::ExportContextPack { .. }
6649 | Self::ForgetContextPack { .. }
6650 | Self::Prompt { .. }
6651 | Self::Paste { .. }
6652 | Self::Input { .. }
6653 | Self::TerminalBytes { .. }
6654 | Self::TerminalControl { .. }
6655 | Self::Resize { .. }
6656 | Self::Interrupt { .. }
6657 | Self::Stop { .. }
6658 | Self::Remove { .. }
6659 | Self::ResolveInteraction { .. }
6660 | Self::SetSessionMode { .. }
6661 | Self::SetSessionConfigOption { .. }
6662 | Self::SetSessionModel { .. }
6663 | Self::Shutdown => false,
6664 }
6665 }
6666}
6667
6668#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6669pub struct ResponseEnvelope {
6670 pub request_id: u64,
6671 pub result: Result<NodeResponse, NodeFailure>,
6672}
6673
6674#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
6675#[serde(tag = "kind", rename_all = "kebab-case")]
6676pub enum NodeResponse {
6677 Snapshot {
6678 event_sequence: u64,
6679 controller: Option<ControllerState>,
6680 snapshot: NodeSnapshot,
6681 },
6682 Resync {
6683 event_sequence: u64,
6684 oldest_available_sequence: u64,
6685 snapshot: NodeSnapshot,
6686 events: Vec<NodeEventEnvelope>,
6687 },
6688 Armed {
6689 reservation_id: HarnessMcpReservationId,
6690 activation_digest: HarnessMcpActivationDigest,
6691 expires_at_unix_ms: u64,
6692 },
6693 Spawned {
6694 reservation_id: HarnessMcpReservationId,
6695 activation_digest: HarnessMcpActivationDigest,
6696 receipt: ResolvedSpawnReceipt,
6697 },
6698 Activated {
6699 reservation_id: HarnessMcpReservationId,
6700 activation_digest: HarnessMcpActivationDigest,
6701 record_id: SessionRecordId,
6702 session: SessionAddress,
6703 },
6704 Aborted {
6705 reservation_id: HarnessMcpReservationId,
6706 activation_digest: HarnessMcpActivationDigest,
6707 },
6708 ReplyChunkAccepted {
6709 reservation_id: HarnessMcpReservationId,
6710 activation_digest: HarnessMcpActivationDigest,
6711 record_id: SessionRecordId,
6712 session: SessionAddress,
6713 call_id: HarnessMcpCallId,
6714 next_offset: u32,
6715 completed: bool,
6716 },
6717 CallRejected {
6718 reservation_id: HarnessMcpReservationId,
6719 activation_digest: HarnessMcpActivationDigest,
6720 record_id: SessionRecordId,
6721 session: SessionAddress,
6722 call_id: HarnessMcpCallId,
6723 },
6724 DeliveryStageBegun {
6725 stage_id: DeliveryStageId,
6726 manifest_digest: DeliveryManifestDigestV2,
6727 #[serde(deserialize_with = "deserialize_delivery_blob_digests")]
6728 missing_blobs: Vec<DeliveryBlobDigestV1>,
6729 },
6730 DeliveryBlobChunkAccepted {
6731 stage_id: DeliveryStageId,
6732 blob_digest: DeliveryBlobDigestV1,
6733 next_offset: u64,
6734 },
6735 DeliveryCommitted {
6736 receipt: DeliveryCommitReceiptV1,
6737 },
6738 DeliveryStageAborted {
6739 stage_id: DeliveryStageId,
6740 },
6741 WorkspaceInspected {
6742 inspection: WorkspaceInspection,
6743 },
6744 HostDirectoriesBrowsed {
6745 listing: HostDirectoryListing,
6746 },
6747 WorkspaceFileRead {
6748 file: WorkspaceFileRead,
6749 },
6750 WorkspaceFileWritten {
6751 file: WorkspaceFileRead,
6752 },
6753 WorkspaceFileCreated {
6754 file: WorkspaceFileRead,
6755 },
6756 WorkspaceDirectoryCreated {
6757 workspace_id: WorkspaceId,
6758 entry: WorkspaceEntry,
6759 },
6760 GitHistoryRead {
6761 workspace_id: WorkspaceId,
6762 page: GitHistoryPage,
6763 },
6764 GitDiffRead {
6765 workspace_id: WorkspaceId,
6766 diff: GitDiff,
6767 },
6768 Controller {
6769 controller: Option<ControllerState>,
6770 },
6771 SpawnAccepted {
6772 session: SessionAddress,
6773 },
6774 SpawnSpecAccepted {
6775 receipt: ResolvedSpawnReceipt,
6776 },
6777 ManagedWorktreeSpawnAccepted {
6778 receipt: ManagedWorktreeSpawnReceipt,
6779 },
6780 ManagedWorktreeCleanup {
6781 lease: ManagedWorktreeLeaseSnapshot,
6782 },
6783 SessionRecordUpdated {
6784 record: ManagedSessionRecord,
6785 },
6786 ProviderSessionIndexed {
6787 record: ManagedSessionRecord,
6788 },
6789 NativeSessionIndexed {
6790 selection: NativeSessionSelection,
6791 record: ManagedSessionRecord,
6792 },
6793 SessionRecordResumed {
6794 record: ManagedSessionRecord,
6795 session: SessionAddress,
6796 },
6797 SessionRecordForgotten {
6798 record_id: SessionRecordId,
6799 },
6800 NativeSessionsCataloged {
6801 route: NativeSessionCatalogRoute,
6802 #[serde(deserialize_with = "deserialize_native_session_catalog_entries")]
6803 entries: Vec<NativeSessionCatalogEntry>,
6804 #[serde(
6805 default,
6806 skip_serializing_if = "Option::is_none",
6807 deserialize_with = "deserialize_optional_native_session_catalog_summary"
6808 )]
6809 summary: Option<NativeSessionCatalogSummary>,
6810 },
6811 NativeSessionsPaged {
6812 route: NativeSessionCatalogRoute,
6813 #[serde(deserialize_with = "deserialize_native_session_catalog_page")]
6814 page: NativeSessionCatalogPage,
6815 },
6816 NativeSessionPreviewed {
6817 selection: NativeSessionSelection,
6818 #[serde(deserialize_with = "deserialize_native_session_preview")]
6819 preview: NativeSessionPreview,
6820 },
6821 SessionRecordPreviewed {
6822 record_id: SessionRecordId,
6823 #[serde(deserialize_with = "deserialize_session_record_preview")]
6824 preview: SessionRecordPreview,
6825 },
6826 HistoryDiscovered {
6827 session: SessionAddress,
6828 #[serde(deserialize_with = "deserialize_history_candidates")]
6829 candidates: Vec<HistoryCandidateSummary>,
6830 },
6831 HistoryLoaded {
6832 session: SessionAddress,
6833 #[serde(deserialize_with = "deserialize_history_session_id")]
6834 session_id: String,
6835 message_count: u64,
6836 #[serde(default, skip_serializing_if = "Option::is_none")]
6837 completed_turn_count: Option<u64>,
6838 },
6839 ContextPackExported {
6840 context: ResolvedContextPackReceipt,
6841 },
6842 ContextPackForSessionRecordExported {
6843 record_id: SessionRecordId,
6844 session: SessionAddress,
6845 context: ResolvedContextPackReceipt,
6846 },
6847 ContextPackForgotten {
6848 context_id: SpawnContextId,
6849 },
6850 DurableContextPackResolved {
6851 context: ResolvedContextPackReceipt,
6852 },
6853 ContextPackBytesRead {
6854 pack: ContextPackBytesRead,
6855 },
6856 WorkspaceRegistered {
6857 workspace: WorkspaceSnapshot,
6858 },
6859 StandaloneWorkspaceCreated {
6860 workspace: WorkspaceSnapshot,
6861 },
6862 WorkspaceUnregistered {
6863 workspace_id: WorkspaceId,
6864 },
6865 WorktreeCreated {
6866 worktree: GitWorktreeSnapshot,
6867 workspace: WorkspaceSnapshot,
6868 },
6869 WorktreeRemoved {
6870 target_root: OpaqueHostPath,
6871 workspace_id: Option<WorkspaceId>,
6872 },
6873 Accepted,
6874 ShuttingDown,
6875}
6876
6877impl NodeResponse {
6878 pub fn requires_session_record_context_export_capability(&self) -> bool {
6879 matches!(self, Self::ContextPackForSessionRecordExported { .. })
6880 }
6881
6882 pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
6883 matches!(self,
6884 Self::Armed { .. }
6885 | Self::Spawned { .. }
6886 | Self::Activated { .. }
6887 | Self::Aborted { .. }
6888 | Self::ReplyChunkAccepted { .. }
6889 | Self::CallRejected { .. })
6890 || matches!(self, Self::SpawnSpecAccepted { receipt }
6891 if receipt.harness_mcp_proxy.is_some())
6892 || matches!(self, Self::ManagedWorktreeSpawnAccepted { receipt }
6893 if receipt.spawn.harness_mcp_proxy.is_some())
6894 }
6895
6896 pub fn native_session_catalog_contract_is_valid(&self) -> bool {
6897 match self {
6898 Self::NativeSessionsCataloged { route, entries, summary: Some(summary) } => {
6899 route.validate().is_ok()
6900 && validate_native_session_catalog_entries(route, entries).is_ok()
6901 && summary.validate_initial_entries(entries.len()).is_ok()
6902 }
6903 Self::NativeSessionsCataloged { route, entries, summary: None } => {
6904 route.validate().is_ok()
6905 && validate_native_session_catalog_entries(route, entries).is_ok()
6906 }
6907 Self::NativeSessionsPaged { route, page } => {
6908 page.validate_for_route(route).is_ok()
6909 }
6910 _ => true,
6911 }
6912 }
6913
6914 pub fn requires_native_session_catalog_capability(&self) -> bool {
6915 matches!(self, Self::NativeSessionsCataloged { .. })
6916 }
6917
6918 pub fn requires_native_session_catalog_paging_capability(&self) -> bool {
6919 matches!(self, Self::NativeSessionsPaged { .. })
6920 }
6921
6922 pub fn requires_native_session_preview_capability(&self) -> bool {
6923 matches!(
6924 self,
6925 Self::NativeSessionPreviewed { .. } | Self::SessionRecordPreviewed { .. }
6926 )
6927 }
6928
6929 pub fn requires_native_session_index_capability(&self) -> bool {
6930 matches!(self, Self::NativeSessionIndexed { .. })
6931 }
6932
6933 pub fn native_session_index_contract_is_valid(&self) -> bool {
6934 match self {
6935 Self::NativeSessionIndexed { selection, record } => {
6936 selection.validate().is_ok()
6937 && selection.route.scope == NativeSessionCatalogScope::Workspace
6938 && selection.route.workspace_id.as_ref() == Some(&record.workspace_id)
6939 && selection.route.provider == record.provider
6940 }
6941 _ => false,
6942 }
6943 }
6944
6945 pub fn native_session_preview_contract_is_valid(&self) -> bool {
6946 match self {
6947 Self::NativeSessionPreviewed { selection, preview } => {
6948 selection.validate().is_ok() && preview.validate().is_ok()
6949 }
6950 Self::SessionRecordPreviewed { preview, .. } => preview.validate().is_ok(),
6951 _ => true,
6952 }
6953 }
6954
6955 pub fn requires_worktree_selection_capability(&self) -> bool {
6956 matches!(self,
6957 Self::SpawnSpecAccepted { receipt }
6958 | Self::Spawned { receipt, .. }
6959 if receipt.target.worktree_id.is_some())
6960 || matches!(
6961 self,
6962 Self::ManagedWorktreeSpawnAccepted { .. }
6963 | Self::ManagedWorktreeCleanup { .. }
6964 )
6965 }
6966
6967
6968 pub fn requires_spawn_spec_defaults_overrides_capability(&self) -> bool {
6969 matches!(
6970 self,
6971 Self::SpawnSpecAccepted { .. }
6972 | Self::Spawned { .. }
6973 | Self::ManagedWorktreeSpawnAccepted { .. }
6974 )
6975 }
6976
6977 pub fn requires_spawn_profile_revision_capability(&self) -> bool {
6978 matches!(
6979 self,
6980 Self::SpawnSpecAccepted { .. }
6981 | Self::Spawned { .. }
6982 | Self::ManagedWorktreeSpawnAccepted { .. }
6983 )
6984 }
6985
6986 pub fn requires_child_environment_profile_capability(&self) -> bool {
6987 match self {
6988 Self::Snapshot { snapshot, .. } => {
6989 snapshot.requires_child_environment_profile_capability()
6990 }
6991 Self::Resync {
6992 snapshot, events, ..
6993 } => {
6994 snapshot.requires_child_environment_profile_capability()
6995 || events.iter().any(|event| {
6996 event.event.requires_child_environment_profile_capability()
6997 })
6998 }
6999 Self::SpawnSpecAccepted { receipt }
7000 | Self::Spawned { receipt, .. } => receipt.environment_profile.is_some(),
7001 Self::ManagedWorktreeSpawnAccepted { receipt } => {
7002 receipt.spawn.environment_profile.is_some()
7003 }
7004 Self::SessionRecordUpdated { record }
7005 | Self::ProviderSessionIndexed { record }
7006 | Self::NativeSessionIndexed { record, .. }
7007 | Self::SessionRecordResumed { record, .. } => {
7008 record.environment_profile.is_some()
7009 }
7010 Self::Armed { .. }
7011 | Self::Activated { .. }
7012 | Self::Aborted { .. }
7013 | Self::ReplyChunkAccepted { .. }
7014 | Self::CallRejected { .. }
7015 | Self::WorkspaceInspected { .. }
7016 | Self::DeliveryStageBegun { .. }
7017 | Self::DeliveryBlobChunkAccepted { .. }
7018 | Self::DeliveryCommitted { .. }
7019 | Self::DeliveryStageAborted { .. }
7020 | Self::HostDirectoriesBrowsed { .. }
7021 | Self::WorkspaceFileRead { .. }
7022 | Self::WorkspaceFileWritten { .. }
7023 | Self::WorkspaceFileCreated { .. }
7024 | Self::WorkspaceDirectoryCreated { .. }
7025 | Self::GitHistoryRead { .. }
7026 | Self::GitDiffRead { .. }
7027 | Self::Controller { .. }
7028 | Self::SpawnAccepted { .. }
7029 | Self::ManagedWorktreeCleanup { .. }
7030 | Self::SessionRecordForgotten { .. }
7031 | Self::NativeSessionsCataloged { .. }
7032 | Self::NativeSessionsPaged { .. }
7033 | Self::NativeSessionPreviewed { .. }
7034 | Self::SessionRecordPreviewed { .. }
7035 | Self::HistoryDiscovered { .. }
7036 | Self::HistoryLoaded { .. }
7037 | Self::ContextPackForSessionRecordExported { .. }
7038 | Self::ContextPackExported { .. }
7039 | Self::ContextPackForgotten { .. }
7040 | Self::DurableContextPackResolved { .. }
7041 | Self::ContextPackBytesRead { .. }
7042 | Self::WorkspaceRegistered { .. }
7043 | Self::StandaloneWorkspaceCreated { .. }
7044 | Self::WorkspaceUnregistered { .. }
7045 | Self::WorktreeCreated { .. }
7046 | Self::WorktreeRemoved { .. }
7047 | Self::Accepted
7048 | Self::ShuttingDown => false,
7049 }
7050 }
7051
7052 pub fn requires_session_bundle_materialization_capability(&self) -> bool {
7053 match self {
7054 Self::Snapshot { snapshot, .. } => {
7055 snapshot.requires_session_bundle_materialization_capability()
7056 }
7057 Self::Resync {
7058 snapshot, events, ..
7059 } => {
7060 snapshot.requires_session_bundle_materialization_capability()
7061 || events.iter().any(|event| {
7062 event.event.requires_session_bundle_materialization_capability()
7063 })
7064 }
7065 Self::SpawnSpecAccepted { receipt }
7066 | Self::Spawned { receipt, .. } => receipt.bundle.is_some(),
7067 Self::ManagedWorktreeSpawnAccepted { receipt } => receipt.spawn.bundle.is_some(),
7068 Self::SessionRecordUpdated { record }
7069 | Self::ProviderSessionIndexed { record }
7070 | Self::NativeSessionIndexed { record, .. }
7071 | Self::SessionRecordResumed { record, .. } => record.bundle.is_some(),
7072 Self::DurableContextPackResolved { .. } => false,
7073 Self::Armed { .. }
7074 | Self::Activated { .. }
7075 | Self::Aborted { .. }
7076 | Self::ReplyChunkAccepted { .. }
7077 | Self::CallRejected { .. }
7078 | Self::WorkspaceInspected { .. }
7079 | Self::DeliveryStageBegun { .. }
7080 | Self::DeliveryBlobChunkAccepted { .. }
7081 | Self::DeliveryCommitted { .. }
7082 | Self::DeliveryStageAborted { .. }
7083 | Self::HostDirectoriesBrowsed { .. }
7084 | Self::WorkspaceFileRead { .. }
7085 | Self::WorkspaceFileWritten { .. }
7086 | Self::WorkspaceFileCreated { .. }
7087 | Self::WorkspaceDirectoryCreated { .. }
7088 | Self::GitHistoryRead { .. }
7089 | Self::GitDiffRead { .. }
7090 | Self::Controller { .. }
7091 | Self::SpawnAccepted { .. }
7092 | Self::ManagedWorktreeCleanup { .. }
7093 | Self::SessionRecordForgotten { .. }
7094 | Self::NativeSessionsCataloged { .. }
7095 | Self::NativeSessionsPaged { .. }
7096 | Self::NativeSessionPreviewed { .. }
7097 | Self::SessionRecordPreviewed { .. }
7098 | Self::HistoryDiscovered { .. }
7099 | Self::HistoryLoaded { .. }
7100 | Self::ContextPackForSessionRecordExported { .. }
7101 | Self::ContextPackExported { .. }
7102 | Self::ContextPackForgotten { .. }
7103 | Self::ContextPackBytesRead { .. }
7104 | Self::WorkspaceRegistered { .. }
7105 | Self::StandaloneWorkspaceCreated { .. }
7106 | Self::WorkspaceUnregistered { .. }
7107 | Self::WorktreeCreated { .. }
7108 | Self::WorktreeRemoved { .. }
7109 | Self::Accepted
7110 | Self::ShuttingDown => false,
7111 }
7112 }
7113
7114 pub fn requires_history_context_pack_capability(&self) -> bool {
7115 match self {
7116 Self::Snapshot { snapshot, .. } => {
7117 snapshot.requires_history_context_pack_capability()
7118 }
7119 Self::Resync {
7120 snapshot, events, ..
7121 } => {
7122 snapshot.requires_history_context_pack_capability()
7123 || events.iter().any(|event| {
7124 event.event.requires_history_context_pack_capability()
7125 })
7126 }
7127 Self::SpawnSpecAccepted { receipt }
7128 | Self::Spawned { receipt, .. } => {
7129 receipt.context_id.is_some() || receipt.context.is_some()
7130 }
7131 Self::ManagedWorktreeSpawnAccepted { receipt } => {
7132 receipt.spawn.context_id.is_some() || receipt.spawn.context.is_some()
7133 }
7134 Self::SessionRecordUpdated { record }
7135 | Self::ProviderSessionIndexed { record }
7136 | Self::NativeSessionIndexed { record, .. }
7137 | Self::SessionRecordResumed { record, .. } => {
7138 record.context_id.is_some() || record.context.is_some()
7139 }
7140 Self::HistoryDiscovered { .. }
7141 | Self::HistoryLoaded { .. }
7142 | Self::ContextPackForSessionRecordExported { .. }
7143 | Self::ContextPackExported { .. }
7144 | Self::ContextPackForgotten { .. }
7145 | Self::DurableContextPackResolved { .. }
7146 | Self::ContextPackBytesRead { .. } => true,
7147 Self::Armed { .. }
7148 | Self::Activated { .. }
7149 | Self::Aborted { .. }
7150 | Self::ReplyChunkAccepted { .. }
7151 | Self::CallRejected { .. }
7152 | Self::WorkspaceInspected { .. }
7153 | Self::DeliveryStageBegun { .. }
7154 | Self::DeliveryBlobChunkAccepted { .. }
7155 | Self::DeliveryCommitted { .. }
7156 | Self::DeliveryStageAborted { .. }
7157 | Self::HostDirectoriesBrowsed { .. }
7158 | Self::WorkspaceFileRead { .. }
7159 | Self::WorkspaceFileWritten { .. }
7160 | Self::WorkspaceFileCreated { .. }
7161 | Self::WorkspaceDirectoryCreated { .. }
7162 | Self::GitHistoryRead { .. }
7163 | Self::GitDiffRead { .. }
7164 | Self::Controller { .. }
7165 | Self::SpawnAccepted { .. }
7166 | Self::ManagedWorktreeCleanup { .. }
7167 | Self::SessionRecordForgotten { .. }
7168 | Self::NativeSessionsCataloged { .. }
7169 | Self::NativeSessionsPaged { .. }
7170 | Self::NativeSessionPreviewed { .. }
7171 | Self::SessionRecordPreviewed { .. }
7172 | Self::WorkspaceRegistered { .. }
7173 | Self::StandaloneWorkspaceCreated { .. }
7174 | Self::WorkspaceUnregistered { .. }
7175 | Self::WorktreeCreated { .. }
7176 | Self::WorktreeRemoved { .. }
7177 | Self::Accepted
7178 | Self::ShuttingDown => false,
7179 }
7180 }
7181}
7182
7183#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7184pub struct NodeFailure {
7185 pub code: NodeFailureCode,
7186 pub message: String,
7187}
7188
7189#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7190#[serde(rename_all = "kebab-case")]
7191pub enum NodeFailureCode {
7192 InvalidRequest,
7193 UnsupportedCapability,
7194 SpawnProfileRevisionMismatch,
7195 HarnessMcpUnavailable,
7196 ReservationNotFound,
7197 ReservationConflict,
7198 ReservationExpired,
7199 BindingMismatch,
7200 NotActivated,
7201 CallNotFound,
7202 ChunkOutOfOrder,
7203 ResponseTooLarge,
7204 DeliveryManifestInvalid,
7205 UnknownDeliveryStage,
7206 DeliveryStageConflict,
7207 DeliveryBlobUnexpected,
7208 DeliveryChunkOutOfOrder,
7209 DeliveryBlobDigestMismatch,
7210 DeliveryBundleDigestMismatch,
7211 DeliveryStageIncomplete,
7212 DeliveryStageStorageFailed,
7213 Unauthorized,
7214 ObserverReadOnly,
7215 ControllerBusy,
7216 ControllerRequired,
7217 UnknownWorkspace,
7218 HostDirectoryInvalid,
7219 HostDirectoryReadFailed,
7220 HostDirectoryReadTimedOut,
7221 InvalidRepositoryPath,
7222 RepositoryFileNotFound,
7223 RepositoryFileNotRegular,
7224 RepositoryPathUnsafe,
7225 RepositoryFileReadTimedOut,
7226 RepositoryFileReadFailed,
7227 RepositoryFileWriteTimedOut,
7228 RepositoryFileWriteFailed,
7229 RepositoryFileRevisionConflict,
7230 RepositoryEntryAlreadyExists,
7231 RepositoryParentNotFound,
7232 RepositoryParentNotDirectory,
7233 RepositoryEntryCreateTimedOut,
7234 RepositoryEntryCreateFailed,
7235 GitReadTimedOut,
7236 GitReadFailed,
7237 InvalidWorkspaceRoot,
7238 DuplicateWorkspaceId,
7239 DuplicateWorkspaceRoot,
7240 WorkspaceBusy,
7241 LastWorkspace,
7242 NotGitRepository,
7243 WorktreeConflict,
7244 WorktreeProtected,
7245 WorktreeDirty,
7246 WorktreeLocked,
7247 UnknownManagedWorktreeLease,
7248 ManagedWorktreeBusy,
7249 ManagedWorktreeOwnershipConflict,
7250 ManagedWorktreeProfileRevisionMismatch,
7251 ManagedWorktreeRecoveryRequired,
7252 StandaloneWorkspaceRecoveryRequired,
7253 UnknownSpawnProfile,
7254 UnknownBundle,
7255 UnknownContextPack,
7256 ContextPackBusy,
7257 ContextPackMaterializationFailed,
7258 UnknownEnvironmentProfile,
7259 UnknownNetworkAllowlist,
7265 UnsupportedNetworkAllowlistMapping,
7270 BrowserStationProbeUnavailable,
7277 BrowserStationUnreachable,
7281 BrowserStationProfileBusy,
7286 BundleBindingMismatch,
7287 EnvironmentProfileBindingMismatch,
7288 BundleMaterializationFailed,
7289 SpawnTargetMismatch,
7290 SpawnIdempotencyConflict,
7291 SpawnIdempotencyCapacity,
7292 SpawnDeadlineExceeded,
7293 UnsupportedSpawnCapability,
7294 UnsupportedTransport,
7303 TurnInFlight,
7315 UnknownSession,
7316 UnknownSessionRecord,
7317 SessionRecordNotResumable,
7318 SessionRecordBusy,
7319 SessionRecordConflict,
7320 SessionWorkspaceMismatch,
7321 WorkspaceRegistrationRequired,
7322 StaleNativeSessionCatalog,
7323 StaleGeneration,
7324 BackendBusy,
7325 BackendDisconnected,
7326 BackendOperationFailed,
7327 ShuttingDown,
7328}
7329
7330#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7334#[serde(deny_unknown_fields)]
7335pub struct AgentStreamNamedIdV1 {
7336 pub id: String,
7337 pub name: String,
7338 pub description: Option<String>,
7339}
7340
7341#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7344#[serde(deny_unknown_fields)]
7345pub struct AgentStreamInteractionOptionV1 {
7346 pub option_id: String,
7347 pub name: String,
7348 pub kind: String,
7349}
7350
7351#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7360#[serde(rename_all = "kebab-case")]
7361pub enum BlockAuthorityV1 {
7362 HostGate,
7364 HostPolicy,
7366 HostDeadline,
7369 Operator,
7371 ProviderClassifier,
7373 ProviderPermissionRule,
7376 ProviderSandbox,
7378 ProviderRefusal,
7380 UserRejected,
7382 ProviderQuota,
7385 Unknown,
7387}
7388
7389#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7398#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
7399pub enum AgentStreamChunkKindV1 {
7400 Text { text: String, is_delta: bool },
7401 Thinking { text: String },
7402 InteractionPrompt {
7407 correlation_id: String,
7408 interaction_kind: ProviderInteractionKind,
7409 tool_name: String,
7410 title: Option<String>,
7411 prompt: String,
7412 options: Vec<AgentStreamInteractionOptionV1>,
7413 },
7414 ModeCatalog {
7415 current: Option<String>,
7416 available: Vec<AgentStreamNamedIdV1>,
7417 },
7418 ConfigOptions { options: Vec<ProviderConfigOption> },
7421 ModelCatalog {
7422 current: Option<String>,
7423 available: Vec<AgentStreamNamedIdV1>,
7424 },
7425 Blocked {
7433 correlation_id: Option<String>,
7434 tool_class: String,
7435 authority: BlockAuthorityV1,
7436 reason_kind: Option<String>,
7437 reason: String,
7438 help: Option<String>,
7439 },
7440}
7441
7442impl AgentStreamChunkKindV1 {
7443 pub fn validate(&self) -> Result<(), &'static str> {
7444 match self {
7445 Self::Text { text, .. } | Self::Thinking { text } => {
7446 if text.len() > MAX_ACP_CONTROL_TEXT_BYTES || contains_unsafe_control_bytes(text) {
7447 return Err("agent stream text chunk is invalid");
7448 }
7449 Ok(())
7450 }
7451 Self::InteractionPrompt {
7452 correlation_id,
7453 tool_name,
7454 title,
7455 prompt,
7456 options,
7457 ..
7458 } => {
7459 if correlation_id.is_empty()
7460 || correlation_id.len() > MAX_ACP_CORRELATION_ID_BYTES
7461 || correlation_id.chars().any(char::is_control)
7462 {
7463 return Err("agent stream interaction prompt correlation id is invalid");
7464 }
7465 if tool_name.is_empty()
7466 || tool_name.len() > MAX_ACP_CONTROL_ID_BYTES
7467 || tool_name.chars().any(char::is_control)
7468 {
7469 return Err("agent stream interaction prompt tool name is invalid");
7470 }
7471 if let Some(title) = title {
7472 if title.len() > MAX_ACP_CONTROL_TEXT_BYTES
7473 || contains_unsafe_control_bytes(title)
7474 {
7475 return Err("agent stream interaction prompt title is invalid");
7476 }
7477 }
7478 if prompt.len() > MAX_ACP_CONTROL_TEXT_BYTES || contains_unsafe_control_bytes(prompt)
7479 {
7480 return Err("agent stream interaction prompt text is invalid");
7481 }
7482 if options.len() > MAX_ACP_INTERACTION_OPTIONS {
7483 return Err("agent stream interaction prompt has too many options");
7484 }
7485 for option in options {
7486 if option.option_id.is_empty()
7487 || option.option_id.len() > MAX_ACP_CONTROL_ID_BYTES
7488 || option.option_id.chars().any(char::is_control)
7489 {
7490 return Err("agent stream interaction prompt option id is invalid");
7491 }
7492 if option.name.is_empty()
7493 || option.name.len() > MAX_ACP_CONTROL_ID_BYTES
7494 || option.name.chars().any(char::is_control)
7495 {
7496 return Err("agent stream interaction prompt option name is invalid");
7497 }
7498 if option.kind.is_empty()
7499 || option.kind.len() > MAX_ACP_CONTROL_ID_BYTES
7500 || option.kind.chars().any(char::is_control)
7501 {
7502 return Err("agent stream interaction prompt option kind is invalid");
7503 }
7504 }
7505 Ok(())
7506 }
7507 Self::ModeCatalog { current, available } | Self::ModelCatalog { current, available } => {
7508 if let Some(current) = current {
7509 if current.is_empty()
7510 || current.len() > MAX_ACP_CONTROL_ID_BYTES
7511 || current.chars().any(char::is_control)
7512 {
7513 return Err("agent stream catalog current id is invalid");
7514 }
7515 }
7516 if available.len() > MAX_ACP_CATALOG_ENTRIES {
7517 return Err("agent stream catalog has too many entries");
7518 }
7519 for entry in available {
7520 if entry.id.is_empty()
7521 || entry.id.len() > MAX_ACP_CONTROL_ID_BYTES
7522 || entry.id.chars().any(char::is_control)
7523 {
7524 return Err("agent stream catalog entry id is invalid");
7525 }
7526 if entry.name.is_empty()
7527 || entry.name.len() > MAX_ACP_CONTROL_ID_BYTES
7528 || entry.name.chars().any(char::is_control)
7529 {
7530 return Err("agent stream catalog entry name is invalid");
7531 }
7532 if let Some(description) = &entry.description {
7533 if description.len() > MAX_ACP_CONTROL_TEXT_BYTES
7534 || contains_unsafe_control_bytes(description)
7535 {
7536 return Err("agent stream catalog entry description is invalid");
7537 }
7538 }
7539 }
7540 Ok(())
7541 }
7542 Self::ConfigOptions { options } => {
7543 if options.len() > MAX_ACP_CATALOG_ENTRIES {
7544 return Err("agent stream config options exceed the catalog entry limit");
7545 }
7546 for option in options {
7547 if option.id.is_empty()
7548 || option.id.len() > MAX_ACP_CONTROL_ID_BYTES
7549 || option.id.chars().any(char::is_control)
7550 {
7551 return Err("agent stream config option id is invalid");
7552 }
7553 if option.name.is_empty()
7554 || option.name.len() > MAX_ACP_CONTROL_ID_BYTES
7555 || option.name.chars().any(char::is_control)
7556 {
7557 return Err("agent stream config option name is invalid");
7558 }
7559 if let Some(description) = &option.description {
7560 if description.len() > MAX_ACP_CONTROL_TEXT_BYTES
7561 || contains_unsafe_control_bytes(description)
7562 {
7563 return Err("agent stream config option description is invalid");
7564 }
7565 }
7566 if let Some(category) = &option.category {
7567 if category.len() > MAX_ACP_CONTROL_ID_BYTES
7568 || category.chars().any(char::is_control)
7569 {
7570 return Err("agent stream config option category is invalid");
7571 }
7572 }
7573 if option.value_json.len() > MAX_ACP_CONTROL_TEXT_BYTES
7574 || contains_unsafe_control_bytes(&option.value_json)
7575 {
7576 return Err("agent stream config option value is invalid");
7577 }
7578 if option.choices.len() > MAX_ACP_INTERACTION_OPTIONS {
7579 return Err("agent stream config option has too many choices");
7580 }
7581 for choice in &option.choices {
7582 if choice.value_json.len() > MAX_ACP_CONTROL_TEXT_BYTES
7583 || contains_unsafe_control_bytes(&choice.value_json)
7584 {
7585 return Err("agent stream config option choice value is invalid");
7586 }
7587 if let Some(label) = &choice.label {
7588 if label.len() > MAX_ACP_CONTROL_TEXT_BYTES
7589 || contains_unsafe_control_bytes(label)
7590 {
7591 return Err("agent stream config option choice label is invalid");
7592 }
7593 }
7594 }
7595 }
7596 Ok(())
7597 }
7598 Self::Blocked {
7599 correlation_id,
7600 tool_class,
7601 reason_kind,
7602 reason,
7603 help,
7604 ..
7605 } => {
7606 if let Some(correlation_id) = correlation_id {
7607 if correlation_id.is_empty()
7608 || correlation_id.len() > MAX_ACP_CORRELATION_ID_BYTES
7609 || correlation_id.chars().any(char::is_control)
7610 {
7611 return Err("agent stream blocked chunk correlation id is invalid");
7612 }
7613 }
7614 if tool_class.is_empty()
7615 || tool_class.len() > MAX_ACP_CORRELATION_ID_BYTES
7616 || tool_class.chars().any(char::is_control)
7617 {
7618 return Err("agent stream blocked chunk tool class is invalid");
7619 }
7620 if let Some(reason_kind) = reason_kind {
7621 if reason_kind.is_empty()
7622 || reason_kind.len() > MAX_ACP_CORRELATION_ID_BYTES
7623 || reason_kind.chars().any(char::is_control)
7624 {
7625 return Err("agent stream blocked chunk reason kind is invalid");
7626 }
7627 }
7628 if reason.is_empty()
7629 || reason.len() > MAX_ACP_BLOCKED_REASON_BYTES
7630 || contains_unsafe_control_bytes(reason)
7631 {
7632 return Err("agent stream blocked chunk reason is invalid");
7633 }
7634 if let Some(help) = help {
7635 if help.is_empty()
7636 || help.len() > MAX_ACP_BLOCKED_HELP_BYTES
7637 || contains_unsafe_control_bytes(help)
7638 {
7639 return Err("agent stream blocked chunk help is invalid");
7640 }
7641 }
7642 Ok(())
7643 }
7644 }
7645 }
7646}
7647
7648#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7654#[serde(deny_unknown_fields)]
7655pub struct AgentStreamChunkV1 {
7656 pub source_sequence: u64,
7657 pub kind: AgentStreamChunkKindV1,
7658}
7659
7660impl AgentStreamChunkV1 {
7661 pub fn validate(&self) -> Result<(), &'static str> {
7662 self.kind.validate()
7663 }
7664}
7665
7666#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7667pub struct NodeEventEnvelope {
7668 pub sequence: u64,
7669 pub event: NodeEvent,
7670}
7671
7672#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7673#[serde(tag = "kind", rename_all = "kebab-case")]
7674pub enum NodeEvent {
7675 HarnessMcpReadCall {
7676 reservation_id: HarnessMcpReservationId,
7677 activation_digest: HarnessMcpActivationDigest,
7678 record_id: SessionRecordId,
7679 session: SessionAddress,
7680 call_id: HarnessMcpCallId,
7681 request: HarnessMcpOpaquePayloadV1,
7682 deadline_unix_ms: u64,
7683 },
7684 Control { address: SessionAddress, event: ControlEvent },
7685 SessionRecordHistorySummarized { record_id: SessionRecordId, summary: SessionHistorySummaryV1 },
7689 TerminalFrame { address: SessionAddress, frame: TerminalFrame },
7690 AgentStream { address: SessionAddress, chunk: AgentStreamChunkV1 },
7691 ControllerChanged { controller: Option<ControllerState> },
7692 WorkspaceAdded { workspace: WorkspaceSnapshot },
7693 WorkspaceRemoved { workspace_id: WorkspaceId },
7694 SessionRecordUpserted { record: ManagedSessionRecord },
7695 SessionRecordRemoved { record_id: SessionRecordId },
7696 ManagedWorktreeUpserted { lease: ManagedWorktreeLeaseSnapshot },
7697 ManagedWorktreeRemoved { lease_id: ManagedWorktreeLeaseId },
7698 ResyncRequired { oldest_available_sequence: u64 },
7699}
7700
7701impl NodeEvent {
7702 pub fn requires_harness_mcp_proxy_capability(&self) -> bool {
7703 matches!(self, Self::HarnessMcpReadCall { .. })
7704 }
7705
7706 pub fn harness_mcp_contract_is_valid_at(&self, now_unix_ms: u64) -> bool {
7707 match self {
7708 Self::HarnessMcpReadCall { request, deadline_unix_ms, .. } => {
7709 serde_json::to_vec(request)
7710 .is_ok_and(|wire| wire.len() <= MAX_HARNESS_MCP_LOCAL_REQUEST_BYTES)
7711 && *deadline_unix_ms > now_unix_ms
7712 && deadline_unix_ms.saturating_sub(now_unix_ms)
7713 <= MAX_HARNESS_MCP_CALL_DEADLINE_MS
7714 }
7715 _ => true,
7716 }
7717 }
7718
7719 pub fn requires_child_environment_profile_capability(&self) -> bool {
7720 matches!(self, Self::SessionRecordUpserted { record }
7721 if record.environment_profile.is_some())
7722 }
7723
7724
7725 pub fn requires_session_bundle_materialization_capability(&self) -> bool {
7726 matches!(self, Self::SessionRecordUpserted { record }
7727 if record.bundle.is_some())
7728 }
7729
7730 pub fn requires_history_context_pack_capability(&self) -> bool {
7731 matches!(self, Self::SessionRecordUpserted { record }
7732 if record.context_id.is_some() || record.context.is_some())
7733 }
7734}
7735
7736#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
7739#[serde(deny_unknown_fields)]
7740pub struct SessionHistorySummaryV1 {
7741 pub message_count: u64,
7742 pub message_count_exact: bool,
7743 pub completed_turn_count: Option<u64>,
7744 pub total_tokens: Option<u64>,
7745 pub modified_at_unix_ms: Option<u64>,
7746}
7747
7748impl From<&SessionRecordPreview> for SessionHistorySummaryV1 {
7749 fn from(preview: &SessionRecordPreview) -> Self {
7750 Self {
7751 message_count: preview.message_count,
7752 message_count_exact: preview.message_count_exact,
7753 completed_turn_count: preview.completed_turn_count,
7754 total_tokens: preview.total_tokens,
7755 modified_at_unix_ms: preview.modified_at_unix_ms,
7756 }
7757 }
7758}
7759
7760#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7786pub struct NodeCallHomeAnnounce {
7787 pub build_stamp: String,
7788 pub node_id: String,
7789}
7790
7791impl NodeCallHomeAnnounce {
7792 pub fn new(node_id: impl Into<String>) -> Self {
7793 Self { build_stamp: BUILD_STAMP.to_owned(), node_id: node_id.into() }
7794 }
7795}
7796
7797#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7798#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
7799pub enum ClientFrame {
7800 Hello(ClientHello),
7801 Authenticate(ClientAuthentication),
7802 Request(RequestEnvelope),
7803}
7804
7805#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
7806#[serde(tag = "kind", content = "payload", rename_all = "kebab-case")]
7807pub enum ServerFrame {
7808 Challenge(ServerChallenge),
7809 Hello(NodeHello),
7810 Reply(ResponseEnvelope),
7811 Event(NodeEventEnvelope),
7812}
7813
7814pub async fn read_json_frame<R, T>(reader: &mut R) -> Result<T, FrameError>
7815where
7816 R: AsyncRead + Unpin,
7817 T: DeserializeOwned,
7818{
7819 read_json_frame_limited(reader, MAX_NODE_FRAME_BYTES).await
7820}
7821
7822pub async fn read_json_frame_limited<R, T>(reader: &mut R, max_bytes: usize) -> Result<T, FrameError>
7823where
7824 R: AsyncRead + Unpin,
7825 T: DeserializeOwned,
7826{
7827 let length = reader.read_u32_le().await? as usize;
7828 if length == 0 || length > max_bytes {
7829 return Err(FrameError::InvalidLength {
7830 length,
7831 max: max_bytes,
7832 });
7833 }
7834 let mut payload = vec![0; length];
7835 reader.read_exact(&mut payload).await?;
7836 Ok(serde_json::from_slice(&payload)?)
7837}
7838
7839pub async fn read_json_frame_limited_body_timeout<R, T>(
7840 reader: &mut R,
7841 max_bytes: usize,
7842 body_timeout: Duration,
7843) -> Result<T, FrameError>
7844where
7845 R: AsyncRead + Unpin,
7846 T: DeserializeOwned,
7847{
7848 let mut length_prefix = [0_u8; std::mem::size_of::<u32>()];
7849 reader.read_exact(&mut length_prefix[..1]).await?;
7850 timeout(body_timeout, reader.read_exact(&mut length_prefix[1..]))
7851 .await
7852 .map_err(|_| FrameError::PrefixTimedOut)??;
7853 let length = u32::from_le_bytes(length_prefix) as usize;
7854 if length == 0 || length > max_bytes {
7855 return Err(FrameError::InvalidLength {
7856 length,
7857 max: max_bytes,
7858 });
7859 }
7860 let mut payload = vec![0; length];
7861 timeout(body_timeout, reader.read_exact(&mut payload))
7862 .await
7863 .map_err(|_| FrameError::BodyTimedOut { length })??;
7864 Ok(serde_json::from_slice(&payload)?)
7865}
7866
7867pub async fn write_json_frame<W, T>(writer: &mut W, value: &T) -> Result<(), FrameError>
7868where
7869 W: AsyncWrite + Unpin,
7870 T: Serialize,
7871{
7872 write_json_frame_limited(writer, value, MAX_NODE_FRAME_BYTES).await
7873}
7874
7875pub async fn write_json_frame_limited<W, T>(
7876 writer: &mut W,
7877 value: &T,
7878 max_bytes: usize,
7879) -> Result<(), FrameError>
7880where
7881 W: AsyncWrite + Unpin,
7882 T: Serialize,
7883{
7884 let payload = serde_json::to_vec(value)?;
7885 if payload.is_empty() || payload.len() > max_bytes {
7886 return Err(FrameError::InvalidLength {
7887 length: payload.len(),
7888 max: max_bytes,
7889 });
7890 }
7891 writer.write_u32_le(payload.len() as u32).await?;
7892 writer.write_all(&payload).await?;
7893 writer.flush().await?;
7894 Ok(())
7895}
7896
7897#[derive(Debug, Error)]
7898pub enum FrameError {
7899 #[error("node frame I/O failed: {0}")]
7900 Io(#[from] io::Error),
7901 #[error("node frame JSON failed: {0}")]
7902 Json(#[from] serde_json::Error),
7903 #[error("node frame length {length} is outside 1..={max}")]
7904 InvalidLength { length: usize, max: usize },
7905 #[error("node frame body of {length} bytes was not received before the bounded deadline")]
7906 BodyTimedOut { length: usize },
7907 #[error("node frame length prefix was not completed before the bounded deadline")]
7908 PrefixTimedOut,
7909}
7910
7911#[cfg(test)]
7912mod tests {
7913 use super::*;
7914
7915 #[test]
7920 fn agent_stream_blocked_chunk_serialises_additively_with_all_six_fields() {
7921 let chunk = AgentStreamChunkV1 {
7922 source_sequence: 7,
7923 kind: AgentStreamChunkKindV1::Blocked {
7924 correlation_id: Some("tool-deadbeefcafebabe".to_owned()),
7925 tool_class: "Shell".to_owned(),
7926 authority: BlockAuthorityV1::HostGate,
7927 reason_kind: Some("gate-rule".to_owned()),
7928 reason: "blocked by dangerous-command gate: rule=filesystem-wipe".to_owned(),
7929 help: Some("add a Bash permission rule".to_owned()),
7930 },
7931 };
7932 chunk.validate().unwrap();
7933 let json = serde_json::to_string(&chunk).unwrap();
7934 assert_eq!(
7935 json,
7936 r#"{"source_sequence":7,"kind":{"kind":"blocked","correlation_id":"tool-deadbeefcafebabe","tool_class":"Shell","authority":"host-gate","reason_kind":"gate-rule","reason":"blocked by dangerous-command gate: rule=filesystem-wipe","help":"add a Bash permission rule"}}"#,
7937 );
7938 assert_eq!(serde_json::from_str::<AgentStreamChunkV1>(&json).unwrap(), chunk);
7939
7940 let minimal = AgentStreamChunkV1 {
7946 source_sequence: 8,
7947 kind: AgentStreamChunkKindV1::Blocked {
7948 correlation_id: None,
7949 tool_class: "Write".to_owned(),
7950 authority: BlockAuthorityV1::Unknown,
7951 reason_kind: None,
7952 reason: "blocked; no reason was reported".to_owned(),
7953 help: None,
7954 },
7955 };
7956 minimal.validate().unwrap();
7957 let minimal_json = serde_json::to_string(&minimal).unwrap();
7958 assert_eq!(
7959 minimal_json,
7960 r#"{"source_sequence":8,"kind":{"kind":"blocked","correlation_id":null,"tool_class":"Write","authority":"unknown","reason_kind":null,"reason":"blocked; no reason was reported","help":null}}"#,
7961 );
7962 assert_eq!(
7963 serde_json::from_str::<AgentStreamChunkV1>(&minimal_json).unwrap(),
7964 minimal,
7965 );
7966 }
7967
7968 #[test]
7969 fn harness_mcp_protocol_serde_bounds_and_privacy() {
7970 let reservation_id = HarnessMcpReservationId::new(format!(
7971 "hmcpres_{}", "a".repeat(24),
7972 )).unwrap();
7973 let call_id = HarnessMcpCallId::new(format!("hmcpcall_{}", "b".repeat(24))).unwrap();
7974 let digest = HarnessMcpActivationDigest::new(format!(
7975 "sha256:{}", "c".repeat(64),
7976 )).unwrap();
7977 let token = HarnessMcpLocalToken::new(format!("g4ah3_{}", "d".repeat(64))).unwrap();
7978 assert!(!format!("{token:?}").contains(token.expose()));
7979 assert!(HarnessMcpReservationId::new(format!("hmcpres_{}", "A".repeat(24))).is_err());
7980 assert!(HarnessMcpCallId::new(format!("hmcpcall_{}", "b".repeat(23))).is_err());
7981 assert!(HarnessMcpActivationDigest::new(format!("sha256:{}", "g".repeat(64))).is_err());
7982
7983 let local = HarnessMcpLocalRequestV1 {
7984 version: 1,
7985 token,
7986 request: HarnessMcpOpaquePayloadV1 {
7987 content_type: HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
7988 body: br#"{"kind":"context-get"}"#.to_vec(),
7989 },
7990 };
7991 local.validate().unwrap();
7992 let mut unknown = serde_json::to_value(&local).unwrap();
7993 unknown["endpoint"] = serde_json::Value::String("forbidden".to_owned());
7994 assert!(serde_json::from_value::<HarnessMcpLocalRequestV1>(unknown).is_err());
7995 assert!(HarnessMcpReplyChunkHexV1::new(
7996 "00".repeat(MAX_HARNESS_MCP_REPLY_CHUNK_RAW_BYTES),
7997 ).is_ok());
7998 assert!(HarnessMcpReplyChunkHexV1::new(
7999 "00".repeat(MAX_HARNESS_MCP_REPLY_CHUNK_RAW_BYTES + 1),
8000 ).is_err());
8001
8002 let event = NodeEvent::HarnessMcpReadCall {
8003 reservation_id: reservation_id.clone(),
8004 activation_digest: digest.clone(),
8005 record_id: SessionRecordId::new("record-a").unwrap(),
8006 session: session_address("primary", 1),
8007 call_id,
8008 request: HarnessMcpOpaquePayloadV1 {
8009 content_type: HarnessMcpContentTypeV1::HarnessReadRequestJsonV1,
8010 body: br#"{"kind":"context-get"}"#.to_vec(),
8011 },
8012 deadline_unix_ms: 4_000,
8013 };
8014 assert!(event.harness_mcp_contract_is_valid_at(1_000));
8015 assert!(!event.harness_mcp_contract_is_valid_at(999));
8016 let request = NodeRequest::AbortHarnessMcpReservation {
8017 reservation_id,
8018 activation_digest: digest,
8019 };
8020 assert_eq!(request.required_capability(), Some(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY));
8021 let json = serde_json::to_string(&event).unwrap();
8022 assert!(!json.contains("g4ah3_"));
8023 assert!(!json.contains("endpoint"));
8024 assert!(!json.contains("path"));
8025 }
8026
8027 #[test]
8028 fn task_id_is_fixed_opaque_and_bounded() {
8029 let task_id = TaskId::from_nonce([
8030 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xff,
8031 ]);
8032 assert_eq!(task_id.as_str(), "task-00112233445566778899aaff");
8033 assert_eq!(task_id.as_str().len(), 29);
8034 assert_eq!(task_id.as_str().parse::<TaskId>().unwrap(), task_id);
8035 assert!("task-00112233445566778899aaf".parse::<TaskId>().is_err());
8036 assert!("task-00112233445566778899aaff00".parse::<TaskId>().is_err());
8037 assert!("task-00112233445566778899aaFF".parse::<TaskId>().is_err());
8038 assert!("work-00112233445566778899aaff".parse::<TaskId>().is_err());
8039 assert!(serde_json::from_str::<TaskId>(
8040 r#""task-00112233445566778899aaff-extra""#,
8041 )
8042 .is_err());
8043 }
8044
8045 fn agent(value: &str) -> AgentId {
8046 AgentId::new(value).unwrap()
8047 }
8048
8049 fn host_path(value: &str) -> OpaqueHostPath {
8050 OpaqueHostPath::utf8(value.to_owned()).unwrap()
8051 }
8052
8053 fn repository_path(value: &str) -> RepositoryPath {
8054 RepositoryPath::utf8(value.to_owned()).unwrap()
8055 }
8056
8057 fn session_address(workspace_id: &str, instance_id: u64) -> SessionAddress {
8058 SessionAddress {
8059 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
8060 session: SessionKey {
8061 instance_id: AgentInstanceId(instance_id),
8062 generation: SessionGeneration(1),
8063 },
8064 }
8065 }
8066
8067 fn context_receipt(
8068 id: &str,
8069 source_session: SessionAddress,
8070 ) -> ResolvedContextPackReceipt {
8071 ResolvedContextPackReceipt {
8072 id: SpawnContextId::new(id).unwrap(),
8073 digest: SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).unwrap(),
8074 lineage: ContextPackLineageReceipt {
8075 source_node_id: NodeId::new("node-a").unwrap(),
8076 source_session,
8077 source_provider: agent("claude"),
8078 },
8079 source_message_count: 3,
8080 retained_message_count: 2,
8081 byte_len: 32,
8082 truncated: true,
8083 }
8084 }
8085
8086 fn portable_node_support() -> NodeCompatibilitySupport {
8087 NodeCompatibilitySupport {
8088 build_stamp: BUILD_STAMP.to_owned(),
8089 capabilities: vec![
8090 CapabilityId::new("workspace.inspect").unwrap(),
8091 CapabilityId::new("session.spawn").unwrap(),
8092 ],
8093 host: HostDescriptor {
8094 operating_system: OperatingSystemId::new("windows").unwrap(),
8095 architecture: ArchitectureId::new("x86_64").unwrap(),
8096 },
8097 path_semantics: PathSemantics {
8098 style: PathStyle::Windows,
8099 encoding: PathEncoding::Utf8,
8100 },
8101 local_transport: LocalTransportKind::WindowsNamedPipe,
8102 state_schema: StateSchemaSupport {
8103 versions: ProtocolRange::new(3, 5).unwrap(),
8104 },
8105 provider_contracts: vec![ProviderContractSupport {
8106 provider: agent("codex"),
8107 revision: ProviderContractRevision::new("codex.2026-08").unwrap(),
8108 }],
8109 provider_adapter_contracts: vec![ProviderAdapterContractSupport {
8110 provider: agent("codex"),
8111 family: AdapterFamily::PtySemantic,
8112 adapter_id: AdapterId::new("codex-cli").unwrap(),
8113 revision: AdapterContractRevision::new("pty-semantic-v1").unwrap(),
8114 }],
8115 }
8116 }
8117
8118 #[test]
8119 fn legacy_hello_json_carries_the_build_stamp() {
8120 let client = ClientHello::new(ClientRole::Observer, [0; NODE_AUTH_NONCE_BYTES]);
8121 assert_eq!(
8122 serde_json::to_string(&client).unwrap(),
8123 format!(
8124 r#"{{"build_stamp":"{BUILD_STAMP}","role":"observer","client_nonce":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}}"#,
8125 ),
8126 );
8127
8128 let challenge = ServerChallenge {
8129 build_stamp: BUILD_STAMP.to_owned(),
8130 server_nonce: [0; NODE_AUTH_NONCE_BYTES],
8131 server_proof: [0; NODE_AUTH_PROOF_BYTES],
8132 compatibility: None,
8133 };
8134 let json = serde_json::to_string(&challenge).unwrap();
8135 assert!(!json.contains("compatibility"));
8136 assert_eq!(serde_json::from_str::<ServerChallenge>(&json).unwrap(), challenge);
8137 }
8138
8139 #[test]
8140 fn legacy_hello_omits_compatibility_instead_of_synthesizing_a_selection() {
8141 let hello = ClientHello::new(ClientRole::Observer, [0; NODE_AUTH_NONCE_BYTES]);
8142 assert_eq!(hello.compatibility, None);
8143 }
8144
8145 #[test]
8146 fn legacy_spawn_json_remains_exact_after_spawn_spec() {
8147 let request = NodeRequest::Spawn {
8148 workspace_id: WorkspaceId::new("primary").unwrap(),
8149 provider: agent("claude"),
8150 mode: SessionMode::Pty,
8151 terminal_size: TerminalSize {
8152 rows: 24,
8153 columns: 80,
8154 },
8155 initial_prompt: None,
8156 };
8157 assert_eq!(
8158 serde_json::to_string(&request).unwrap(),
8159 r#"{"kind":"spawn","workspace_id":"primary","provider":"claude","mode":"pty","terminal_size":{"rows":24,"columns":80},"initial_prompt":null}"#,
8160 );
8161 assert_eq!(request.required_capability(), None);
8162
8163 let response = NodeResponse::SpawnAccepted {
8164 session: SessionAddress {
8165 workspace_id: WorkspaceId::new("primary").unwrap(),
8166 session: SessionKey {
8167 instance_id: AgentInstanceId(7),
8168 generation: SessionGeneration(1),
8169 },
8170 },
8171 };
8172 assert_eq!(
8173 serde_json::to_string(&response).unwrap(),
8174 r#"{"kind":"spawn-accepted","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}}}"#,
8175 );
8176 }
8177
8178 #[test]
8179 fn history_loaded_completed_turn_count_is_optional_wire_metadata() {
8180 let legacy_json = r#"{"kind":"history-loaded","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}},"session_id":"session-7","message_count":12}"#;
8181 let legacy = serde_json::from_str::<NodeResponse>(legacy_json).unwrap();
8182 assert_eq!(serde_json::to_string(&legacy).unwrap(), legacy_json);
8183 assert!(matches!(
8184 legacy,
8185 NodeResponse::HistoryLoaded {
8186 completed_turn_count: None,
8187 ..
8188 }
8189 ));
8190
8191 let current = NodeResponse::HistoryLoaded {
8192 session: SessionAddress {
8193 workspace_id: WorkspaceId::new("primary").unwrap(),
8194 session: SessionKey {
8195 instance_id: AgentInstanceId(7),
8196 generation: SessionGeneration(1),
8197 },
8198 },
8199 session_id: "session-7".to_owned(),
8200 message_count: 12,
8201 completed_turn_count: Some(5),
8202 };
8203 assert_eq!(
8204 serde_json::to_string(¤t).unwrap(),
8205 r#"{"kind":"history-loaded","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}},"session_id":"session-7","message_count":12,"completed_turn_count":5}"#,
8206 );
8207 }
8208
8209 #[test]
8210 fn spawn_spec_defaults_overrides_are_deterministic() {
8211 let profile_id = SpawnProfileId::new("review-default").unwrap();
8212 let defaults = SpawnProfileDefaults {
8213 profile_id: profile_id.clone(),
8214 revision: SpawnProfileRevision::new("review-default.r3").unwrap(),
8215 provider: agent("claude"),
8216 mode: SessionMode::Pty,
8217 terminal_size: TerminalSize {
8218 rows: 24,
8219 columns: 80,
8220 },
8221 prompt: Some(SpawnPrompt::new("profile prompt").unwrap()),
8222 bundle_id: Some(SpawnBundleId::new("review-bundle").unwrap()),
8223 context_id: Some(SpawnContextId::new("repo-context").unwrap()),
8224 environment_profile_id: Some(
8225 SpawnEnvironmentProfileId::new("local-default").unwrap(),
8226 ),
8227 };
8228 let spec = SpawnSpec {
8229 target: SpawnTarget {
8230 node_id: NodeId::new("node-a").unwrap(),
8231 workspace_id: WorkspaceId::new("primary").unwrap(),
8232 worktree_id: Some(WorkspaceId::new("review-tree").unwrap()),
8233 },
8234 profile_id,
8235 expected_profile_revision: defaults.revision.clone(),
8236 overrides: SpawnOverrides {
8237 provider: SpawnOverride::Inherit,
8238 mode: SpawnOverride::Set {
8239 value: SessionMode::Inline,
8240 },
8241 terminal_size: SpawnOverride::Set {
8242 value: TerminalSize {
8243 rows: 31,
8244 columns: 97,
8245 },
8246 },
8247 prompt: SpawnOverride::Set {
8248 value: SpawnPrompt::new("private prompt text").unwrap(),
8249 },
8250 bundle_id: SpawnOverride::Clear,
8251 context_id: SpawnOverride::Inherit,
8252 environment_profile_id: SpawnOverride::Clear,
8253 approval_level: None,
8254 network_allowlist: None,
8255 browser_profile_id: None,
8256 },
8257 deadline_ms: SpawnDeadlineMs::new(30_000).unwrap(),
8258 idempotency_key: SpawnIdempotencyKey::new("request-0001").unwrap(),
8259 required_capabilities: SpawnRequiredCapabilities::new([
8260 CapabilityId::new(SPAWN_RUNTIME_STRUCTURED_PROMPT).unwrap(),
8261 CapabilityId::new(SPAWN_RUNTIME_RAW_PTY_LIFECYCLE).unwrap(),
8262 ])
8263 .unwrap(),
8264 };
8265
8266 let first = spec.resolve(&defaults).unwrap();
8267 let second = spec.resolve(&defaults).unwrap();
8268 assert_eq!(first, second);
8269 assert_eq!(first.provider, agent("claude"));
8270 assert_eq!(first.mode, SessionMode::Inline);
8271 assert_eq!(first.terminal_size.rows, 31);
8272 assert_eq!(first.prompt.as_ref().unwrap().as_str(), "private prompt text");
8273 assert_eq!(first.bundle_id, None);
8274 assert_eq!(
8275 first.context_id.as_ref().map(SpawnContextId::as_str),
8276 Some("repo-context"),
8277 );
8278 assert_eq!(first.environment_profile_id, None);
8279 assert_eq!(first.network_allowlist, None);
8280 assert_eq!(first.browser_profile_id, None);
8281 assert_eq!(first.provenance.provider, SpawnFieldProvenance::Profile);
8282 assert_eq!(first.provenance.mode, SpawnFieldProvenance::Override);
8283 assert_eq!(first.provenance.prompt, SpawnFieldProvenance::Override);
8284 assert_eq!(first.provenance.bundle_id, SpawnFieldProvenance::Cleared);
8285 assert_eq!(first.provenance.context_id, SpawnFieldProvenance::Profile);
8286 assert_eq!(
8287 first.required_capabilities.as_slice(),
8288 &[
8289 CapabilityId::new(SPAWN_RUNTIME_RAW_PTY_LIFECYCLE).unwrap(),
8290 CapabilityId::new(SPAWN_RUNTIME_STRUCTURED_PROMPT).unwrap(),
8291 ],
8292 );
8293
8294 let source_session = session_address("primary", 7);
8295 let receipt = first.receipt_with_materialization(
8296 NodeIncarnationId::from_bytes([9; NODE_INCARNATION_ID_BYTES]),
8297 session_address("review-tree", 11),
8298 None,
8299 None,
8300 Some(context_receipt("repo-context", source_session)),
8301 );
8302 assert_eq!(receipt.prompt, SpawnPromptMetadata {
8303 present: true,
8304 byte_len: 19,
8305 });
8306 let receipt_json = serde_json::to_string(&receipt).unwrap();
8307 assert_eq!(
8308 receipt_json,
8309 r#"{"incarnation_id":"09090909090909090909090909090909","session":{"workspace_id":"review-tree","session":{"instance_id":11,"generation":1}},"target":{"node_id":"node-a","workspace_id":"primary","worktree_id":"review-tree"},"profile_id":"review-default","profile_revision":"review-default.r3","provider":"claude","mode":"inline","terminal_size":{"rows":31,"columns":97},"prompt":{"present":true,"byte_len":19},"bundle_id":null,"context_id":"repo-context","context":{"id":"repo-context","digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","lineage":{"source_node_id":"node-a","source_session":{"workspace_id":"primary","session":{"instance_id":7,"generation":1}},"source_provider":"claude"},"source_message_count":3,"retained_message_count":2,"byte_len":32,"truncated":true},"environment_profile_id":null,"deadline_ms":30000,"idempotency_key":"request-0001","required_capabilities":["raw-pty-lifecycle","structured-prompt"],"provenance":{"provider":"profile","mode":"override","terminal_size":"override","prompt":"override","bundle_id":"cleared","context_id":"profile","environment_profile_id":"cleared"}}"#,
8310 );
8311 assert_eq!(
8312 serde_json::from_str::<ResolvedSpawnReceipt>(&receipt_json).unwrap(),
8313 receipt,
8314 );
8315 assert!(!receipt_json.contains("private prompt text"));
8316 assert!(!receipt_json.contains("profile prompt"));
8317
8318 let environment_profile = ResolvedEnvironmentProfileReceipt {
8319 profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8320 profile_revision: SpawnEnvironmentProfileRevision::new(
8321 "local-default.2026-08",
8322 )
8323 .unwrap(),
8324 network_allowlist: None,
8325 browser_profile_id: None,
8326 };
8327 let environment_receipt = first.receipt_with_materialization(
8328 NodeIncarnationId::from_bytes([9; NODE_INCARNATION_ID_BYTES]),
8329 receipt.session.clone(),
8330 Some(environment_profile.clone()),
8331 None,
8332 receipt.context.clone(),
8333 );
8334 let environment_json = serde_json::to_string(&environment_receipt).unwrap();
8335 assert!(environment_json.contains(
8336 r#""environment_profile_id":{"profile_id":"local-default","profile_revision":"local-default.2026-08"}"#,
8337 ));
8338 assert_eq!(
8339 serde_json::from_str::<ResolvedSpawnReceipt>(&environment_json)
8340 .unwrap()
8341 .environment_profile,
8342 Some(environment_profile),
8343 );
8344 assert!(serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8345 r#"{"profile_id":"local-default","profile_revision":"r1","value":"secret"}"#,
8346 )
8347 .is_err());
8348 assert!(SpawnEnvironmentProfileRevision::new(
8349 "r".repeat(MAX_SPAWN_ENVIRONMENT_PROFILE_REVISION_BYTES),
8350 )
8351 .is_ok());
8352 assert!(SpawnEnvironmentProfileRevision::new(
8353 "r".repeat(MAX_SPAWN_ENVIRONMENT_PROFILE_REVISION_BYTES + 1),
8354 )
8355 .is_err());
8356
8357 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
8358 .requires_child_environment_profile_capability());
8359 let mut explicit_environment = spec.clone();
8360 explicit_environment.overrides.environment_profile_id = SpawnOverride::Set {
8361 value: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8362 };
8363 assert!(NodeRequest::SpawnSpec {
8364 spec: explicit_environment,
8365 }
8366 .requires_child_environment_profile_capability());
8367 let mut inherited_environment = spec.clone();
8368 inherited_environment.overrides.environment_profile_id = SpawnOverride::Inherit;
8369 assert!(!NodeRequest::SpawnSpec {
8370 spec: inherited_environment,
8371 }
8372 .requires_child_environment_profile_capability());
8373
8374 let mut clear_required = spec.clone();
8375 clear_required.overrides.provider = SpawnOverride::Clear;
8376 assert!(matches!(
8377 clear_required.resolve(&defaults),
8378 Err(SpawnSpecResolveError::RequiredFieldCleared { field: "provider" }),
8379 ));
8380
8381 let mut stale_revision = spec.clone();
8382 stale_revision.expected_profile_revision =
8383 SpawnProfileRevision::new("review-default.r2").unwrap();
8384 assert!(matches!(
8385 stale_revision.resolve(&defaults),
8386 Err(SpawnSpecResolveError::ProfileRevisionMismatch {
8387 expected,
8388 loaded,
8389 }) if expected.as_str() == "review-default.r2"
8390 && loaded.as_str() == "review-default.r3",
8391 ));
8392
8393 let missing_revision_json = r#"{"target":{"node_id":"node-a","workspace_id":"primary"},"profile_id":"review-default","deadline_ms":1,"idempotency_key":"request-0002"}"#;
8394 assert!(serde_json::from_str::<SpawnSpec>(missing_revision_json).is_err());
8395 let minimal_json = r#"{"target":{"node_id":"node-a","workspace_id":"primary"},"profile_id":"review-default","expected_profile_revision":"review-default.r3","deadline_ms":1,"idempotency_key":"request-0002"}"#;
8396 let minimal = serde_json::from_str::<SpawnSpec>(minimal_json).unwrap();
8397 assert_eq!(minimal.overrides, SpawnOverrides::default());
8398 assert!(minimal.required_capabilities.is_empty());
8399 let mut unknown_field = serde_json::to_value(&minimal).unwrap();
8400 unknown_field["profile_revision"] = serde_json::json!("review-default.r3");
8401 assert!(serde_json::from_value::<SpawnSpec>(unknown_field).is_err());
8402 assert!(SpawnDeadlineMs::new(MAX_SPAWN_DEADLINE_MS + 1).is_err());
8403 assert!(SpawnProfileId::new("unsafe/profile").is_err());
8404 assert!(serde_json::from_str::<SpawnOverride<AgentId>>(
8405 r#"{"kind":"set","value":"claude","typo":true}"#,
8406 )
8407 .is_err());
8408 }
8409
8410 #[test]
8418 fn station_network_and_browser_profile_knobs_serde_ids_only() {
8419 let allowlist = SpawnNetworkAllowlistId::new("egress-default").unwrap();
8420 let browser = SpawnBrowserProfileId::new("station-profile-a").unwrap();
8421 assert_eq!(allowlist.as_str(), "egress-default");
8422 assert_eq!(browser.as_str(), "station-profile-a");
8423 assert!(SpawnNetworkAllowlistId::new("").is_err());
8424 assert!(SpawnBrowserProfileId::new("").is_err());
8425 assert!(SpawnNetworkAllowlistId::new(" ").is_err());
8426 assert!(SpawnBrowserProfileId::new("\t").is_err());
8427 assert!(SpawnNetworkAllowlistId::new("\n").is_err());
8428 assert!(SpawnBrowserProfileId::new("bad/id").is_err());
8429 assert!(SpawnNetworkAllowlistId::new(
8430 "x".repeat(MAX_SPAWN_RESOURCE_ID_BYTES + 1),
8431 )
8432 .is_err());
8433
8434 let overrides = SpawnOverrides {
8435 network_allowlist: Some(allowlist.clone()),
8436 browser_profile_id: Some(browser.clone()),
8437 ..SpawnOverrides::default()
8438 };
8439 let overrides_json = serde_json::to_string(&overrides).unwrap();
8440 assert_eq!(
8441 overrides_json,
8442 r#"{"provider":{"kind":"inherit"},"mode":{"kind":"inherit"},"terminal_size":{"kind":"inherit"},"prompt":{"kind":"inherit"},"bundle_id":{"kind":"inherit"},"context_id":{"kind":"inherit"},"environment_profile_id":{"kind":"inherit"},"network_allowlist":"egress-default","browser_profile_id":"station-profile-a"}"#,
8443 );
8444 assert!(!overrides_json.contains("cookie"));
8445 assert!(!overrides_json.contains("oauth"));
8446 assert!(!overrides_json.contains("password"));
8447 assert_eq!(
8448 serde_json::from_str::<SpawnOverrides>(&overrides_json).unwrap(),
8449 overrides,
8450 );
8451
8452 let omitted = serde_json::to_string(&SpawnOverrides::default()).unwrap();
8453 assert!(!omitted.contains("network_allowlist"));
8454 assert!(!omitted.contains("browser_profile_id"));
8455 assert_eq!(
8456 serde_json::from_str::<SpawnOverrides>(r#"{}"#).unwrap(),
8457 SpawnOverrides::default(),
8458 );
8459
8460 let mut unknown = serde_json::to_value(&overrides).unwrap();
8461 unknown["proxy_password"] = serde_json::json!("nope");
8462 assert!(serde_json::from_value::<SpawnOverrides>(unknown).is_err());
8463 let mut cookie_field = serde_json::to_value(&overrides).unwrap();
8464 cookie_field["cookie_bytes"] = serde_json::json!("deadbeef");
8465 assert!(serde_json::from_value::<SpawnOverrides>(cookie_field).is_err());
8466
8467 let receipt = ResolvedEnvironmentProfileReceipt {
8468 profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8469 profile_revision: SpawnEnvironmentProfileRevision::new("r1").unwrap(),
8470 network_allowlist: Some(allowlist),
8471 browser_profile_id: Some(browser),
8472 };
8473 let receipt_json = serde_json::to_string(&receipt).unwrap();
8474 assert_eq!(
8475 receipt_json,
8476 r#"{"profile_id":"local-default","profile_revision":"r1","network_allowlist":"egress-default","browser_profile_id":"station-profile-a"}"#,
8477 );
8478 assert_eq!(
8479 serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(&receipt_json).unwrap(),
8480 receipt,
8481 );
8482
8483 let bare = ResolvedEnvironmentProfileReceipt {
8484 profile_id: SpawnEnvironmentProfileId::new("local-default").unwrap(),
8485 profile_revision: SpawnEnvironmentProfileRevision::new("r1").unwrap(),
8486 network_allowlist: None,
8487 browser_profile_id: None,
8488 };
8489 assert_eq!(
8490 serde_json::to_string(&bare).unwrap(),
8491 r#"{"profile_id":"local-default","profile_revision":"r1"}"#,
8492 );
8493 assert_eq!(
8494 serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8495 r#"{"profile_id":"local-default","profile_revision":"r1"}"#,
8496 )
8497 .unwrap(),
8498 bare,
8499 );
8500 assert!(serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8501 r#"{"profile_id":"local-default","profile_revision":"r1","cookie":"x"}"#,
8502 )
8503 .is_err());
8504 assert!(serde_json::from_str::<ResolvedEnvironmentProfileReceipt>(
8505 r#"{"profile_id":"local-default","profile_revision":"r1","oauth_token":"x"}"#,
8506 )
8507 .is_err());
8508 assert!(serde_json::from_str::<SpawnOverrides>(
8510 r#"{"network_allowlist":""}"#,
8511 )
8512 .is_err());
8513 assert!(serde_json::from_str::<SpawnOverrides>(
8514 r#"{"browser_profile_id":" "}"#,
8515 )
8516 .is_err());
8517 }
8518
8519 #[test]
8524 fn spawn_spec_resolve_echoes_station_network_and_browser_profile_knobs() {
8525 let profile_id = SpawnProfileId::new("review-default").unwrap();
8526 let defaults = SpawnProfileDefaults {
8527 profile_id: profile_id.clone(),
8528 revision: SpawnProfileRevision::new("review-default.r3").unwrap(),
8529 provider: agent("claude"),
8530 mode: SessionMode::Pty,
8531 terminal_size: TerminalSize {
8532 rows: 24,
8533 columns: 80,
8534 },
8535 prompt: None,
8536 bundle_id: None,
8537 context_id: None,
8538 environment_profile_id: Some(
8539 SpawnEnvironmentProfileId::new("local-default").unwrap(),
8540 ),
8541 };
8542 let allowlist = SpawnNetworkAllowlistId::new("egress-default").unwrap();
8543 let browser = SpawnBrowserProfileId::new("station-profile-a").unwrap();
8544 let spec = SpawnSpec {
8545 target: SpawnTarget {
8546 node_id: NodeId::new("node-a").unwrap(),
8547 workspace_id: WorkspaceId::new("primary").unwrap(),
8548 worktree_id: None,
8549 },
8550 profile_id,
8551 expected_profile_revision: defaults.revision.clone(),
8552 overrides: SpawnOverrides {
8553 network_allowlist: Some(allowlist.clone()),
8554 browser_profile_id: Some(browser.clone()),
8555 ..SpawnOverrides::default()
8556 },
8557 deadline_ms: SpawnDeadlineMs::new(30_000).unwrap(),
8558 idempotency_key: SpawnIdempotencyKey::new("request-station-knobs").unwrap(),
8559 required_capabilities: SpawnRequiredCapabilities::default(),
8560 };
8561 let resolved = spec.resolve(&defaults).unwrap();
8562 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
8563 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
8564 assert_eq!(
8565 resolved
8566 .environment_profile_id
8567 .as_ref()
8568 .map(SpawnEnvironmentProfileId::as_str),
8569 Some("local-default"),
8570 );
8571
8572 let omitted = SpawnSpec {
8573 overrides: SpawnOverrides::default(),
8574 idempotency_key: SpawnIdempotencyKey::new("request-station-knobs-omit").unwrap(),
8575 ..spec.clone()
8576 };
8577 let resolved_omit = omitted.resolve(&defaults).unwrap();
8578 assert_eq!(resolved_omit.network_allowlist, None);
8579 assert_eq!(resolved_omit.browser_profile_id, None);
8580 }
8581
8582 #[test]
8583 fn session_bundle_materialization_contract_is_bounded_exact_and_dual_gated() {
8584 assert_eq!(NODE_STATE_SCHEMA_V7, 7);
8585 assert_eq!(NODE_STATE_SCHEMA_V8, 8);
8586 assert_eq!(
8587 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
8588 "session-bundle-materialization-v1",
8589 );
8590 let capability =
8591 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
8592 assert!(production_node_client_compatibility_offer()
8593 .capabilities
8594 .contains(&capability));
8595 let mut support = portable_node_support();
8596 support.capabilities = vec![capability.clone()];
8597 let offer = ClientCompatibilityOffer {
8598 build_stamp: BUILD_STAMP.to_owned(),
8599 capabilities: vec![capability.clone()],
8600 state_schema: None,
8601 };
8602 let selected = support
8603 .negotiate(&offer)
8604 .unwrap();
8605 assert_eq!(selected.capabilities, vec![capability]);
8606 let bound = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8607 assert!(bound
8608 .windows(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.len())
8609 .any(|window| {
8610 window == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY.as_bytes()
8611 }));
8612 assert!(SpawnBundleRevision::new(
8613 "r".repeat(MAX_SPAWN_BUNDLE_REVISION_BYTES),
8614 )
8615 .is_ok());
8616 assert!(SpawnBundleRevision::new(
8617 "r".repeat(MAX_SPAWN_BUNDLE_REVISION_BYTES + 1),
8618 )
8619 .is_err());
8620
8621 let digest = format!("sha256:{}", "a".repeat(64));
8622 let receipt = ResolvedBundleReceipt {
8623 id: SpawnBundleId::new("review-bundle").unwrap(),
8624 revision: SpawnBundleRevision::new("review-bundle.r1").unwrap(),
8625 digest: SpawnBundleDigest::new(&digest).unwrap(),
8626 };
8627 assert_eq!(
8628 serde_json::to_string(&receipt).unwrap(),
8629 format!(
8630 r#"{{"id":"review-bundle","revision":"review-bundle.r1","digest":"{digest}"}}"#,
8631 ),
8632 );
8633 for invalid in [
8634 format!("sha256:{}", "a".repeat(63)),
8635 format!("sha256:{}", "A".repeat(64)),
8636 "sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
8637 .to_owned(),
8638 ] {
8639 assert!(SpawnBundleDigest::new(invalid).is_err());
8640 }
8641
8642 let minimal_json = r#"{"target":{"node_id":"node-a","workspace_id":"primary"},"profile_id":"review-default","expected_profile_revision":"review-default.r3","deadline_ms":1,"idempotency_key":"request-bundle"}"#;
8643 let inherited = serde_json::from_str::<SpawnSpec>(minimal_json).unwrap();
8644 assert!(NodeRequest::SpawnSpec {
8645 spec: inherited.clone(),
8646 }
8647 .requires_session_bundle_materialization_capability());
8648
8649 let mut explicit = inherited.clone();
8650 explicit.overrides.bundle_id = SpawnOverride::Set {
8651 value: SpawnBundleId::new("review-bundle").unwrap(),
8652 };
8653 assert!(NodeRequest::SpawnSpec { spec: explicit }
8654 .requires_session_bundle_materialization_capability());
8655
8656 let mut cleared = inherited;
8657 cleared.overrides.bundle_id = SpawnOverride::Clear;
8658 assert!(!NodeRequest::SpawnSpec { spec: cleared }
8659 .requires_session_bundle_materialization_capability());
8660 }
8661
8662 #[test]
8663 fn provider_ids_preserve_legacy_json_and_accept_open_values() {
8664 for (provider, expected) in [
8665 (agent("claude"), r#""claude""#),
8666 (agent("codex"), r#""codex""#),
8667 (agent("kimi"), r#""kimi""#),
8668 ] {
8669 assert!(provider_id_is_legacy(&provider));
8670 assert_eq!(serde_json::to_string(&provider).unwrap(), expected);
8671 assert_eq!(serde_json::from_str::<AgentId>(expected).unwrap(), provider);
8672 }
8673
8674 let open = agent("third-party-agent");
8675 assert!(!provider_id_is_legacy(&open));
8676 assert_eq!(serde_json::to_string(&open).unwrap(), r#""third-party-agent""#);
8677 }
8678
8679 #[test]
8680 fn provider_runtime_wire_is_exact_bounded_and_consistent() {
8681 let statuses = ProviderRuntimeStatuses::new([
8682 ProviderRuntimeStatus::raw_passthrough(
8683 agent("claude"),
8684 Some(ProviderRuntimeVersion::new("2.1.220").unwrap()),
8685 ),
8686 ProviderRuntimeStatus::verified_semantic(
8687 agent("codex"),
8688 ProviderRuntimeVersion::new("0.147.0").unwrap(),
8689 ProviderRuntimeContractId::new("codex.windows-x86_64.0.147.0").unwrap(),
8690 ),
8691 ProviderRuntimeStatus::unavailable(agent("kimi")),
8692 ])
8693 .unwrap();
8694 let encoded = serde_json::to_string(&statuses).unwrap();
8695 assert_eq!(
8696 encoded,
8697 r#"[{"provider":"claude","mode":"raw-passthrough","version":"2.1.220"},{"provider":"codex","mode":"verified-semantic","version":"0.147.0","contract_id":"codex.windows-x86_64.0.147.0"},{"provider":"kimi","mode":"unavailable"}]"#,
8698 );
8699 assert_eq!(
8700 serde_json::from_str::<ProviderRuntimeStatuses>(&encoded).unwrap(),
8701 statuses,
8702 );
8703
8704 let duplicate = r#"[{"provider":"codex","mode":"unavailable"},{"provider":"codex","mode":"unavailable"}]"#;
8705 assert!(serde_json::from_str::<ProviderRuntimeStatuses>(duplicate).is_err());
8706 let too_many = (0..=MAX_PROVIDER_RUNTIME_STATUSES)
8707 .map(|index| ProviderRuntimeStatus::unavailable(agent(&format!("provider-{index}"))))
8708 .collect::<Vec<_>>();
8709 assert!(matches!(
8710 ProviderRuntimeStatuses::new(too_many),
8711 Err(ProviderRuntimeStatusError::TooMany {
8712 max: MAX_PROVIDER_RUNTIME_STATUSES,
8713 }),
8714 ));
8715 let inconsistent = r#"[{"provider":"codex","mode":"verified-semantic","version":"0.147.0"}]"#;
8716 assert!(serde_json::from_str::<ProviderRuntimeStatuses>(inconsistent).is_err());
8717 for arbitrary in ["secret-token", "raw.stdout", "1.2", "01.2.3", "1.2.3.4"] {
8718 assert!(ProviderRuntimeVersion::new(arbitrary).is_err(), "accepted {arbitrary}");
8719 }
8720 let overflow = format!(
8721 r#"[{{"provider":"codex","mode":"raw-passthrough","version":"{}"}}]"#,
8722 "1".repeat(MAX_PROVIDER_RUNTIME_VERSION_BYTES + 1),
8723 );
8724 assert!(serde_json::from_str::<ProviderRuntimeStatuses>(&overflow).is_err());
8725 }
8726
8727 #[test]
8728 fn legacy_node_snapshot_defaults_runtime_status_to_unreported() {
8729 let legacy = r#"{"node_id":"legacy-node","enabled_providers":["codex"],"workspaces":[]}"#;
8730 let snapshot = serde_json::from_str::<NodeSnapshot>(legacy).unwrap();
8731 assert!(snapshot.provider_runtime_statuses.is_empty());
8732 let reencoded = serde_json::to_string(&snapshot).unwrap();
8733 assert!(!reencoded.contains("provider_runtime"));
8734 }
8735
8736 #[test]
8737 fn compatibility_negotiation_keeps_the_build_stamp_and_selects_highest_state_schema() {
8738 let offer = ClientCompatibilityOffer {
8739 build_stamp: BUILD_STAMP.to_owned(),
8740 capabilities: vec![
8741 CapabilityId::new("session.spawn").unwrap(),
8742 CapabilityId::new("unknown.future").unwrap(),
8743 ],
8744 state_schema: Some(StateSchemaSupport {
8745 versions: ProtocolRange::new(4, 6).unwrap(),
8746 }),
8747 };
8748 let hello = ClientHello::negotiating(
8749 ClientRole::Operator,
8750 [1; NODE_AUTH_NONCE_BYTES],
8751 offer.clone(),
8752 );
8753 assert_eq!(hello.compatibility, Some(offer.clone()));
8754
8755 let support = portable_node_support();
8756 let negotiated = support.negotiate(&offer).unwrap();
8757 assert_eq!(negotiated.build_stamp, BUILD_STAMP);
8758 assert_eq!(negotiated.state_schema_version, Some(5));
8759 assert_eq!(
8760 negotiated.capabilities,
8761 vec![CapabilityId::new("session.spawn").unwrap()],
8762 );
8763 assert!(negotiated.provider_contracts.is_empty());
8764 assert!(negotiated.provider_adapter_contracts.is_empty());
8765 }
8766
8767 #[test]
8768 fn negotiate_rejects_a_foreign_build_stamp_naming_both_values() {
8769 let foreign_stamp = "f".repeat(40);
8770 let offer = ClientCompatibilityOffer {
8771 build_stamp: foreign_stamp.clone(),
8772 capabilities: Vec::new(),
8773 state_schema: None,
8774 };
8775 let error = portable_node_support().negotiate(&offer).unwrap_err();
8776 assert!(matches!(
8777 &error,
8778 ProtocolNegotiationError::BuildStampMismatch { local, remote }
8779 if local == BUILD_STAMP && remote == &foreign_stamp,
8780 ));
8781 assert_eq!(
8782 error.to_string(),
8783 format!("build stamp mismatch: local={BUILD_STAMP} remote={foreign_stamp}"),
8784 );
8785 }
8786
8787 #[test]
8788 fn compatibility_auth_binding_has_an_exact_bounded_encoding() {
8789 let offer = ClientCompatibilityOffer {
8790 build_stamp: BUILD_STAMP.to_owned(),
8791 capabilities: vec![CapabilityId::new("session.spawn").unwrap()],
8792 state_schema: Some(StateSchemaSupport {
8793 versions: ProtocolRange::new(4, 6).unwrap(),
8794 }),
8795 };
8796 let support = portable_node_support();
8797 let selected = support
8798 .negotiate(&offer)
8799 .unwrap();
8800 let encoded = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8801 assert_eq!(
8802 String::from_utf8(encoded).unwrap(),
8803 format!(
8804 r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["session.spawn"],"state_schema":{{"versions":{{"minimum":4,"maximum":6}}}}}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["session.spawn"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","state_schema_version":5,"provider_contracts":[]}}}}"#,
8805 ),
8806 );
8807 }
8808
8809 #[test]
8810 fn provider_contract_manifest_is_capability_gated_and_auth_bound_exactly() {
8811 let manifest_capability =
8812 CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap();
8813 let mut support = portable_node_support();
8814 support.capabilities.push(manifest_capability.clone());
8815 let offer = ClientCompatibilityOffer {
8816 build_stamp: BUILD_STAMP.to_owned(),
8817 capabilities: vec![manifest_capability.clone()],
8818 state_schema: None,
8819 };
8820 let selected = support
8821 .negotiate(&offer)
8822 .unwrap();
8823 assert_eq!(selected.capabilities, vec![manifest_capability]);
8824 assert_eq!(selected.provider_contracts, support.provider_contracts);
8825 assert_eq!(
8826 selected.provider_adapter_contracts,
8827 support.provider_adapter_contracts,
8828 );
8829 let encoded = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8830 assert_eq!(
8831 String::from_utf8(encoded).unwrap(),
8832 format!(
8833 r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["provider-contract-manifest-v1"]}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["provider-contract-manifest-v1"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","provider_contracts":[{{"provider":"codex","revision":"codex.2026-08"}}],"provider_adapter_contracts":[{{"provider":"codex","family":"pty-semantic","adapter_id":"codex-cli","revision":"pty-semantic-v1"}}]}}}}"#,
8834 ),
8835 );
8836 }
8837
8838 #[test]
8839 fn open_provider_capability_and_manifest_are_auth_bound_exactly() {
8840 let manifest_capability =
8841 CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap();
8842 let open_capability = CapabilityId::new(NODE_PROVIDER_ID_OPEN_CAPABILITY).unwrap();
8843 let mut support = portable_node_support();
8844 support.capabilities = vec![manifest_capability.clone(), open_capability.clone()];
8845 support.provider_contracts = vec![ProviderContractSupport {
8846 provider: agent("third-party-agent"),
8847 revision: ProviderContractRevision::new("third-party.2026-08").unwrap(),
8848 }];
8849 support.provider_adapter_contracts.clear();
8850 let offer = ClientCompatibilityOffer {
8851 build_stamp: BUILD_STAMP.to_owned(),
8852 capabilities: vec![manifest_capability, open_capability],
8853 state_schema: None,
8854 };
8855 let selected = support
8856 .negotiate(&offer)
8857 .unwrap();
8858 let encoded = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
8859 assert_eq!(
8860 String::from_utf8(encoded).unwrap(),
8861 format!(
8862 r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["provider-contract-manifest-v1","provider-id.open-v1"]}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["provider-contract-manifest-v1","provider-id.open-v1"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","provider_contracts":[{{"provider":"third-party-agent","revision":"third-party.2026-08"}}]}}}}"#,
8863 ),
8864 );
8865 }
8866
8867 #[test]
8868 fn n_minus_one_selected_json_round_trips_without_manifest_fields() {
8869 let json = r#"{"build_stamp":"n-minus-one-stamp","capabilities":["compatibility.metadata"],"host":{"operating_system":"windows","architecture":"x86_64"},"path_semantics":{"style":"windows","encoding":"utf8"},"local_transport":"windows-named-pipe","state_schema_version":1,"provider_contracts":[]}"#;
8870 let selected: NegotiatedNodeCompatibility = serde_json::from_str(json).unwrap();
8871 assert!(selected.provider_contracts.is_empty());
8872 assert!(selected.provider_adapter_contracts.is_empty());
8873 assert_eq!(serde_json::to_string(&selected).unwrap(), json);
8874 }
8875
8876 #[test]
8877 fn provider_contract_manifest_rejects_bounds_duplicates_and_unlinked_entries() {
8878 let provider = ProviderContractSupport {
8879 provider: agent("codex"),
8880 revision: ProviderContractRevision::new("codex.2026-08").unwrap(),
8881 };
8882 let adapter = ProviderAdapterContractSupport {
8883 provider: agent("codex"),
8884 family: AdapterFamily::PtySemantic,
8885 adapter_id: AdapterId::new("codex-cli").unwrap(),
8886 revision: AdapterContractRevision::new("pty-semantic-v1").unwrap(),
8887 };
8888 assert!(matches!(
8889 validate_provider_contract_manifest(
8890 &vec![provider.clone(); MAX_PROVIDER_CONTRACTS + 1],
8891 &[],
8892 ),
8893 Err(ProviderContractManifestError::TooManyProviders { .. }),
8894 ));
8895 assert!(matches!(
8896 validate_provider_contract_manifest(
8897 &[provider.clone()],
8898 &vec![adapter.clone(); MAX_PROVIDER_ADAPTER_CONTRACTS + 1],
8899 ),
8900 Err(ProviderContractManifestError::TooManyAdapterContracts { .. }),
8901 ));
8902 assert!(matches!(
8903 validate_provider_contract_manifest(
8904 &[provider.clone()],
8905 &vec![adapter.clone(); MAX_PROVIDER_ADAPTER_CONTRACTS],
8906 ),
8907 Err(ProviderContractManifestError::DuplicateProviderFamily { .. }),
8908 ));
8909 assert!(matches!(
8910 validate_provider_contract_manifest(&[provider.clone(), provider.clone()], &[]),
8911 Err(ProviderContractManifestError::DuplicateProvider { provider })
8912 if provider == agent("codex"),
8913 ));
8914 let mut unlinked = adapter.clone();
8915 unlinked.provider = agent("claude");
8916 assert!(matches!(
8917 validate_provider_contract_manifest(&[provider.clone()], &[unlinked]),
8918 Err(ProviderContractManifestError::UnlinkedAdapterProvider { provider })
8919 if provider == agent("claude"),
8920 ));
8921 let mut duplicate_family = adapter.clone();
8922 duplicate_family.adapter_id = AdapterId::new("codex-cli-next").unwrap();
8923 assert!(matches!(
8924 validate_provider_contract_manifest(
8925 &[provider.clone()],
8926 &[adapter.clone(), duplicate_family],
8927 ),
8928 Err(ProviderContractManifestError::DuplicateProviderFamily {
8929 provider,
8930 family: AdapterFamily::PtySemantic,
8931 }) if provider == agent("codex"),
8932 ));
8933 let mut shared_adapter_id = adapter.clone();
8934 shared_adapter_id.family = AdapterFamily::History;
8935 assert!(validate_provider_contract_manifest(
8936 &[provider],
8937 &[adapter, shared_adapter_id],
8938 )
8939 .is_ok());
8940 }
8941
8942 #[test]
8943 fn provider_contract_manifest_negotiates_all_current_provider_family_tuples() {
8944 let providers = [
8945 (agent("claude"), "claude-code", "claude.2026-08"),
8946 (agent("codex"), "codex-cli", "codex.2026-08"),
8947 (agent("kimi"), "kimi-cli", "kimi.2026-08"),
8948 ];
8949 let families = [
8950 AdapterFamily::PtySemantic,
8951 AdapterFamily::Pipe,
8952 AdapterFamily::OneShot,
8953 AdapterFamily::Acp,
8954 AdapterFamily::Hook,
8955 AdapterFamily::ManagedHook,
8956 AdapterFamily::History,
8957 AdapterFamily::Resume,
8958 AdapterFamily::SessionOptions,
8959 AdapterFamily::CapabilityProbe,
8960 ];
8961 let provider_contracts = providers
8962 .iter()
8963 .map(|(provider, _, revision)| ProviderContractSupport {
8964 provider: provider.clone(),
8965 revision: ProviderContractRevision::new(*revision).unwrap(),
8966 })
8967 .collect::<Vec<_>>();
8968 let provider_adapter_contracts = providers
8969 .iter()
8970 .flat_map(|(provider, adapter_id, _)| {
8971 families
8972 .iter()
8973 .map(move |family| ProviderAdapterContractSupport {
8974 provider: provider.clone(),
8975 family: *family,
8976 adapter_id: AdapterId::new(*adapter_id).unwrap(),
8977 revision: AdapterContractRevision::new("adapter-contract-v1").unwrap(),
8978 })
8979 })
8980 .collect::<Vec<_>>();
8981 assert_eq!(provider_contracts.len(), 3);
8982 assert_eq!(provider_adapter_contracts.len(), 30);
8983 assert!(provider_adapter_contracts.len() <= MAX_PROVIDER_ADAPTER_CONTRACTS);
8984
8985 let manifest_capability =
8986 CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap();
8987 let mut support = portable_node_support();
8988 support.capabilities.push(manifest_capability.clone());
8989 support.provider_contracts = provider_contracts;
8990 support.provider_adapter_contracts = provider_adapter_contracts;
8991 let selected = support
8992 .negotiate(&ClientCompatibilityOffer {
8993 build_stamp: BUILD_STAMP.to_owned(),
8994 capabilities: vec![manifest_capability],
8995 state_schema: None,
8996 })
8997 .unwrap();
8998 assert_eq!(selected.provider_contracts.len(), 3);
8999 assert_eq!(selected.provider_adapter_contracts.len(), 30);
9000 }
9001
9002 #[test]
9003 fn sixteen_provider_manifest_headroom_fits_the_authenticated_handshake() {
9004 assert_eq!(MAX_PROVIDER_IDENTITIES, 16);
9005 let providers = (0..MAX_PROVIDER_IDENTITIES)
9006 .map(|index| agent(&format!("provider-{index}")))
9007 .collect::<Vec<_>>();
9008 let mut support = portable_node_support();
9009 support.capabilities.extend([
9010 CapabilityId::new(NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY).unwrap(),
9011 CapabilityId::new(NODE_PROVIDER_ID_OPEN_CAPABILITY).unwrap(),
9012 ]);
9013 support.provider_contracts = providers
9014 .iter()
9015 .map(|provider| ProviderContractSupport {
9016 provider: provider.clone(),
9017 revision: ProviderContractRevision::new(format!(
9018 "{}.2026-08",
9019 provider.as_str(),
9020 ))
9021 .unwrap(),
9022 })
9023 .collect();
9024 support.provider_adapter_contracts = providers
9025 .iter()
9026 .map(|provider| ProviderAdapterContractSupport {
9027 provider: provider.clone(),
9028 family: AdapterFamily::PtySemantic,
9029 adapter_id: AdapterId::new(format!("{}-cli", provider.as_str())).unwrap(),
9030 revision: AdapterContractRevision::new("pty-semantic-v1").unwrap(),
9031 })
9032 .collect();
9033
9034 validate_node_negotiated_handshake_capacity(&support).unwrap();
9035 }
9036
9037 #[test]
9038 fn adapter_contract_revision_is_bounded_printable_ascii() {
9039 assert!(AdapterContractRevision::new("history-jsonl-v1").is_ok());
9040 assert!(AdapterContractRevision::new("").is_err());
9041 assert!(AdapterContractRevision::new("revision with spaces").is_err());
9042 assert!(AdapterContractRevision::new(
9043 "x".repeat(MAX_ADAPTER_CONTRACT_REVISION_BYTES + 1),
9044 )
9045 .is_err());
9046 }
9047
9048 #[test]
9049 fn protocol_ranges_reject_invalid_and_disjoint_inputs() {
9050 assert!(matches!(
9051 ProtocolRange::new(0, 8),
9052 Err(ProtocolNegotiationError::InvalidRange { minimum: 0, maximum: 8 }),
9053 ));
9054 assert!(matches!(
9055 ProtocolRange::new(9, 8),
9056 Err(ProtocolNegotiationError::InvalidRange { minimum: 9, maximum: 8 }),
9057 ));
9058 let error = ProtocolRange::new(7, 8)
9059 .unwrap()
9060 .highest_common(ProtocolRange::new(9, 10).unwrap())
9061 .unwrap_err();
9062 assert!(matches!(error, ProtocolNegotiationError::Disjoint { .. }));
9063 assert!(serde_json::from_str::<ProtocolRange>(
9064 r#"{"minimum":10,"maximum":9}"#,
9065 )
9066 .is_err());
9067 assert!(ProviderContractRevision::new(
9068 "gate4agent-inline/codex-cli-0.144/v1",
9069 )
9070 .is_ok());
9071 assert!(ProviderContractRevision::new(
9072 "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42",
9073 )
9074 .is_ok());
9075 }
9076
9077 #[test]
9078 fn foreign_path_metadata_round_trips_without_normalization() {
9079 #[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
9080 struct ForeignPath {
9081 raw: String,
9082 semantics: PathSemantics,
9083 }
9084
9085 let foreign = ForeignPath {
9086 raw: r"C:\Users\operator\repo\src\lib.rs".to_owned(),
9087 semantics: portable_node_support().path_semantics,
9088 };
9089 let json = serde_json::to_string(&foreign).unwrap();
9090 assert!(json.contains(r#""raw":"C:\\Users\\operator\\repo\\src\\lib.rs""#));
9091 assert_eq!(serde_json::from_str::<ForeignPath>(&json).unwrap(), foreign);
9092 }
9093
9094 #[test]
9095 fn opaque_host_path_utf8_preserves_legacy_json_string_shape() {
9096 let path = host_path(r"C:\Users\operator\repo");
9097 assert_eq!(path.byte_len(), 22);
9098 assert_eq!(path.as_utf8(), Some(r"C:\Users\operator\repo"));
9099 assert_eq!(path.as_unix_bytes(), None);
9100 assert_eq!(path.display_text(), r"C:\Users\operator\repo");
9101
9102 let json = serde_json::to_string(&path).unwrap();
9103 assert_eq!(json, r#""C:\\Users\\operator\\repo""#);
9104 assert_eq!(serde_json::from_str::<OpaqueHostPath>(&json).unwrap(), path);
9105 }
9106
9107 #[test]
9108 fn opaque_host_path_unix_bytes_has_strict_bounded_tagged_wire_shape() {
9109 let raw = vec![b'/', b'r', b'e', b'p', b'o', b'/', 0xff];
9110 let path = OpaqueHostPath::unix_bytes(raw.clone()).unwrap();
9111 assert_eq!(path.byte_len(), raw.len());
9112 assert_eq!(path.as_utf8(), None);
9113 assert_eq!(path.as_unix_bytes(), Some(raw.as_slice()));
9114
9115 let json = serde_json::to_string(&path).unwrap();
9116 assert_eq!(
9117 json,
9118 r#"{"kind":"unix-bytes","bytes":[47,114,101,112,111,47,255]}"#,
9119 );
9120 assert_eq!(serde_json::from_str::<OpaqueHostPath>(&json).unwrap(), path);
9121
9122 for invalid in [
9123 r#"{"kind":"future","bytes":[47]}"#,
9124 r#"{"kind":"unix-bytes","bytes":[47],"extra":true}"#,
9125 r#"{"kind":"unix-bytes"}"#,
9126 r#"{"bytes":[47]}"#,
9127 r#"{"kind":"unix-bytes","bytes":[]}"#,
9128 r#"{"kind":"unix-bytes","bytes":[47,0]}"#,
9129 ] {
9130 assert!(serde_json::from_str::<OpaqueHostPath>(invalid).is_err(), "{invalid}");
9131 }
9132 assert!(OpaqueHostPath::utf8(String::new()).is_err());
9133 assert!(OpaqueHostPath::utf8("a\0b".to_owned()).is_err());
9134 assert!(OpaqueHostPath::utf8("x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1)).is_err());
9135 assert!(OpaqueHostPath::unix_bytes(vec![b'x'; MAX_WORKSPACE_ROOT_BYTES + 1]).is_err());
9136 }
9137
9138 #[test]
9139 fn repository_path_utf8_preserves_legacy_json_string_shape_and_typed_components() {
9140 let path = repository_path(r"src\literal-name/lib.rs");
9141 assert_eq!(path.byte_len(), 23);
9142 assert_eq!(path.as_utf8(), Some(r"src\literal-name/lib.rs"));
9143 assert_eq!(path.as_unix_bytes(), None);
9144 assert_eq!(path.display_text(), r"src\literal-name/lib.rs");
9145 assert_eq!(path.component_count(), 2);
9146 assert_eq!(path.depth(), 1);
9147 assert_eq!(path.file_name_bytes(), b"lib.rs");
9148 assert_eq!(path.file_name_display_text(), "lib.rs");
9149 assert!(path.is_descendant_of(&repository_path(r"src\literal-name")));
9150 assert!(!path.is_descendant_of(&repository_path("src")));
9151
9152 let json = serde_json::to_string(&path).unwrap();
9153 assert_eq!(json, r#""src\\literal-name/lib.rs""#);
9154 assert_eq!(serde_json::from_str::<RepositoryPath>(&json).unwrap(), path);
9155 }
9156
9157 #[test]
9158 fn repository_path_unix_bytes_has_strict_bounded_tagged_wire_shape() {
9159 let raw = vec![b's', b'r', b'c', b'/', 0xff, b'/', b'f'];
9160 let path = RepositoryPath::unix_bytes(raw.clone()).unwrap();
9161 assert_eq!(path.byte_len(), raw.len());
9162 assert_eq!(path.as_bytes(), raw);
9163 assert_eq!(path.as_utf8(), None);
9164 assert_eq!(path.as_unix_bytes(), Some(raw.as_slice()));
9165 assert_eq!(path.component_count(), 3);
9166 assert_eq!(path.depth(), 2);
9167 assert_eq!(path.file_name_bytes(), b"f");
9168 assert!(path.is_descendant_of(
9169 &RepositoryPath::unix_bytes(vec![b's', b'r', b'c', b'/', 0xff]).unwrap(),
9170 ));
9171
9172 let json = serde_json::to_string(&path).unwrap();
9173 assert_eq!(
9174 json,
9175 r#"{"kind":"unix-bytes","bytes":[115,114,99,47,255,47,102]}"#,
9176 );
9177 assert_eq!(serde_json::from_str::<RepositoryPath>(&json).unwrap(), path);
9178 }
9179
9180 #[test]
9181 fn repository_path_physical_identity_ignores_wire_representation() {
9182 use std::collections::{BTreeSet, HashSet};
9183
9184 let utf8 = repository_path("src/main.rs");
9185 let tagged = RepositoryPath::unix_bytes(b"src/main.rs".to_vec()).unwrap();
9186 assert_eq!(utf8, tagged);
9187 assert_eq!(utf8.cmp(&tagged), Ordering::Equal);
9188 assert_eq!(utf8.as_unix_bytes(), None);
9189 assert_eq!(tagged.as_unix_bytes(), Some(b"src/main.rs".as_slice()));
9190 assert_eq!(serde_json::to_string(&utf8).unwrap(), r#""src/main.rs""#);
9191 assert_eq!(
9192 serde_json::to_string(&tagged).unwrap(),
9193 r#"{"kind":"unix-bytes","bytes":[115,114,99,47,109,97,105,110,46,114,115]}"#,
9194 );
9195
9196 let mut ordered = BTreeSet::new();
9197 ordered.insert(utf8.clone());
9198 ordered.insert(tagged.clone());
9199 assert_eq!(ordered.len(), 1);
9200
9201 let mut hashed = HashSet::new();
9202 hashed.insert(utf8);
9203 hashed.insert(tagged);
9204 assert_eq!(hashed.len(), 1);
9205
9206 let backslash = repository_path(r"src\main.rs");
9207 let slash = repository_path("src/main.rs");
9208 assert_ne!(backslash, slash);
9209 assert_ne!(backslash.cmp(&slash), Ordering::Equal);
9210 }
9211
9212 #[test]
9213 fn repository_path_rejects_non_canonical_or_unbounded_components() {
9214 for invalid in [
9215 "",
9216 "/",
9217 "/src",
9218 "src/",
9219 "src//lib.rs",
9220 ".",
9221 "..",
9222 "./src",
9223 "src/.",
9224 "../src",
9225 "src/..",
9226 "src\0lib.rs",
9227 ] {
9228 assert!(RepositoryPath::utf8(invalid.to_owned()).is_err(), "{invalid:?}");
9229 }
9230 assert!(RepositoryPath::utf8("x".repeat(MAX_REPOSITORY_PATH_BYTES + 1)).is_err());
9231 assert!(RepositoryPath::unix_bytes(vec![b'x'; MAX_REPOSITORY_PATH_BYTES + 1]).is_err());
9232
9233 for invalid in [
9234 r#"{"kind":"future","bytes":[115]}"#,
9235 r#"{"kind":"unix-bytes","bytes":[115],"extra":true}"#,
9236 r#"{"kind":"unix-bytes"}"#,
9237 r#"{"bytes":[115]}"#,
9238 r#"{"kind":"unix-bytes","bytes":[]}"#,
9239 r#"{"kind":"unix-bytes","bytes":[47,115]}"#,
9240 r#"{"kind":"unix-bytes","bytes":[115,47,46,46]}"#,
9241 ] {
9242 assert!(serde_json::from_str::<RepositoryPath>(invalid).is_err(), "{invalid}");
9243 }
9244 }
9245
9246 #[test]
9247 fn repository_path_fields_preserve_legacy_utf8_wire_bytes_and_default_rename_source() {
9248 let entry = WorkspaceEntry {
9249 relative_path: repository_path("src/lib.rs"),
9250 kind: WorkspaceEntryKind::File,
9251 };
9252 assert_eq!(
9253 serde_json::to_string(&entry).unwrap(),
9254 r#"{"relative_path":"src/lib.rs","kind":"file"}"#,
9255 );
9256
9257 let legacy_status =
9258 r#"{"index_status":"R","worktree_status":" ","path":"src/new.rs"}"#;
9259 let status = serde_json::from_str::<GitStatusEntry>(legacy_status).unwrap();
9260 assert_eq!(status.path, repository_path("src/new.rs"));
9261 assert_eq!(status.previous_path, None);
9262 assert_eq!(serde_json::to_string(&status).unwrap(), legacy_status);
9263
9264 let renamed = GitStatusEntry {
9265 previous_path: Some(repository_path("src/old.rs")),
9266 ..status
9267 };
9268 assert_eq!(
9269 serde_json::to_string(&renamed).unwrap(),
9270 r#"{"index_status":"R","worktree_status":" ","path":"src/new.rs","previous_path":"src/old.rs"}"#,
9271 );
9272 }
9273
9274 #[tokio::test]
9275 async fn json_frame_round_trips_a_client_hello_without_the_access_token() {
9276 let expected = ClientFrame::Hello(ClientHello::new(ClientRole::Operator, [7; NODE_AUTH_NONCE_BYTES]));
9277 let mut wire = Vec::new();
9278 write_json_frame(&mut wire, &expected).await.unwrap();
9279
9280 assert!(!String::from_utf8_lossy(&wire).contains("local-token"));
9281
9282 let actual: ClientFrame = read_json_frame(&mut wire.as_slice()).await.unwrap();
9283 assert_eq!(actual, expected);
9284 }
9285
9286 #[tokio::test]
9287 async fn reader_rejects_zero_length_before_allocating() {
9288 let bytes = 0_u32.to_le_bytes();
9289 let mut wire = bytes.as_slice();
9290 let error = read_json_frame::<_, ClientFrame>(&mut wire).await.unwrap_err();
9291 assert!(matches!(
9292 error,
9293 FrameError::InvalidLength { length: 0, max: MAX_NODE_FRAME_BYTES }
9294 ));
9295 }
9296
9297 #[tokio::test]
9298 async fn hello_reader_rejects_an_oversized_frame_before_allocating() {
9299 let declared = (MAX_NODE_HELLO_FRAME_BYTES + 1) as u32;
9300 let bytes = declared.to_le_bytes();
9301 let mut wire = bytes.as_slice();
9302 let error = read_json_frame_limited::<_, ClientFrame>(
9303 &mut wire,
9304 MAX_NODE_HELLO_FRAME_BYTES,
9305 )
9306 .await
9307 .unwrap_err();
9308 assert!(matches!(
9309 error,
9310 FrameError::InvalidLength {
9311 length,
9312 max: MAX_NODE_HELLO_FRAME_BYTES
9313 } if length == MAX_NODE_HELLO_FRAME_BYTES + 1
9314 ));
9315 }
9316
9317 #[tokio::test]
9318 async fn client_writer_enforces_the_smaller_request_limit() {
9319 let oversized = ClientFrame::Request(RequestEnvelope {
9320 request_id: 1,
9321 request: NodeRequest::Input {
9322 session: SessionAddress {
9323 workspace_id: WorkspaceId::new("primary").unwrap(),
9324 session: SessionKey {
9325 instance_id: AgentInstanceId(1),
9326 generation: SessionGeneration(1),
9327 },
9328 },
9329 text: "x".repeat(MAX_NODE_CLIENT_FRAME_BYTES),
9330 },
9331 });
9332 let mut wire = Vec::new();
9333 let error = write_json_frame_limited(
9334 &mut wire,
9335 &oversized,
9336 MAX_NODE_CLIENT_FRAME_BYTES,
9337 )
9338 .await
9339 .unwrap_err();
9340 assert!(matches!(
9341 error,
9342 FrameError::InvalidLength {
9343 length,
9344 max: MAX_NODE_CLIENT_FRAME_BYTES
9345 } if length > MAX_NODE_CLIENT_FRAME_BYTES
9346 ));
9347 assert!(wire.is_empty());
9348 }
9349
9350 #[tokio::test]
9351 async fn maximum_node_text_fits_the_client_frame_under_worst_case_json_escaping() {
9352 let frame = ClientFrame::Request(RequestEnvelope {
9353 request_id: 2,
9354 request: NodeRequest::Paste {
9355 session: SessionAddress {
9356 workspace_id: WorkspaceId::new("primary").unwrap(),
9357 session: SessionKey {
9358 instance_id: AgentInstanceId(1),
9359 generation: SessionGeneration(1),
9360 },
9361 },
9362 text: "\0".repeat(MAX_NODE_TEXT_BYTES),
9363 },
9364 });
9365 let mut wire = Vec::new();
9366 write_json_frame_limited(&mut wire, &frame, MAX_NODE_CLIENT_FRAME_BYTES)
9367 .await
9368 .unwrap();
9369 assert!(wire.len() <= MAX_NODE_CLIENT_FRAME_BYTES + std::mem::size_of::<u32>());
9370 }
9371
9372 #[tokio::test]
9373 async fn body_timeout_starts_after_the_bounded_length_prefix() {
9374 let (mut writer, mut reader) = tokio::io::duplex(64);
9375 writer.write_u32_le(32).await.unwrap();
9376 writer.write_all(b"{").await.unwrap();
9377 let error = read_json_frame_limited_body_timeout::<_, ClientFrame>(
9378 &mut reader,
9379 MAX_NODE_CLIENT_FRAME_BYTES,
9380 Duration::from_millis(10),
9381 )
9382 .await
9383 .unwrap_err();
9384 assert!(matches!(error, FrameError::BodyTimedOut { length: 32 }));
9385 }
9386
9387 #[tokio::test]
9388 async fn partial_length_prefix_cannot_pin_a_connection_slot() {
9389 let (mut writer, mut reader) = tokio::io::duplex(64);
9390 writer.write_all(&[32]).await.unwrap();
9391 let error = read_json_frame_limited_body_timeout::<_, ClientFrame>(
9392 &mut reader,
9393 MAX_NODE_CLIENT_FRAME_BYTES,
9394 Duration::from_millis(10),
9395 )
9396 .await
9397 .unwrap_err();
9398 assert!(matches!(error, FrameError::PrefixTimedOut));
9399 }
9400
9401 #[test]
9402 fn resume_wire_does_not_accept_a_replacement_working_directory() {
9403 let frame = ClientFrame::Request(RequestEnvelope {
9404 request_id: 9,
9405 request: NodeRequest::Resume {
9406 session: SessionAddress {
9407 workspace_id: WorkspaceId::new("primary").unwrap(),
9408 session: SessionKey {
9409 instance_id: AgentInstanceId(3),
9410 generation: SessionGeneration(2),
9411 },
9412 },
9413 terminal_size: TerminalSize { rows: 24, columns: 80 },
9414 initial_prompt: Some("continue".to_owned()),
9415 },
9416 });
9417 let json = serde_json::to_string(&frame).unwrap();
9418 assert!(!json.contains("working_directory"));
9419
9420 let mut malicious = serde_json::to_value(match frame {
9421 ClientFrame::Request(envelope) => envelope.request,
9422 _ => unreachable!("constructed request frame"),
9423 })
9424 .unwrap();
9425 malicious
9426 .as_object_mut()
9427 .unwrap()
9428 .insert(
9429 "working_directory".to_owned(),
9430 serde_json::Value::String(r"C:\attacker-selected-root".to_owned()),
9431 );
9432 let error = serde_json::from_value::<NodeRequest>(malicious).unwrap_err();
9433 assert!(error.to_string().contains("unknown field `working_directory`"));
9434 }
9435
9436 #[test]
9437 fn history_context_pack_wire_is_bounded_path_free_and_auth_bound() {
9438 assert_eq!(NODE_HISTORY_CONTEXT_PACK_CAPABILITY, "history-context-pack-v1");
9439 let capability = CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
9440 assert!(production_node_client_compatibility_offer()
9441 .capabilities
9442 .contains(&capability));
9443 let mut support = portable_node_support();
9444 support.capabilities = vec![capability.clone()];
9445 let offer = ClientCompatibilityOffer {
9446 build_stamp: BUILD_STAMP.to_owned(),
9447 capabilities: vec![capability.clone()],
9448 state_schema: None,
9449 };
9450 let selected = support.negotiate(&offer).unwrap();
9451 assert_eq!(selected.capabilities, vec![capability]);
9452 let binding = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
9453 assert!(binding
9454 .windows(NODE_HISTORY_CONTEXT_PACK_CAPABILITY.len())
9455 .any(|window| window == NODE_HISTORY_CONTEXT_PACK_CAPABILITY.as_bytes()));
9456
9457 let request = NodeRequest::DiscoverHistory {
9458 session: session_address("primary", 3),
9459 limit: HISTORY_DISCOVERY_LIMIT_MAX,
9460 };
9461 assert_eq!(
9462 request.required_capability(),
9463 Some(NODE_HISTORY_CONTEXT_PACK_CAPABILITY),
9464 );
9465 assert!(request.history_context_pack_contract_is_valid());
9466 let json = serde_json::to_string(&request).unwrap();
9467 assert!(!json.contains("working_directory"));
9468 assert!(!json.contains("path"));
9469 assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
9470
9471 let mut invalid = serde_json::to_value(&request).unwrap();
9472 invalid["limit"] = serde_json::Value::from(0);
9473 assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9474 assert!(!NodeRequest::DiscoverHistory {
9475 session: session_address("primary", 3),
9476 limit: 0,
9477 }
9478 .history_context_pack_contract_is_valid());
9479 assert!(!NodeRequest::LoadHistory {
9480 session: session_address("primary", 3),
9481 candidate_id: String::new(),
9482 }
9483 .history_context_pack_contract_is_valid());
9484 let mut invalid = serde_json::to_value(&request).unwrap();
9485 invalid["limit"] = serde_json::Value::from(u64::from(HISTORY_DISCOVERY_LIMIT_MAX) + 1);
9486 assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9487 let mut invalid = serde_json::to_value(&request).unwrap();
9488 invalid["working_directory"] =
9489 serde_json::Value::String(r"C:\attacker-selected-root".to_owned());
9490 assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9491
9492 let response = NodeResponse::HistoryDiscovered {
9493 session: session_address("primary", 3),
9494 candidates: vec![HistoryCandidateSummary {
9495 id: "candidate-1".to_owned(),
9496 session_id_hint: "session-1".to_owned(),
9497 modified_at_unix_ms: Some(1),
9498 }],
9499 };
9500 assert!(response.requires_history_context_pack_capability());
9501 let response_json = serde_json::to_string(&response).unwrap();
9502 assert!(!response_json.contains("messages"));
9503 assert!(!response_json.contains("working_directory"));
9504 assert!(!response_json.contains("path"));
9505 assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
9506 }
9507
9508 #[test]
9509 fn session_record_context_export_wire_is_exact_private_and_capability_bound() {
9510 assert_eq!(
9511 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
9512 "session-record-context-export-v1",
9513 );
9514 let capability =
9515 CapabilityId::new(NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY).unwrap();
9516 assert!(production_node_client_compatibility_offer()
9517 .capabilities
9518 .contains(&capability));
9519 let session = session_address("primary", 19);
9520 let record_id = SessionRecordId::new("record-19").unwrap();
9521 let request = NodeRequest::ExportContextPackForSessionRecord {
9522 record_id: record_id.clone(),
9523 session: session.clone(),
9524 };
9525 assert_eq!(
9526 request.required_capability(),
9527 Some(NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY),
9528 );
9529 assert!(request.requires_history_context_pack_capability());
9530 let request_json = serde_json::to_string(&request).unwrap();
9531 assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
9532 for private in [
9533 "candidate_id",
9534 "session_id_hint",
9535 "provider_session",
9536 "working_directory",
9537 "path",
9538 "model",
9539 "messages",
9540 ] {
9541 assert!(!request_json.contains(private), "request leaked {private}");
9542 }
9543
9544 let response = NodeResponse::ContextPackForSessionRecordExported {
9545 record_id,
9546 session: session.clone(),
9547 context: context_receipt("context-record-19", session),
9548 };
9549 assert!(response.requires_history_context_pack_capability());
9550 assert!(response.requires_session_record_context_export_capability());
9551 let response_json = serde_json::to_string(&response).unwrap();
9552 assert_eq!(
9553 serde_json::from_str::<NodeResponse>(&response_json).unwrap(),
9554 response,
9555 );
9556 for private in [
9557 "candidate_id",
9558 "session_id_hint",
9559 "provider_session",
9560 "working_directory",
9561 "path",
9562 "model",
9563 "messages",
9564 ] {
9565 assert!(!response_json.contains(private), "response leaked {private}");
9566 }
9567 }
9568
9569 #[test]
9570 fn native_session_catalog_wire_is_bounded_metadata_only() {
9571 assert_eq!(NODE_NATIVE_SESSION_CATALOG_CAPABILITY, "native-session-catalog-v2");
9572 let route = NativeSessionCatalogRoute::workspace(
9573 WorkspaceId::new("primary").unwrap(),
9574 agent("codex"),
9575 );
9576 let request = NodeRequest::CatalogNativeSessions {
9577 route: route.clone(),
9578 limit: NATIVE_SESSION_CATALOG_LIMIT_MAX,
9579 };
9580 assert_eq!(
9581 request.required_capability(),
9582 Some(NODE_NATIVE_SESSION_CATALOG_CAPABILITY)
9583 );
9584 assert!(request.native_session_catalog_contract_is_valid());
9585 let mut invalid = serde_json::to_value(&request).unwrap();
9586 invalid["limit"] = serde_json::Value::from(65);
9587 assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9588
9589 let response = NodeResponse::NativeSessionsCataloged {
9590 route: route.clone(),
9591 entries: vec![NativeSessionCatalogEntry {
9592 selection_id: "hist_selection_1".to_owned(),
9593 title: Some("Review".to_owned()),
9594 modified_at_unix_ms: Some(9),
9595 model: Some("model-1".to_owned()),
9596 message_count: 4,
9597 completed_turn_count: Some(2),
9598 external_group: None,
9599 record_id: Some(SessionRecordId::new("record-1").unwrap()),
9600 }],
9601 summary: Some(NativeSessionCatalogSummary {
9602 catalog_revision: 7,
9603 recent_cutoff_unix_ms: 8,
9604 recent_total_count: 1,
9605 older_total_count: 2,
9606 recent_next_after_selection_id: None,
9607 recent_has_more: false,
9608 }),
9609 };
9610 assert!(response.requires_native_session_catalog_capability());
9611 assert!(response.native_session_catalog_contract_is_valid());
9612 let json = serde_json::to_string(&response).unwrap();
9613 for forbidden in ["session_id", "stable-session", "cwd", "candidate", "path", "messages", "tokens", "documents", "raw"] {
9614 assert!(!json.contains(forbidden));
9615 }
9616 assert_eq!(serde_json::from_str::<NodeResponse>(&json).unwrap(), response);
9617 assert!(serde_json::from_str::<NodeResponse>(
9618 r#"{"kind":"native-sessions-cataloged","workspace_id":"primary","provider":"codex","entries":[]}"#,
9619 )
9620 .is_err());
9621 let mut injected = serde_json::to_value(&response).unwrap();
9622 injected["entries"][0]["title"] = serde_json::Value::String("safe\nunsafe".to_owned());
9623 assert!(serde_json::from_value::<NodeResponse>(injected).is_err());
9624
9625 let external = NodeResponse::NativeSessionsCataloged {
9626 route: NativeSessionCatalogRoute::unregistered(agent("codex")),
9627 entries: vec![NativeSessionCatalogEntry {
9628 selection_id: "external_selection_1".to_owned(),
9629 title: None,
9630 modified_at_unix_ms: Some(9),
9631 model: None,
9632 message_count: 0,
9633 completed_turn_count: None,
9634 external_group: Some(NativeSessionExternalGroup {
9635 group_id: "external-0001".to_owned(),
9636 kind: gate4agent_types::NativeSessionExternalGroupKind::Project,
9637 display_name: "shared".to_owned(),
9638 }),
9639 record_id: None,
9640 }],
9641 summary: Some(NativeSessionCatalogSummary {
9642 catalog_revision: 9,
9643 recent_cutoff_unix_ms: 8,
9644 recent_total_count: 1,
9645 older_total_count: 0,
9646 recent_next_after_selection_id: None,
9647 recent_has_more: false,
9648 }),
9649 };
9650 assert!(external.native_session_catalog_contract_is_valid());
9651 let external_json = serde_json::to_string(&external).unwrap();
9652 assert!(!external_json.contains("project-"));
9653 for hostile in [r"C:\private", "/srv/private", "..", "nested/path"] {
9654 let mut injected = serde_json::to_value(&external).unwrap();
9655 injected["entries"][0]["external_group"]["display_name"] =
9656 serde_json::Value::String(hostile.to_owned());
9657 assert!(serde_json::from_value::<NodeResponse>(injected).is_err());
9658 }
9659
9660 let page_request = NodeRequest::PageNativeSessions {
9661 route: route.clone(),
9662 window: NativeSessionCatalogWindow::Older,
9663 catalog_revision: 7,
9664 recent_cutoff_unix_ms: 8,
9665 after_selection_id: Some("hist_selection_1".to_owned()),
9666 limit: 1,
9667 };
9668 assert_eq!(
9669 page_request.required_capability(),
9670 Some(NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY),
9671 );
9672 let paged = NodeResponse::NativeSessionsPaged {
9673 route,
9674 page: NativeSessionCatalogPage {
9675 window: NativeSessionCatalogWindow::Older,
9676 revision: 7,
9677 entries: Vec::new(),
9678 next_after_selection_id: None,
9679 remaining_count: 0,
9680 has_more: false,
9681 },
9682 };
9683 assert!(paged.requires_native_session_catalog_paging_capability());
9684 assert!(paged.native_session_catalog_contract_is_valid());
9685 }
9686
9687 #[test]
9688 fn native_session_preview_wire_is_bounded_and_record_projection_is_redacted() {
9689 assert_eq!(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY, "native-session-preview-v2");
9690 let selection = NativeSessionSelection {
9691 route: NativeSessionCatalogRoute::workspace(
9692 WorkspaceId::new("primary").unwrap(),
9693 agent("claude"),
9694 ),
9695 catalog_revision: 7,
9696 recent_cutoff_unix_ms: 8,
9697 selection_id: "hist_selection_1".to_owned(),
9698 };
9699 let request = NodeRequest::PreviewNativeSession {
9700 selection: selection.clone(),
9701 message_limit: NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX,
9702 };
9703 assert_eq!(
9704 request.required_capability(),
9705 Some(NODE_NATIVE_SESSION_PREVIEW_CAPABILITY)
9706 );
9707 assert!(request.native_session_preview_contract_is_valid());
9708 let mut invalid = serde_json::to_value(&request).unwrap();
9709 invalid["message_limit"] = serde_json::Value::from(25);
9710 assert!(serde_json::from_value::<NodeRequest>(invalid).is_err());
9711
9712 let preview = NativeSessionPreview {
9713 title: Some("Review".to_owned()),
9714 modified_at_unix_ms: Some(9),
9715 model: Some("model-1".to_owned()),
9716 message_count: 8,
9717 message_count_exact: true,
9718 completed_turn_count: Some(4),
9719 total_tokens: None,
9720 truncated: true,
9721 messages: vec![gate4agent_types::NativeSessionPreviewMessage {
9722 role: gate4agent_types::HistoryMessageRole::Assistant,
9723 text: "visible answer".to_owned(),
9724 }],
9725 };
9726 let response = NodeResponse::NativeSessionPreviewed {
9727 selection,
9728 preview,
9729 };
9730 assert!(response.requires_native_session_preview_capability());
9731 let json = serde_json::to_string(&response).unwrap();
9732 for forbidden in ["native-secret-id", "cwd", "path", "tokens", "tool_result", "thinking"] {
9733 assert!(!json.contains(forbidden));
9734 }
9735 assert_eq!(serde_json::from_str::<NodeResponse>(&json).unwrap(), response);
9736 }
9737
9738 #[test]
9739 fn context_receipts_are_nonempty_and_strictly_correlated() {
9740 assert!(SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).is_ok());
9741 assert!(SpawnContextDigest::new(format!("sha256:{}", "A".repeat(64))).is_err());
9742 let context = context_receipt("context-1", session_address("primary", 7));
9743 let record = ManagedSessionRecord {
9744 record_id: SessionRecordId::new("session-1").unwrap(),
9745 display_name: "review".to_owned(),
9746 provider: agent("claude"),
9747 mode: SessionMode::Pty,
9748 state: ManagedSessionState::Dormant,
9749 workspace_id: WorkspaceId::new("primary").unwrap(),
9750 canonical_root: host_path(r"C:\repo"),
9751 provider_session: None,
9752 active_session: None,
9753 environment_profile: None,
9754 bundle: None,
9755 context_id: Some(context.id.clone()),
9756 context: Some(context.clone()),
9757 exported_context: None,
9758 task_binding: None,
9759 created_at_unix_ms: 1,
9760 updated_at_unix_ms: 2,
9761 last_error: None,
9762 };
9763 assert!(record.context_binding_is_valid());
9764 let json = serde_json::to_value(&record).unwrap();
9765 assert_eq!(serde_json::from_value::<ManagedSessionRecord>(json.clone()).unwrap(), record);
9766
9767 let mut invalid_task_revision = json.clone();
9768 invalid_task_revision["task_binding"] = serde_json::json!({
9769 "revision": 0,
9770 "task_id": "task-00112233445566778899aaff",
9771 "changed_at_unix_ms": 1,
9772 });
9773 assert!(serde_json::from_value::<ManagedSessionRecord>(invalid_task_revision).is_err());
9774 let mut invalid_task_timestamp = json.clone();
9775 invalid_task_timestamp["task_binding"] = serde_json::json!({
9776 "revision": 1,
9777 "task_id": "task-00112233445566778899aaff",
9778 "changed_at_unix_ms": 3,
9779 });
9780 assert!(serde_json::from_value::<ManagedSessionRecord>(invalid_task_timestamp).is_err());
9781
9782 let mut metadata_without_id = json.clone();
9783 metadata_without_id["context_id"] = serde_json::Value::Null;
9784 assert!(serde_json::from_value::<ManagedSessionRecord>(metadata_without_id).is_err());
9785 let mut id_without_metadata = json.clone();
9786 id_without_metadata["context"] = serde_json::Value::Null;
9787 assert!(serde_json::from_value::<ManagedSessionRecord>(id_without_metadata).is_err());
9788 let mut mismatched = json.clone();
9789 mismatched["context_id"] = serde_json::Value::String("context-2".to_owned());
9790 assert!(serde_json::from_value::<ManagedSessionRecord>(mismatched).is_err());
9791 let mut missing_truncation = json.clone();
9792 missing_truncation["context"]["truncated"] = serde_json::Value::Bool(false);
9793 assert!(serde_json::from_value::<ManagedSessionRecord>(missing_truncation).is_err());
9794 let mut false_truncation = json.clone();
9795 false_truncation["context"]["retained_message_count"] =
9796 false_truncation["context"]["source_message_count"].clone();
9797 assert!(serde_json::from_value::<ManagedSessionRecord>(false_truncation).is_err());
9798 let mut empty = json;
9799 empty["context"]["retained_message_count"] = serde_json::Value::from(0);
9800 assert!(serde_json::from_value::<ManagedSessionRecord>(empty).is_err());
9801
9802 let mut with_exported_context = record.clone();
9803 with_exported_context.exported_context = Some(context.clone());
9804 assert!(with_exported_context.exported_context_is_valid());
9805 let exported_json = serde_json::to_value(&with_exported_context).unwrap();
9806 assert_eq!(
9807 serde_json::from_value::<ManagedSessionRecord>(exported_json.clone()).unwrap(),
9808 with_exported_context,
9809 );
9810 let mut mismatched_provider = exported_json;
9811 mismatched_provider["exported_context"]["lineage"]["source_provider"] =
9812 serde_json::Value::String("codex".to_owned());
9813 assert!(serde_json::from_value::<ManagedSessionRecord>(mismatched_provider).is_err());
9814
9815 let receipt = ResolvedSpawnReceipt {
9816 incarnation_id: NodeIncarnationId::from_bytes([9; NODE_INCARNATION_ID_BYTES]),
9817 session: session_address("primary", 8),
9818 target: SpawnTarget {
9819 node_id: NodeId::new("node-a").unwrap(),
9820 workspace_id: WorkspaceId::new("primary").unwrap(),
9821 worktree_id: None,
9822 },
9823 profile_id: SpawnProfileId::new("default").unwrap(),
9824 profile_revision: SpawnProfileRevision::new("default.r1").unwrap(),
9825 provider: agent("claude"),
9826 mode: SessionMode::Pty,
9827 terminal_size: TerminalSize { rows: 24, columns: 80 },
9828 prompt: SpawnPromptMetadata { present: false, byte_len: 0 },
9829 bundle_id: None,
9830 bundle: None,
9831 context_id: Some(context.id.clone()),
9832 context: Some(context),
9833 environment_profile: None,
9834 deadline_ms: SpawnDeadlineMs::new(5_000).unwrap(),
9835 idempotency_key: SpawnIdempotencyKey::new("spawn-1").unwrap(),
9836 required_capabilities: SpawnRequiredCapabilities::default(),
9837 provenance: SpawnResolutionProvenance {
9838 provider: SpawnFieldProvenance::Profile,
9839 mode: SpawnFieldProvenance::Profile,
9840 terminal_size: SpawnFieldProvenance::Profile,
9841 prompt: SpawnFieldProvenance::Profile,
9842 bundle_id: SpawnFieldProvenance::Profile,
9843 context_id: SpawnFieldProvenance::Profile,
9844 environment_profile_id: SpawnFieldProvenance::Profile,
9845 },
9846 harness_mcp_proxy: None,
9847 };
9848 assert!(receipt.context_binding_is_valid());
9849 let mut mismatched = serde_json::to_value(&receipt).unwrap();
9850 mismatched["context_id"] = serde_json::Value::String("context-2".to_owned());
9851 assert!(serde_json::from_value::<ResolvedSpawnReceipt>(mismatched).is_err());
9852 }
9853
9854 #[test]
9855 fn protocol_v9_workspace_and_worktree_mutations_have_exact_bounded_wire_shapes() {
9856 assert_eq!(MAX_WORKSPACE_ROOT_BYTES, gate4agent_types::WORKING_DIRECTORY_MAX_BYTES);
9857
9858 let register = NodeRequest::RegisterWorkspace {
9859 workspace_id: WorkspaceId::new("repo-2").unwrap(),
9860 root: host_path(r"C:\repo-2"),
9861 };
9862 let register_json = serde_json::to_string(®ister).unwrap();
9863 assert_eq!(
9864 register_json,
9865 r#"{"kind":"register-workspace","workspace_id":"repo-2","root":"C:\\repo-2"}"#,
9866 );
9867 assert_eq!(serde_json::from_str::<NodeRequest>(®ister_json).unwrap(), register);
9868
9869 let standalone = NodeRequest::CreateStandaloneWorkspace {
9870 workspace_id: WorkspaceId::new("independent").unwrap(),
9871 root: host_path(r"C:\independent"),
9872 initial_branch: Some("main".to_owned()),
9873 };
9874 let standalone_json = serde_json::to_string(&standalone).unwrap();
9875 assert_eq!(
9876 standalone_json,
9877 r#"{"kind":"create-standalone-workspace","workspace_id":"independent","root":"C:\\independent","initial_branch":"main"}"#,
9878 );
9879 assert_eq!(
9880 serde_json::from_str::<NodeRequest>(&standalone_json).unwrap(),
9881 standalone,
9882 );
9883 assert_eq!(
9884 standalone.required_capability(),
9885 Some(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY),
9886 );
9887 let oversized_branch = format!(
9888 r#"{{"kind":"create-standalone-workspace","workspace_id":"independent","root":"C:\\independent","initial_branch":"{}"}}"#,
9889 "x".repeat(MAX_REPOSITORY_PATH_BYTES + 1),
9890 );
9891 assert!(serde_json::from_str::<NodeRequest>(&oversized_branch).is_err());
9892
9893 let unregister = NodeRequest::UnregisterWorkspace {
9894 workspace_id: WorkspaceId::new("repo-2").unwrap(),
9895 };
9896 let unregister_json = serde_json::to_string(&unregister).unwrap();
9897 assert_eq!(
9898 unregister_json,
9899 r#"{"kind":"unregister-workspace","workspace_id":"repo-2"}"#,
9900 );
9901 assert_eq!(serde_json::from_str::<NodeRequest>(&unregister_json).unwrap(), unregister);
9902
9903 let create = NodeRequest::CreateWorktree {
9904 source_workspace_id: WorkspaceId::new("primary").unwrap(),
9905 workspace_id: WorkspaceId::new("topic-one").unwrap(),
9906 target_root: host_path(r"C:\trees\topic-one"),
9907 branch: "codex/topic-one".to_owned(),
9908 base: Some("main".to_owned()),
9909 };
9910 let create_json = serde_json::to_string(&create).unwrap();
9911 assert_eq!(
9912 create_json,
9913 r#"{"kind":"create-worktree","source_workspace_id":"primary","workspace_id":"topic-one","target_root":"C:\\trees\\topic-one","branch":"codex/topic-one","base":"main"}"#,
9914 );
9915 assert_eq!(serde_json::from_str::<NodeRequest>(&create_json).unwrap(), create);
9916
9917 let remove = NodeRequest::RemoveWorktree {
9918 source_workspace_id: WorkspaceId::new("primary").unwrap(),
9919 target_root: host_path(r"C:\trees\topic-one"),
9920 };
9921 let remove_json = serde_json::to_string(&remove).unwrap();
9922 assert_eq!(
9923 remove_json,
9924 r#"{"kind":"remove-worktree","source_workspace_id":"primary","target_root":"C:\\trees\\topic-one"}"#,
9925 );
9926 assert_eq!(serde_json::from_str::<NodeRequest>(&remove_json).unwrap(), remove);
9927 }
9928
9929 #[test]
9930 fn incarnation_id_and_cursor_have_exact_lowercase_hex_wire_shapes() {
9931 let incarnation_id = NodeIncarnationId::from_bytes([
9932 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
9933 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
9934 ]);
9935 assert_eq!(
9936 incarnation_id.to_string(),
9937 "00112233445566778899aabbccddeeff",
9938 );
9939 let json = serde_json::to_string(&incarnation_id).unwrap();
9940 assert_eq!(json, r#""00112233445566778899aabbccddeeff""#);
9941 assert_eq!(
9942 serde_json::from_str::<NodeIncarnationId>(&json).unwrap(),
9943 incarnation_id,
9944 );
9945 assert!("00112233445566778899AABBCCDDEEFF"
9946 .parse::<NodeIncarnationId>()
9947 .is_err());
9948 assert!("00112233445566778899aabbccddeef"
9949 .parse::<NodeIncarnationId>()
9950 .is_err());
9951 assert!("00112233445566778899aabbccddeefg"
9952 .parse::<NodeIncarnationId>()
9953 .is_err());
9954
9955 let cursor = NodeCursor {
9956 incarnation_id,
9957 sequence: 17,
9958 };
9959 let cursor_json = serde_json::to_string(&cursor).unwrap();
9960 assert_eq!(
9961 cursor_json,
9962 r#"{"incarnation_id":"00112233445566778899aabbccddeeff","sequence":17}"#,
9963 );
9964 assert_eq!(serde_json::from_str::<NodeCursor>(&cursor_json).unwrap(), cursor);
9965 }
9966
9967 #[test]
9968 fn node_hello_carries_the_incarnation_sequence_domain_with_the_build_stamp() {
9969 let hello = NodeHello {
9970 build_stamp: BUILD_STAMP.to_owned(),
9971 incarnation_id: NodeIncarnationId::from_bytes([0; NODE_INCARNATION_ID_BYTES]),
9972 connection_id: 42,
9973 role: ClientRole::Observer,
9974 event_sequence: 9,
9975 controller: None,
9976 snapshot: NodeSnapshot {
9977 node_id: NodeId::new("fixture-node").unwrap(),
9978 enabled_providers: Vec::new(),
9979 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
9980 workspaces: Vec::new(),
9981 session_records: Vec::new(),
9982 managed_worktrees: Vec::new(),
9983 launch_inventory: None,
9984 agent_progress: Vec::new(),
9985 },
9986 compatibility: None,
9987 };
9988 let json = serde_json::to_string(&hello).unwrap();
9989 assert_eq!(
9990 json,
9991 format!(
9992 r#"{{"build_stamp":"{BUILD_STAMP}","incarnation_id":"00000000000000000000000000000000","connection_id":42,"role":"observer","event_sequence":9,"controller":null,"snapshot":{{"node_id":"fixture-node","enabled_providers":[],"workspaces":[],"session_records":[]}}}}"#,
9993 ),
9994 );
9995 assert_eq!(serde_json::from_str::<NodeHello>(&json).unwrap(), hello);
9996 }
9997
9998 #[test]
9999 fn agent_progress_v1_rejects_oversize_and_controls() {
10000 let address = serde_json::json!({
10001 "workspace_id": "primary",
10002 "session": { "instance_id": 7, "generation": 3 }
10003 });
10004 let valid_progress = serde_json::json!({
10005 "provider_sequence": 11,
10006 "activity": "working",
10007 "completed_turns": 2,
10008 "usage": {
10009 "input_tokens": 10,
10010 "output_tokens": 20,
10011 "cache_read_tokens": 30,
10012 "cache_write_tokens": 40,
10013 "reasoning_tokens": 50
10014 },
10015 "current": "working",
10016 "active_tool_labels": ["Read"],
10017 "active_tool_count": 1,
10018 "attention": null,
10019 "subagent_count": 0,
10020 "last_event_kind": "tool-started",
10021 "gap_count": 0,
10022 "stale": false,
10023 "truncated": false
10024 });
10025 let mut controlled = valid_progress.clone();
10026 controlled["active_tool_labels"] = serde_json::json!(["unsafe\u{0000}tool"]);
10027 assert!(serde_json::from_value::<AgentProgressV1>(controlled.clone()).is_err());
10028
10029 let mut path_like = valid_progress.clone();
10030 path_like["active_tool_labels"] = serde_json::json!([r"Read C:\private\secret"]);
10031 assert!(serde_json::from_value::<AgentProgressV1>(path_like).is_err());
10032
10033 let mut oversized_label = valid_progress.clone();
10034 oversized_label["active_tool_labels"] =
10035 serde_json::json!(["x".repeat(MAX_AGENT_PROGRESS_TOOL_LABEL_BYTES + 1)]);
10036 assert!(serde_json::from_value::<AgentProgressV1>(oversized_label.clone()).is_err());
10037
10038 let oversized_entry = serde_json::json!({
10039 "address": address.clone(),
10040 "progress": valid_progress,
10041 "padding": "x".repeat(MAX_AGENT_PROGRESS_ENTRY_BYTES)
10042 });
10043 assert!(serde_json::to_vec(&oversized_entry).unwrap().len()
10044 > MAX_AGENT_PROGRESS_ENTRY_BYTES);
10045 let snapshot = serde_json::json!({
10046 "node_id": "fixture-node",
10047 "enabled_providers": [],
10048 "workspaces": [],
10049 "session_records": [],
10050 "agent_progress": [
10051 { "address": address.clone(), "progress": controlled },
10052 { "address": address, "progress": oversized_label },
10053 oversized_entry
10054 ]
10055 });
10056 let decoded = serde_json::from_value::<NodeSnapshot>(snapshot).unwrap();
10057 assert!(decoded.agent_progress.is_empty());
10058 }
10059
10060 #[test]
10061 fn launch_inventory_preserves_legacy_absence_and_authoritative_empty() {
10062 let legacy = serde_json::from_str::<NodeSnapshot>(
10063 r#"{"node_id":"fixture-node","enabled_providers":[],"workspaces":[],"session_records":[]}"#,
10064 )
10065 .unwrap();
10066 assert!(legacy.launch_inventory.is_none());
10067 assert!(serde_json::from_str::<LaunchInventory>(r#"{}"#).is_err());
10068
10069 let current = LaunchInventory {
10070 spawn_profiles: Some(Vec::new()),
10071 bundles: Some(Vec::new()),
10072 network_allowlists: None,
10073 };
10074 assert_eq!(
10075 serde_json::to_string(¤t).unwrap(),
10076 r#"{"spawn_profiles":[],"bundles":[]}"#,
10077 );
10078 assert_eq!(
10079 serde_json::from_str::<LaunchInventory>(
10080 r#"{"spawn_profiles":[],"bundles":[]}"#,
10081 )
10082 .unwrap(),
10083 current,
10084 );
10085
10086 let legacy_workspace = serde_json::from_str::<WorkspaceSnapshot>(
10087 r#"{"workspace_id":"repo","canonical_root":"fixture-root","sessions":[]}"#,
10088 )
10089 .unwrap();
10090 assert!(legacy_workspace.managed_worktree_profiles.is_none());
10091 let current_workspace = WorkspaceSnapshot {
10092 managed_worktree_profiles: Some(WorktreeProfileInventory {
10093 profiles: Vec::new(),
10094 }),
10095 ..legacy_workspace
10096 };
10097 assert!(serde_json::to_string(¤t_workspace)
10098 .unwrap()
10099 .contains(r#""managed_worktree_profiles":[]"#));
10100 }
10101
10102 #[test]
10103 fn spawn_profile_environment_authority_is_optional_safe_and_capability_bound() {
10104 let environment_profile = ResolvedEnvironmentProfileReceipt {
10105 profile_id: SpawnEnvironmentProfileId::new("local-claude").unwrap(),
10106 profile_revision: SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap(),
10107 network_allowlist: None,
10108 browser_profile_id: None,
10109 };
10110 let summary = SpawnProfileSummary {
10111 id: SpawnProfileId::new("default").unwrap(),
10112 revision: SpawnProfileRevision::new("v1").unwrap(),
10113 environment_profile: Some(environment_profile.clone()),
10114 };
10115 let encoded = serde_json::to_string(&summary).unwrap();
10116 assert_eq!(
10117 encoded,
10118 r#"{"id":"default","revision":"v1","environment_profile":{"profile_id":"local-claude","profile_revision":"local-claude-r1"}}"#,
10119 );
10120 assert!(!encoded.contains("provider"));
10121 assert!(!encoded.contains("mode"));
10122 let legacy = serde_json::from_str::<SpawnProfileSummary>(
10123 r#"{"id":"default","revision":"v1"}"#,
10124 )
10125 .unwrap();
10126 assert!(legacy.environment_profile.is_none());
10127
10128 let snapshot = serde_json::from_value::<NodeSnapshot>(serde_json::json!({
10129 "node_id": "fixture-node",
10130 "enabled_providers": [],
10131 "workspaces": [],
10132 "launch_inventory": {
10133 "spawn_profiles": [summary],
10134 },
10135 }))
10136 .unwrap();
10137 assert!(snapshot.requires_child_environment_profile_capability());
10138 assert_eq!(
10139 snapshot.launch_inventory.unwrap().spawn_profiles.unwrap()[0]
10140 .environment_profile,
10141 Some(environment_profile),
10142 );
10143 }
10144
10145 #[test]
10146 fn launch_inventory_rejects_duplicate_and_overflow_identities() {
10147 let duplicate_profiles = r#"{"spawn_profiles":[{"id":"default","revision":"v1"},{"id":"default","revision":"v2"}]}"#;
10148 assert!(serde_json::from_str::<LaunchInventory>(duplicate_profiles).is_err());
10149
10150 let digest = format!("sha256:{}", "0".repeat(64));
10151 let duplicate_bundles = serde_json::json!({
10152 "bundles": [
10153 { "id": "review", "revision": "v1", "digest": digest },
10154 { "id": "review", "revision": "v2", "digest": format!("sha256:{}", "1".repeat(64)) },
10155 ],
10156 });
10157 assert!(serde_json::from_value::<LaunchInventory>(duplicate_bundles).is_err());
10158
10159 let duplicate_worktrees = r#"[{"id":"default","revision":"v1","retention":"retain"},{"id":"default","revision":"v2","retention":"remove-when-released"}]"#;
10160 assert!(serde_json::from_str::<WorktreeProfileInventory>(duplicate_worktrees).is_err());
10161
10162 let profiles = (0..=MAX_SPAWN_PROFILES)
10163 .map(|index| serde_json::json!({
10164 "id": format!("profile-{index}"),
10165 "revision": "v1",
10166 }))
10167 .collect::<Vec<_>>();
10168 let overflow = serde_json::json!({ "spawn_profiles": profiles });
10169 assert!(serde_json::from_value::<LaunchInventory>(overflow).is_err());
10170
10171 let bundles = (0..=MAX_LAUNCH_BUNDLES)
10172 .map(|index| serde_json::json!({
10173 "id": format!("bundle-{index}"),
10174 "revision": "v1",
10175 "digest": format!("sha256:{}", "2".repeat(64)),
10176 }))
10177 .collect::<Vec<_>>();
10178 assert!(serde_json::from_value::<LaunchInventory>(
10179 serde_json::json!({ "bundles": bundles }),
10180 )
10181 .is_err());
10182
10183 let worktrees = (0..=MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE)
10184 .map(|index| serde_json::json!({
10185 "id": format!("profile-{index}"),
10186 "revision": "v1",
10187 "retention": "retain",
10188 }))
10189 .collect::<Vec<_>>();
10190 assert!(serde_json::from_value::<WorktreeProfileInventory>(
10191 serde_json::Value::Array(worktrees),
10192 )
10193 .is_err());
10194 }
10195
10196 #[test]
10197 fn managed_worktree_contract_is_bounded_dual_gated_and_path_free() {
10198 assert_eq!(
10199 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
10200 "managed-worktree-lifecycle-v1",
10201 );
10202 assert_eq!(NODE_STATE_SCHEMA_V4, 4);
10203 assert_eq!(NODE_STATE_SCHEMA_V5, 5);
10204 assert_eq!(NODE_STATE_SCHEMA_V6, 6);
10205 assert_eq!(NODE_STATE_SCHEMA_V10, 10);
10206 assert!(WorktreeProfileId::new("p".repeat(MAX_WORKTREE_PROFILE_ID_BYTES)).is_ok());
10207 assert!(WorktreeProfileId::new("p".repeat(MAX_WORKTREE_PROFILE_ID_BYTES + 1)).is_err());
10208 assert!(WorktreeProfileRevision::new(
10209 "r".repeat(MAX_WORKTREE_PROFILE_REVISION_BYTES),
10210 )
10211 .is_ok());
10212 assert!(ManagedWorktreeLeaseId::new(
10213 "l".repeat(MAX_MANAGED_WORKTREE_LEASE_ID_BYTES + 1),
10214 )
10215 .is_err());
10216
10217 let request = NodeRequest::SpawnManagedWorktree {
10218 request: ManagedWorktreeSpawnRequest {
10219 spawn_spec: SpawnSpec {
10220 target: SpawnTarget {
10221 node_id: NodeId::new("node-a").unwrap(),
10222 workspace_id: WorkspaceId::new("primary").unwrap(),
10223 worktree_id: None,
10224 },
10225 profile_id: SpawnProfileId::new("default").unwrap(),
10226 expected_profile_revision: SpawnProfileRevision::new("default.r1").unwrap(),
10227 overrides: SpawnOverrides::default(),
10228 deadline_ms: SpawnDeadlineMs::new(30_000).unwrap(),
10229 idempotency_key: SpawnIdempotencyKey::new("managed-1").unwrap(),
10230 required_capabilities: SpawnRequiredCapabilities::default(),
10231 },
10232 worktree_profile_id: WorktreeProfileId::new("review").unwrap(),
10233 },
10234 };
10235 assert_eq!(
10236 request.required_capability(),
10237 Some(NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY),
10238 );
10239 assert!(request.requires_worktree_selection_capability());
10240 let json = serde_json::to_string(&request).unwrap();
10241 for forbidden in ["canonical", "target_root", "gitdir", "branch", "base_commit", "diagnostic"] {
10242 assert!(!json.contains(forbidden), "managed request leaked {forbidden}");
10243 }
10244
10245 let legacy = match request {
10246 NodeRequest::SpawnManagedWorktree { request } => request,
10247 _ => unreachable!(),
10248 };
10249 let legacy_json = serde_json::to_string(&legacy).unwrap();
10250 assert!(serde_json::from_str::<ManagedWorktreeSpawnRequest>(&legacy_json).is_ok());
10251 assert!(serde_json::from_str::<ManagedWorktreeSpawnRequestV2>(&legacy_json).is_err());
10252 let v2 = NodeRequest::SpawnManagedWorktreeV2 {
10253 request: ManagedWorktreeSpawnRequestV2 {
10254 spawn_spec: legacy.spawn_spec,
10255 worktree_profile_id: legacy.worktree_profile_id,
10256 expected_profile_revision: WorktreeProfileRevision::new("review.r1").unwrap(),
10257 },
10258 };
10259 assert_eq!(
10260 v2.required_capability(),
10261 Some(NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY),
10262 );
10263 assert!(v2.requires_worktree_selection_capability());
10264 }
10265
10266 #[test]
10267 fn managed_worktree_snapshot_rejects_invalid_time_duplicate_identity_and_overflow() {
10268 fn lease(lease_id: &str, workspace_id: &str) -> serde_json::Value {
10269 serde_json::json!({
10270 "lease_id": lease_id,
10271 "source_workspace_id": "primary",
10272 "workspace_id": workspace_id,
10273 "profile_id": "review",
10274 "profile_revision": "review.r1",
10275 "retention": "remove-when-released",
10276 "state": "ready",
10277 "active_session_count": 0,
10278 "managed_record_count": 0,
10279 "cleanup_failure": null,
10280 "created_at_unix_ms": 1,
10281 "updated_at_unix_ms": 2
10282 })
10283 }
10284 fn snapshot(leases: Vec<serde_json::Value>) -> serde_json::Value {
10285 serde_json::json!({
10286 "node_id": "node-a",
10287 "enabled_providers": [],
10288 "workspaces": [],
10289 "session_records": [],
10290 "managed_worktrees": leases
10291 })
10292 }
10293
10294 let mut reversed = lease("lease-a", "managed-a");
10295 reversed["updated_at_unix_ms"] = serde_json::json!(0);
10296 assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![reversed])).is_err());
10297 assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![lease(
10298 "lease-a",
10299 "primary",
10300 )]))
10301 .is_err());
10302
10303 for (state, active, records, failure) in [
10304 ("ready", 1, 0, serde_json::Value::Null),
10305 ("in-use", 0, 0, serde_json::Value::Null),
10306 ("in-use", 1, 0, serde_json::json!("busy")),
10307 ("cleanup-blocked", 0, 0, serde_json::Value::Null),
10308 ("cleanup-blocked", 1, 0, serde_json::json!("busy")),
10309 ("recovery-required", 0, 0, serde_json::Value::Null),
10310 ("removed", 0, 1, serde_json::Value::Null),
10311 ] {
10312 let mut malformed = lease("lease-a", "managed-a");
10313 malformed["state"] = serde_json::json!(state);
10314 malformed["active_session_count"] = serde_json::json!(active);
10315 malformed["managed_record_count"] = serde_json::json!(records);
10316 malformed["cleanup_failure"] = failure;
10317 assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![malformed])).is_err());
10318 }
10319 let mut recovery_with_holder = lease("lease-a", "managed-a");
10320 recovery_with_holder["state"] = serde_json::json!("recovery-required");
10321 recovery_with_holder["managed_record_count"] = serde_json::json!(1);
10322 recovery_with_holder["cleanup_failure"] = serde_json::json!("ownership-conflict");
10323 assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![recovery_with_holder])).is_ok());
10324
10325 let mut spawn_lease = lease("lease-a", "managed-a");
10326 spawn_lease["state"] = serde_json::json!("in-use");
10327 spawn_lease["active_session_count"] = serde_json::json!(1);
10328 spawn_lease["managed_record_count"] = serde_json::json!(1);
10329 let spawn = serde_json::json!({
10330 "incarnation_id": "00000000000000000000000000000000",
10331 "session": {
10332 "workspace_id": "managed-a",
10333 "session": { "instance_id": 1, "generation": 1 }
10334 },
10335 "target": {
10336 "node_id": "node-a",
10337 "workspace_id": "primary",
10338 "worktree_id": "managed-a"
10339 },
10340 "profile_id": "default",
10341 "profile_revision": "default.r1",
10342 "provider": "claude",
10343 "mode": "pty",
10344 "terminal_size": { "rows": 24, "columns": 80 },
10345 "prompt": { "present": false, "byte_len": 0 },
10346 "bundle_id": null,
10347 "context_id": null,
10348 "environment_profile_id": null,
10349 "deadline_ms": 5000,
10350 "idempotency_key": "managed-1",
10351 "required_capabilities": [],
10352 "provenance": {
10353 "provider": "profile",
10354 "mode": "profile",
10355 "terminal_size": "profile",
10356 "prompt": "profile",
10357 "bundle_id": "profile",
10358 "context_id": "profile",
10359 "environment_profile_id": "profile"
10360 }
10361 });
10362 let valid_receipt = serde_json::json!({ "spawn": spawn, "lease": spawn_lease });
10363 assert!(serde_json::from_value::<ManagedWorktreeSpawnReceipt>(valid_receipt.clone()).is_ok());
10364 let mut wrong_source = valid_receipt.clone();
10365 wrong_source["lease"]["source_workspace_id"] = serde_json::json!("other");
10366 assert!(serde_json::from_value::<ManagedWorktreeSpawnReceipt>(wrong_source).is_err());
10367 let mut wrong_session = valid_receipt;
10368 wrong_session["spawn"]["session"]["workspace_id"] = serde_json::json!("other");
10369 assert!(serde_json::from_value::<ManagedWorktreeSpawnReceipt>(wrong_session).is_err());
10370
10371 assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![
10372 lease("lease-a", "managed-a"),
10373 lease("lease-a", "managed-b"),
10374 ]))
10375 .is_err());
10376 assert!(serde_json::from_value::<NodeSnapshot>(snapshot(vec![
10377 lease("lease-a", "managed-a"),
10378 lease("lease-b", "managed-a"),
10379 ]))
10380 .is_err());
10381
10382 let leases = (0..=MAX_MANAGED_WORKTREE_LEASES)
10383 .map(|index| lease(&format!("lease-{index}"), &format!("managed-{index}")))
10384 .collect();
10385 assert!(serde_json::from_value::<NodeSnapshot>(snapshot(leases)).is_err());
10386 }
10387
10388 #[test]
10389 fn terminal_bytes_round_trip_as_an_exact_byte_array() {
10390 let request = NodeRequest::TerminalBytes {
10391 session: SessionAddress {
10392 workspace_id: WorkspaceId::new("primary").unwrap(),
10393 session: SessionKey {
10394 instance_id: AgentInstanceId(7),
10395 generation: SessionGeneration(3),
10396 },
10397 },
10398 bytes: b"\x1b[1;5D".to_vec(),
10399 };
10400 let json = serde_json::to_string(&request).unwrap();
10401 assert_eq!(
10402 json,
10403 r#"{"kind":"terminal-bytes","session":{"workspace_id":"primary","session":{"instance_id":7,"generation":3}},"bytes":[27,91,49,59,53,68]}"#,
10404 );
10405 assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
10406 }
10407
10408 #[test]
10409 fn terminal_frame_event_wire_contract_is_exact() {
10410 let event = NodeEvent::TerminalFrame {
10411 address: SessionAddress {
10412 workspace_id: WorkspaceId::new("primary").unwrap(),
10413 session: SessionKey {
10414 instance_id: AgentInstanceId(7),
10415 generation: SessionGeneration(3),
10416 },
10417 },
10418 frame: TerminalFrame {
10419 sequence: 11,
10420 size: TerminalSize { rows: 24, columns: 80 },
10421 cursor_row: 2,
10422 cursor_column: 4,
10423 contents: "ready".to_owned(),
10424 formatted: b"ready".to_vec(),
10425 scrollback_formatted: vec![b"previous".to_vec()],
10426 alternate_screen: false,
10427 mouse_protocol_enabled: false,
10428 mouse_protocol_encoding:
10429 gate4agent_types::TerminalMouseProtocolEncoding::Default,
10430 produced_at_unix_ms: 0,
10431 screen_state: gate4agent_types::PtyScreenState::default(),
10432 bracketed_paste: None,
10433 },
10434 };
10435 let json = serde_json::to_string(&event).unwrap();
10436 assert_eq!(
10437 json,
10438 r#"{"kind":"terminal-frame","address":{"workspace_id":"primary","session":{"instance_id":7,"generation":3}},"frame":{"sequence":11,"size":{"rows":24,"columns":80},"cursor_row":2,"cursor_column":4,"contents":"ready","formatted":[114,101,97,100,121],"scrollback_formatted":[[112,114,101,118,105,111,117,115]],"alternate_screen":false,"mouse_protocol_enabled":false,"mouse_protocol_encoding":"default","produced_at_unix_ms":0,"screen_state":{"kind":"unknown"}}}"#,
10439 );
10440 assert_eq!(serde_json::from_str::<NodeEvent>(&json).unwrap(), event);
10441 }
10442
10443 #[test]
10444 fn terminal_frame_events_capability_is_optional_and_auth_bound_exactly() {
10445 assert_eq!(
10446 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
10447 "terminal-frame-events-v1",
10448 );
10449 assert!(production_node_client_compatibility_offer()
10450 .capabilities
10451 .iter()
10452 .any(|capability| capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY));
10453
10454 let capability = CapabilityId::new(NODE_TERMINAL_FRAME_EVENTS_CAPABILITY).unwrap();
10455 let mut support = portable_node_support();
10456 support.capabilities = vec![capability.clone()];
10457 let legacy = ClientCompatibilityOffer::local();
10458 assert!(support
10459 .negotiate(&legacy)
10460 .unwrap()
10461 .capabilities
10462 .is_empty());
10463
10464 let offer = ClientCompatibilityOffer {
10465 build_stamp: BUILD_STAMP.to_owned(),
10466 capabilities: vec![capability.clone()],
10467 state_schema: None,
10468 };
10469 let selected = support
10470 .negotiate(&offer)
10471 .unwrap();
10472 assert_eq!(selected.capabilities, vec![capability]);
10473 assert_eq!(
10474 String::from_utf8(
10475 encode_node_compatibility_auth_binding(&offer, &selected).unwrap(),
10476 )
10477 .unwrap(),
10478 format!(
10479 r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["terminal-frame-events-v1"]}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["terminal-frame-events-v1"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","provider_contracts":[]}}}}"#,
10480 ),
10481 );
10482 }
10483
10484 #[test]
10485 fn child_environment_profile_capability_is_optional_and_auth_bound_exactly() {
10486 assert_eq!(
10487 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
10488 "child-environment-profile-v1",
10489 );
10490 assert!(production_node_client_compatibility_offer()
10491 .capabilities
10492 .iter()
10493 .any(|capability| {
10494 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
10495 }));
10496
10497 let capability =
10498 CapabilityId::new(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY).unwrap();
10499 let mut support = portable_node_support();
10500 support.capabilities = vec![capability.clone()];
10501 let legacy = ClientCompatibilityOffer::local();
10502 assert!(support
10503 .negotiate(&legacy)
10504 .unwrap()
10505 .capabilities
10506 .is_empty());
10507
10508 let offer = ClientCompatibilityOffer {
10509 build_stamp: BUILD_STAMP.to_owned(),
10510 capabilities: vec![capability.clone()],
10511 state_schema: None,
10512 };
10513 let selected = support
10514 .negotiate(&offer)
10515 .unwrap();
10516 assert_eq!(selected.capabilities, vec![capability]);
10517 let bound = encode_node_compatibility_auth_binding(&offer, &selected).unwrap();
10518 assert!(bound
10519 .windows(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.len())
10520 .any(|window| {
10521 window == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY.as_bytes()
10522 }));
10523 }
10524
10525 #[test]
10526 fn worktree_selection_capability_is_optional_and_auth_bound_exactly() {
10527 assert_eq!(NODE_WORKTREE_SELECTION_CAPABILITY, "worktree-selection-v1");
10528 assert!(production_node_client_compatibility_offer()
10529 .capabilities
10530 .iter()
10531 .any(|capability| capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY));
10532
10533 let capability = CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
10534 let mut support = portable_node_support();
10535 support.capabilities = vec![capability.clone()];
10536 let legacy = ClientCompatibilityOffer::local();
10537 assert!(support
10538 .negotiate(&legacy)
10539 .unwrap()
10540 .capabilities
10541 .is_empty());
10542
10543 let offer = ClientCompatibilityOffer {
10544 build_stamp: BUILD_STAMP.to_owned(),
10545 capabilities: vec![capability.clone()],
10546 state_schema: None,
10547 };
10548 let selected = support.negotiate(&offer).unwrap();
10549 assert_eq!(selected.capabilities, vec![capability]);
10550 assert_eq!(
10551 String::from_utf8(
10552 encode_node_compatibility_auth_binding(&offer, &selected).unwrap(),
10553 )
10554 .unwrap(),
10555 format!(
10556 r#"{{"offer":{{"build_stamp":"{BUILD_STAMP}","capabilities":["worktree-selection-v1"]}},"selected":{{"build_stamp":"{BUILD_STAMP}","capabilities":["worktree-selection-v1"],"host":{{"operating_system":"windows","architecture":"x86_64"}},"path_semantics":{{"style":"windows","encoding":"utf8"}},"local_transport":"windows-named-pipe","provider_contracts":[]}}}}"#,
10557 ),
10558 );
10559 }
10560
10561 #[test]
10562 fn legacy_node_event_bytes_remain_exact_after_terminal_frame_addition() {
10563 assert_eq!(
10564 serde_json::to_vec(&NodeEvent::ResyncRequired {
10565 oldest_available_sequence: 7,
10566 })
10567 .unwrap(),
10568 br#"{"kind":"resync-required","oldest_available_sequence":7}"#,
10569 );
10570 }
10571
10572 #[test]
10573 fn workspace_responses_and_events_round_trip_without_client_only_state() {
10574 let workspace = WorkspaceSnapshot {
10575 workspace_id: WorkspaceId::new("repo-2").unwrap(),
10576 canonical_root: host_path(r"C:\repo-2"),
10577 sessions: Vec::new(),
10578 worktree_service_mode: None,
10579 managed_worktree_profiles: None,
10580 };
10581 let registered = NodeResponse::WorkspaceRegistered {
10582 workspace: workspace.clone(),
10583 };
10584 let registered_json = serde_json::to_string(®istered).unwrap();
10585 assert_eq!(
10586 serde_json::from_str::<NodeResponse>(®istered_json).unwrap(),
10587 registered,
10588 );
10589 let standalone = NodeResponse::StandaloneWorkspaceCreated {
10590 workspace: workspace.clone(),
10591 };
10592 let standalone_json = serde_json::to_string(&standalone).unwrap();
10593 assert_eq!(
10594 serde_json::from_str::<NodeResponse>(&standalone_json).unwrap(),
10595 standalone,
10596 );
10597 let added = NodeEventEnvelope {
10598 sequence: 19,
10599 event: NodeEvent::WorkspaceAdded {
10600 workspace: workspace.clone(),
10601 },
10602 };
10603 let added_json = serde_json::to_string(&added).unwrap();
10604 assert_eq!(
10605 serde_json::from_str::<NodeEventEnvelope>(&added_json).unwrap(),
10606 added,
10607 );
10608 let removed = NodeEventEnvelope {
10609 sequence: 20,
10610 event: NodeEvent::WorkspaceRemoved {
10611 workspace_id: workspace.workspace_id.clone(),
10612 },
10613 };
10614 let removed_json = serde_json::to_string(&removed).unwrap();
10615 assert_eq!(
10616 serde_json::from_str::<NodeEventEnvelope>(&removed_json).unwrap(),
10617 removed,
10618 );
10619
10620 let created = NodeResponse::WorktreeCreated {
10621 worktree: GitWorktreeSnapshot {
10622 path: host_path(r"C:\trees\topic-one"),
10623 head: "abc1234".to_owned(),
10624 branch: Some("codex/topic-one".to_owned()),
10625 is_bare: false,
10626 is_main: false,
10627 locked: false,
10628 lock_reason: None,
10629 prunable: false,
10630 prunable_reason: None,
10631 workspace_id: Some(workspace.workspace_id.clone()),
10632 },
10633 workspace: workspace.clone(),
10634 };
10635 let created_json = serde_json::to_string(&created).unwrap();
10636 assert_eq!(serde_json::from_str::<NodeResponse>(&created_json).unwrap(), created);
10637 let removed = NodeResponse::WorktreeRemoved {
10638 target_root: host_path(r"C:\trees\topic-one"),
10639 workspace_id: Some(workspace.workspace_id),
10640 };
10641 let removed_json = serde_json::to_string(&removed).unwrap();
10642 assert_eq!(serde_json::from_str::<NodeResponse>(&removed_json).unwrap(), removed);
10643 }
10644
10645 #[test]
10646 fn workspace_inspection_round_trips_as_a_workspace_scoped_read_only_request() {
10647 let workspace_id = WorkspaceId::new("primary").unwrap();
10648 let request = NodeRequest::InspectWorkspace {
10649 workspace_id: workspace_id.clone(),
10650 };
10651 let request_json = serde_json::to_string(&request).unwrap();
10652 assert_eq!(
10653 request_json,
10654 r#"{"kind":"inspect-workspace","workspace_id":"primary"}"#,
10655 );
10656 assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
10657
10658 let response = NodeResponse::WorkspaceInspected {
10659 inspection: WorkspaceInspection {
10660 workspace_id,
10661 entries: vec![
10662 WorkspaceEntry {
10663 relative_path: repository_path("src"),
10664 kind: WorkspaceEntryKind::Directory,
10665 },
10666 WorkspaceEntry {
10667 relative_path: repository_path("src/lib.rs"),
10668 kind: WorkspaceEntryKind::File,
10669 },
10670 ],
10671 tree_truncated: false,
10672 git: GitSnapshot {
10673 is_repository: true,
10674 branch: Some("main".to_owned()),
10675 status: vec![GitStatusEntry {
10676 index_status: " ".to_owned(),
10677 worktree_status: "M".to_owned(),
10678 path: repository_path("src/lib.rs"),
10679 previous_path: None,
10680 }],
10681 recent_commits: vec![GitCommitSummary {
10682 id: "abc1234".to_owned(),
10683 summary: "bounded summary".to_owned(),
10684 }],
10685 worktrees: vec![GitWorktreeSnapshot {
10686 path: host_path(r"C:\repo"),
10687 head: "abc1234".to_owned(),
10688 branch: Some("main".to_owned()),
10689 is_bare: false,
10690 is_main: true,
10691 locked: false,
10692 lock_reason: None,
10693 prunable: false,
10694 prunable_reason: None,
10695 workspace_id: Some(WorkspaceId::new("primary").unwrap()),
10696 }],
10697 managed_worktree: None,
10698 truncated: false,
10699 diagnostic: None,
10700 },
10701 truncation: None,
10702 },
10703 };
10704 let response_json = serde_json::to_string(&response).unwrap();
10705 assert!(!response_json.contains("managed_worktree"));
10706 assert!(!response_json.contains("truncation"));
10707 assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
10708 }
10709
10710 #[test]
10711 fn workspace_inspection_truncation_is_additive_and_defaults_on_old_payloads() {
10712 let workspace_id = WorkspaceId::new("primary").unwrap();
10713 let old_payload = serde_json::json!({
10714 "workspace_id": workspace_id.as_str(),
10715 "entries": [],
10716 "tree_truncated": false,
10717 "git": {
10718 "is_repository": false,
10719 "branch": null,
10720 "status": [],
10721 "recent_commits": [],
10722 "worktrees": [],
10723 "truncated": false,
10724 "diagnostic": null,
10725 },
10726 });
10727 let parsed: WorkspaceInspection = serde_json::from_value(old_payload).unwrap();
10728 assert_eq!(parsed.truncation, None);
10729
10730 let truncated = WorkspaceInspectionTruncationV1 {
10731 walk_time_budget_exceeded: true,
10732 walk_entry_cap_exceeded: false,
10733 git_time_budget_exceeded: true,
10734 entries_visited: 50_000,
10735 elapsed_ms: 8_000,
10736 };
10737 let inspection = WorkspaceInspection {
10738 workspace_id,
10739 entries: Vec::new(),
10740 tree_truncated: true,
10741 git: GitSnapshot {
10742 is_repository: false,
10743 branch: None,
10744 status: Vec::new(),
10745 recent_commits: Vec::new(),
10746 worktrees: Vec::new(),
10747 managed_worktree: None,
10748 truncated: true,
10749 diagnostic: Some("git inspection skipped: workspace inspection time budget exhausted".to_owned()),
10750 },
10751 truncation: Some(truncated),
10752 };
10753 let json = serde_json::to_string(&inspection).unwrap();
10754 let round_tripped: WorkspaceInspection = serde_json::from_str(&json).unwrap();
10755 assert_eq!(round_tripped.truncation, Some(truncated));
10756 }
10757
10758 #[test]
10759 fn managed_worktree_git_scope_is_optional_bounded_and_round_trips() {
10760 let scope = ManagedWorktreeGitScope {
10761 lease_id: ManagedWorktreeLeaseId::new("mw-scope").unwrap(),
10762 source_workspace_id: WorkspaceId::new("primary").unwrap(),
10763 branch: "gate4agent/mw-scope".to_owned(),
10764 base_commit: GitObjectId::new(
10765 "0123456789abcdef0123456789abcdef01234567".to_owned(),
10766 )
10767 .unwrap(),
10768 active_session_count: 1,
10769 managed_record_count: 1,
10770 };
10771 let json = serde_json::to_string(&scope).unwrap();
10772 assert_eq!(serde_json::from_str::<ManagedWorktreeGitScope>(&json).unwrap(), scope);
10773
10774 let oversized = format!(
10775 r#"{{"lease_id":"mw-scope","source_workspace_id":"primary","branch":"{}","base_commit":"0123456789abcdef0123456789abcdef01234567","active_session_count":0,"managed_record_count":0}}"#,
10776 "x".repeat(MAX_REPOSITORY_PATH_BYTES + 1),
10777 );
10778 assert!(serde_json::from_str::<ManagedWorktreeGitScope>(&oversized).is_err());
10779 }
10780
10781 #[test]
10782 fn workspace_inspection_rejects_inconsistent_managed_git_scope() {
10783 let valid = r#"{"workspace_id":"managed-a","entries":[],"tree_truncated":false,"git":{"is_repository":true,"branch":"gate4agent/a","status":[],"recent_commits":[],"worktrees":[],"managed_worktree":{"lease_id":"mw-a","source_workspace_id":"primary","branch":"gate4agent/a","base_commit":"0123456789abcdef0123456789abcdef01234567","active_session_count":1,"managed_record_count":0},"truncated":false,"diagnostic":null}}"#;
10784 assert!(serde_json::from_str::<WorkspaceInspection>(valid).is_ok());
10785 for invalid in [
10786 valid.replace("\"is_repository\":true", "\"is_repository\":false"),
10787 valid.replacen("\"branch\":\"gate4agent/a\"", "\"branch\":\"gate4agent/b\"", 1),
10788 valid.replace("\"source_workspace_id\":\"primary\"", "\"source_workspace_id\":\"managed-a\""),
10789 valid.replace("\"active_session_count\":1", "\"active_session_count\":0"),
10790 ] {
10791 assert!(serde_json::from_str::<WorkspaceInspection>(&invalid).is_err());
10792 }
10793 }
10794
10795 #[test]
10796 fn workspace_file_read_has_an_exact_capability_gated_wire_contract() {
10797 let workspace_id = WorkspaceId::new("primary").unwrap();
10798 let path = repository_path("src/lib.rs");
10799 let request = NodeRequest::ReadWorkspaceFile {
10800 workspace_id: workspace_id.clone(),
10801 path: path.clone(),
10802 };
10803 let request_json = serde_json::to_string(&request).unwrap();
10804 assert_eq!(
10805 request_json,
10806 r#"{"kind":"read-workspace-file","workspace_id":"primary","path":"src/lib.rs"}"#,
10807 );
10808 assert_eq!(request.required_capability(), Some(NODE_WORKSPACE_FILE_READ_CAPABILITY));
10809 assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
10810
10811 let response = NodeResponse::WorkspaceFileRead {
10812 file: WorkspaceFileRead {
10813 workspace_id,
10814 path,
10815 content: WorkspaceFileContent::Utf8 {
10816 text: "fn main() {}\n".to_owned(),
10817 byte_len: 13,
10818 },
10819 revision: None,
10820 },
10821 };
10822 let response_json = serde_json::to_string(&response).unwrap();
10823 assert_eq!(
10824 response_json,
10825 r#"{"kind":"workspace-file-read","file":{"workspace_id":"primary","path":"src/lib.rs","content":{"kind":"utf8","text":"fn main() {}\n","byte_len":13}}}"#,
10826 );
10827 assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
10828 }
10829
10830 #[test]
10831 fn workspace_entry_create_has_exact_capability_gated_wire_contracts() {
10832 assert!(production_node_client_compatibility_offer()
10833 .capabilities
10834 .iter()
10835 .any(|capability| capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY));
10836 let workspace_id = WorkspaceId::new("primary").unwrap();
10837 let file_path = repository_path("src/new.rs");
10838 let directory_path = repository_path("src/new");
10839 let create_file = NodeRequest::CreateWorkspaceFile {
10840 workspace_id: workspace_id.clone(),
10841 path: file_path.clone(),
10842 };
10843 let create_directory = NodeRequest::CreateWorkspaceDirectory {
10844 workspace_id: workspace_id.clone(),
10845 path: directory_path.clone(),
10846 };
10847 assert_eq!(
10848 serde_json::to_string(&create_file).unwrap(),
10849 r#"{"kind":"create-workspace-file","workspace_id":"primary","path":"src/new.rs"}"#,
10850 );
10851 assert_eq!(
10852 serde_json::to_string(&create_directory).unwrap(),
10853 r#"{"kind":"create-workspace-directory","workspace_id":"primary","path":"src/new"}"#,
10854 );
10855 for request in [create_file, create_directory] {
10856 assert_eq!(
10857 request.required_capability(),
10858 Some(NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY),
10859 );
10860 let encoded = serde_json::to_string(&request).unwrap();
10861 assert_eq!(serde_json::from_str::<NodeRequest>(&encoded).unwrap(), request);
10862 }
10863
10864 let file_response = NodeResponse::WorkspaceFileCreated {
10865 file: WorkspaceFileRead {
10866 workspace_id: workspace_id.clone(),
10867 path: file_path,
10868 content: WorkspaceFileContent::Utf8 {
10869 text: String::new(),
10870 byte_len: 0,
10871 },
10872 revision: Some(WorkspaceFileRevision::new("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned()).unwrap()),
10873 },
10874 };
10875 let directory_response = NodeResponse::WorkspaceDirectoryCreated {
10876 workspace_id,
10877 entry: WorkspaceEntry {
10878 relative_path: directory_path,
10879 kind: WorkspaceEntryKind::Directory,
10880 },
10881 };
10882 for response in [file_response, directory_response] {
10883 let encoded = serde_json::to_string(&response).unwrap();
10884 assert_eq!(serde_json::from_str::<NodeResponse>(&encoded).unwrap(), response);
10885 }
10886 }
10887
10888 #[test]
10889 fn workspace_file_read_worst_case_json_stays_within_the_server_frame_limit() {
10890 let response = NodeResponse::WorkspaceFileRead {
10891 file: WorkspaceFileRead {
10892 workspace_id: WorkspaceId::new("primary").unwrap(),
10893 path: repository_path(&"x".repeat(MAX_REPOSITORY_PATH_BYTES)),
10894 content: WorkspaceFileContent::Utf8 {
10895 text: "\0".repeat(MAX_WORKSPACE_FILE_BYTES),
10896 byte_len: u32::try_from(MAX_WORKSPACE_FILE_BYTES).unwrap(),
10897 },
10898 revision: None,
10899 },
10900 };
10901 let encoded = serde_json::to_vec(&response).unwrap();
10902 assert!(encoded.len() <= MAX_NODE_FRAME_BYTES, "{}", encoded.len());
10903 }
10904
10905 #[test]
10906 fn read_context_pack_has_an_exact_capability_gated_wire_contract() {
10907 let digest = SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).unwrap();
10908 let request = NodeRequest::ReadContextPack {
10909 digest: digest.clone(),
10910 };
10911 let request_json = serde_json::to_string(&request).unwrap();
10912 assert_eq!(
10913 request_json,
10914 format!(
10915 r#"{{"kind":"read-context-pack","digest":"sha256:{}"}}"#,
10916 "a".repeat(64),
10917 ),
10918 );
10919 assert_eq!(
10920 request.required_capability(),
10921 Some(NODE_HISTORY_CONTEXT_PACK_CAPABILITY),
10922 );
10923 assert!(request.history_context_pack_contract_is_valid());
10924 assert_eq!(serde_json::from_str::<NodeRequest>(&request_json).unwrap(), request);
10925
10926 let id = SpawnContextId::new(format!("ctx-{}", "a".repeat(64))).unwrap();
10927 let response = NodeResponse::ContextPackBytesRead {
10928 pack: ContextPackBytesRead {
10929 digest: digest.clone(),
10930 id: id.clone(),
10931 byte_len: 11,
10932 bytes: b"pack-bytes!".to_vec(),
10933 },
10934 };
10935 assert!(response.requires_history_context_pack_capability());
10936 let response_json = serde_json::to_string(&response).unwrap();
10937 assert_eq!(
10938 response_json,
10939 format!(
10940 r#"{{"kind":"context-pack-bytes-read","pack":{{"digest":"sha256:{}","id":"{}","byte_len":11,"bytes":[112,97,99,107,45,98,121,116,101,115,33]}}}}"#,
10941 "a".repeat(64),
10942 id.as_str(),
10943 ),
10944 );
10945 assert_eq!(serde_json::from_str::<NodeResponse>(&response_json).unwrap(), response);
10946 }
10947
10948 #[test]
10949 fn context_pack_bytes_read_deserialization_rejects_inconsistent_or_oversized_lengths() {
10950 let digest = SpawnContextDigest::new(format!("sha256:{}", "b".repeat(64))).unwrap();
10951 let id = SpawnContextId::new(format!("ctx-{}", "b".repeat(64))).unwrap();
10952
10953 let valid = ContextPackBytesRead {
10954 digest: digest.clone(),
10955 id: id.clone(),
10956 byte_len: 4,
10957 bytes: b"test".to_vec(),
10958 };
10959 let encoded = serde_json::to_string(&valid).unwrap();
10960 assert_eq!(serde_json::from_str::<ContextPackBytesRead>(&encoded).unwrap(), valid);
10961
10962 let declares_more_than_it_carries = ContextPackBytesRead {
10963 digest: digest.clone(),
10964 id: id.clone(),
10965 byte_len: 5,
10966 bytes: b"test".to_vec(),
10967 };
10968 let encoded = serde_json::to_string(&declares_more_than_it_carries).unwrap();
10969 assert!(serde_json::from_str::<ContextPackBytesRead>(&encoded).is_err());
10970
10971 let oversized_len = MAX_CONTEXT_PACK_BYTES + 1;
10972 let oversized = ContextPackBytesRead {
10973 digest,
10974 id,
10975 byte_len: oversized_len,
10976 bytes: vec![0u8; oversized_len as usize],
10977 };
10978 let encoded = serde_json::to_string(&oversized).unwrap();
10979 assert!(serde_json::from_str::<ContextPackBytesRead>(&encoded).is_err());
10980 }
10981
10982 #[test]
10989 fn context_pack_digest_formula_is_pinned() {
10990 let lineage = ContextPackLineageReceipt {
10991 source_node_id: NodeId::new("node-pin").unwrap(),
10992 source_session: session_address("pin-workspace", 42),
10993 source_provider: agent("claude"),
10994 };
10995 let digest = context_pack_digest(&lineage, b"pinned-context-pack-bytes");
10996 assert_eq!(
10997 digest.as_str(),
10998 "sha256:d35f4bd15866f03ca3621129bd661862a307a46002fd90e1ff2ca988c72a7035",
10999 );
11000 }
11001
11002 #[test]
11009 fn sha256_hex_formula_is_pinned() {
11010 assert_eq!(
11011 sha256_hex(b"gate4agent"),
11012 "dfcb966151fe0d63481517c76a7ff6237aeaa5e24f7d76dba7fa862a3a3c5b6b",
11013 );
11014 }
11015
11016 #[test]
11017 fn workspace_write_and_git_reads_have_exact_capability_gated_contracts() {
11018 let workspace_id = WorkspaceId::new("primary").unwrap();
11019 let path = repository_path("src/lib.rs");
11020 let revision = WorkspaceFileRevision::new("a".repeat(64)).unwrap();
11021 let write = NodeRequest::WriteWorkspaceFile {
11022 workspace_id: workspace_id.clone(),
11023 path: path.clone(),
11024 expected_revision: revision,
11025 text: "updated\n".to_owned(),
11026 };
11027 assert_eq!(write.required_capability(), Some(NODE_WORKSPACE_FILE_WRITE_CAPABILITY));
11028 let encoded = serde_json::to_string(&write).unwrap();
11029 assert_eq!(serde_json::from_str::<NodeRequest>(&encoded).unwrap(), write);
11030
11031 let history = NodeRequest::ReadGitHistory {
11032 workspace_id: workspace_id.clone(),
11033 path: Some(path.clone()),
11034 before: Some(GitObjectId::new("b".repeat(40)).unwrap()),
11035 limit: MAX_GIT_HISTORY_COMMITS,
11036 };
11037 let diff = NodeRequest::ReadGitDiff {
11038 workspace_id,
11039 request: GitDiffRequest {
11040 mode: GitDiffMode::Commit {
11041 revision: GitObjectId::new("c".repeat(40)).unwrap(),
11042 },
11043 path: Some(path),
11044 },
11045 };
11046 for request in [history, diff] {
11047 assert_eq!(request.required_capability(), Some(NODE_GIT_READ_CAPABILITY));
11048 let encoded = serde_json::to_string(&request).unwrap();
11049 assert_eq!(serde_json::from_str::<NodeRequest>(&encoded).unwrap(), request);
11050 }
11051 }
11052
11053 #[test]
11054 fn workspace_file_content_deserialization_rejects_unbounded_or_inconsistent_lengths() {
11055 let valid = [
11056 WorkspaceFileContent::Utf8 {
11057 text: "тест".to_owned(),
11058 byte_len: 8,
11059 },
11060 WorkspaceFileContent::NonUtf8 { byte_len: 17 },
11061 WorkspaceFileContent::TooLarge {
11062 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
11063 },
11064 ];
11065 for content in valid {
11066 let encoded = serde_json::to_string(&content).unwrap();
11067 assert_eq!(
11068 serde_json::from_str::<WorkspaceFileContent>(&encoded).unwrap(),
11069 content,
11070 );
11071 }
11072
11073 let inconsistent = r#"{"kind":"utf8","text":"hello","byte_len":4}"#;
11074 assert!(serde_json::from_str::<WorkspaceFileContent>(inconsistent).is_err());
11075
11076 let oversized_non_utf8 = format!(
11077 r#"{{"kind":"non-utf8","byte_len":{}}}"#,
11078 MAX_WORKSPACE_FILE_BYTES + 1,
11079 );
11080 assert!(serde_json::from_str::<WorkspaceFileContent>(&oversized_non_utf8).is_err());
11081
11082 let wrong_limit = r#"{"kind":"too-large","limit_bytes":1}"#;
11083 assert!(serde_json::from_str::<WorkspaceFileContent>(wrong_limit).is_err());
11084
11085 let oversized_utf8 = WorkspaceFileContent::Utf8 {
11086 text: "x".repeat(MAX_WORKSPACE_FILE_BYTES + 1),
11087 byte_len: u32::try_from(MAX_WORKSPACE_FILE_BYTES + 1).unwrap(),
11088 };
11089 let encoded = serde_json::to_string(&oversized_utf8).unwrap();
11090 assert!(serde_json::from_str::<WorkspaceFileContent>(&encoded).is_err());
11091 }
11092
11093 #[test]
11094 fn legacy_requests_do_not_acquire_a_new_required_capability() {
11095 let legacy_requests = [
11096 NodeRequest::Snapshot,
11097 NodeRequest::Resync { after_sequence: 7 },
11098 NodeRequest::InspectWorkspace {
11099 workspace_id: WorkspaceId::new("primary").unwrap(),
11100 },
11101 ];
11102 for request in legacy_requests {
11103 assert_eq!(request.required_capability(), None);
11104 }
11105 }
11106
11107 #[test]
11108 fn host_directory_browse_wire_contract_is_bounded_and_capability_gated() {
11109 assert!(production_node_client_compatibility_offer()
11110 .capabilities
11111 .iter()
11112 .any(|capability| capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1));
11113 let directory = OpaqueHostPath::utf8(r"C:\repo".to_owned()).unwrap();
11114 let request = NodeRequest::BrowseHostDirectories {
11115 directory: Some(directory.clone()),
11116 after: None,
11117 };
11118 assert_eq!(
11119 request.required_capability(),
11120 Some(CAPABILITY_HOST_DIRECTORY_BROWSE_V1),
11121 );
11122 assert_eq!(
11123 serde_json::to_string(&request).unwrap(),
11124 r#"{"kind":"browse-host-directories","directory":"C:\\repo","after":null}"#,
11125 );
11126
11127 let response = NodeResponse::HostDirectoriesBrowsed {
11128 listing: HostDirectoryListing {
11129 directory: Some(directory.clone()),
11130 parent: Some(OpaqueHostPath::utf8(r"C:\".to_owned()).unwrap()),
11131 entries: vec![HostDirectoryEntry {
11132 path: OpaqueHostPath::utf8(r"C:\repo\child".to_owned()).unwrap(),
11133 display_name: "child".to_owned(),
11134 is_link: false,
11135 }],
11136 next_after: None,
11137 incomplete: false,
11138 },
11139 };
11140 let encoded = serde_json::to_string(&response).unwrap();
11141 assert_eq!(serde_json::from_str::<NodeResponse>(&encoded).unwrap(), response);
11142
11143 let entries = (0..=MAX_HOST_DIRECTORY_ENTRIES)
11144 .map(|index| serde_json::json!({
11145 "path": format!(r"C:\directory-{index}"),
11146 "display_name": format!("directory-{index}"),
11147 "is_link": false,
11148 }))
11149 .collect::<Vec<_>>();
11150 let overflow = serde_json::json!({
11151 "directory": null,
11152 "parent": null,
11153 "entries": entries,
11154 "next_after": null,
11155 "incomplete": true,
11156 });
11157 assert!(serde_json::from_value::<HostDirectoryListing>(overflow).is_err());
11158 assert_eq!(
11159 serde_json::to_string(&NodeFailureCode::HostDirectoryReadTimedOut).unwrap(),
11160 r#""host-directory-read-timed-out""#,
11161 );
11162 }
11163
11164 #[test]
11165 fn host_directory_entry_rejects_unbounded_control_or_non_utf8_wire_values() {
11166 let path = host_path(r"C:\repo\child");
11167 assert!(HostDirectoryEntry::new(path.clone(), "child".to_owned(), false).is_ok());
11168 for display_name in [
11169 String::new(),
11170 "child\nname".to_owned(),
11171 "x".repeat(MAX_HOST_DIRECTORY_DISPLAY_NAME_BYTES + 1),
11172 ] {
11173 let encoded = serde_json::json!({
11174 "path": path,
11175 "display_name": display_name,
11176 "is_link": false,
11177 });
11178 assert!(serde_json::from_value::<HostDirectoryEntry>(encoded).is_err());
11179 }
11180 let non_utf8 = serde_json::json!({
11181 "path": { "kind": "unix-bytes", "bytes": [47, 255] },
11182 "display_name": "child",
11183 "is_link": false,
11184 });
11185 assert!(serde_json::from_value::<HostDirectoryEntry>(non_utf8).is_err());
11186 }
11187
11188 #[test]
11189 fn git_snapshot_defaults_worktrees_for_legacy_inspection_payloads() {
11190 let json = r#"{"is_repository":true,"branch":"main","status":[],"recent_commits":[],"truncated":false,"diagnostic":null}"#;
11191 let snapshot = serde_json::from_str::<GitSnapshot>(json).unwrap();
11192 assert!(snapshot.worktrees.is_empty());
11193 }
11194
11195 #[test]
11196 fn promptless_resume_round_trips_as_null() {
11197 let request = NodeRequest::Resume {
11198 session: SessionAddress {
11199 workspace_id: WorkspaceId::new("primary").unwrap(),
11200 session: SessionKey {
11201 instance_id: AgentInstanceId(7),
11202 generation: SessionGeneration(2),
11203 },
11204 },
11205 terminal_size: TerminalSize { rows: 24, columns: 80 },
11206 initial_prompt: None,
11207 };
11208 let json = serde_json::to_string(&request).unwrap();
11209 assert!(json.contains(r#""initial_prompt":null"#));
11210 assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
11211 }
11212
11213 #[test]
11214 fn node_and_workspace_ids_are_bounded_validated_wire_values() {
11215 assert_eq!(NodeId::new("node-1").unwrap().as_str(), "node-1");
11216 assert_eq!(WorkspaceId::new("repo_main").unwrap().as_str(), "repo_main");
11217 assert!(NodeId::new("Node-1").is_err());
11218 assert!(WorkspaceId::new("-repo").is_err());
11219 assert!(WorkspaceId::new("x".repeat(MAX_NODE_IDENTIFIER_BYTES + 1)).is_err());
11220
11221 let encoded = serde_json::to_string(&WorkspaceId::new("repo-1").unwrap()).unwrap();
11222 assert_eq!(encoded, "\"repo-1\"");
11223 assert!(serde_json::from_str::<WorkspaceId>("\"Repo-1\"").is_err());
11224 }
11225
11226 #[test]
11227 fn durable_session_wire_contract_round_trips() {
11228 assert_eq!(MAX_SESSION_DISPLAY_NAME_BYTES, 256);
11229 let record = ManagedSessionRecord {
11230 record_id: SessionRecordId::new("session-001").unwrap(),
11231 display_name: "release shepherd".to_owned(),
11232 provider: agent("claude"),
11233 mode: SessionMode::Pty,
11234 state: ManagedSessionState::Dormant,
11235 workspace_id: WorkspaceId::new("primary").unwrap(),
11236 canonical_root: host_path(r"C:\repo"),
11237 provider_session: Some(ProviderSessionIdentity {
11238 key: gate4agent_types::ProviderSessionKey::SessionId,
11239 id: "b1ef3250-47a2-42ca-9076-cc241487ea22".to_owned(),
11240 transcript_path: Some(r"C:\provider\sessions\b1ef3250.jsonl".to_owned()),
11241 }),
11242 active_session: None,
11243 environment_profile: None,
11244 bundle: None,
11245 context_id: None,
11246 context: None,
11247 exported_context: None,
11248 task_binding: None,
11249 created_at_unix_ms: 1_723_000_000_000,
11250 updated_at_unix_ms: 1_723_000_000_123,
11251 last_error: None,
11252 };
11253 assert!(!serde_json::to_string(&record)
11254 .unwrap()
11255 .contains("environment_profile"));
11256
11257 let native_selection = NativeSessionSelection {
11258 route: NativeSessionCatalogRoute::workspace(
11259 record.workspace_id.clone(),
11260 record.provider.clone(),
11261 ),
11262 catalog_revision: 7,
11263 recent_cutoff_unix_ms: 8,
11264 selection_id: "hist_selection_1".to_owned(),
11265 };
11266
11267 let requests = [
11268 NodeRequest::IndexProviderSession {
11269 workspace_id: record.workspace_id.clone(),
11270 provider: record.provider.clone(),
11271 identity: ProviderSessionIdentity {
11272 key: gate4agent_types::ProviderSessionKey::SessionId,
11273 id: "b1ef3250-47a2-42ca-9076-cc241487ea22".to_owned(),
11274 transcript_path: None,
11275 },
11276 display_name: "release shepherd".to_owned(),
11277 },
11278 NodeRequest::IndexNativeSession {
11279 selection: native_selection.clone(),
11280 display_name: "release shepherd".to_owned(),
11281 },
11282 NodeRequest::RenameSessionRecord {
11283 record_id: record.record_id.clone(),
11284 display_name: "release verification".to_owned(),
11285 },
11286 NodeRequest::ResumeSessionRecord {
11287 record_id: record.record_id.clone(),
11288 terminal_size: TerminalSize { rows: 40, columns: 120 },
11289 initial_prompt: None,
11290 },
11291 NodeRequest::ForgetSessionRecord {
11292 record_id: record.record_id.clone(),
11293 },
11294 ];
11295 for request in requests {
11296 let json = serde_json::to_string(&request).unwrap();
11297 assert_eq!(serde_json::from_str::<NodeRequest>(&json).unwrap(), request);
11298 if matches!(request, NodeRequest::IndexProviderSession { .. }) {
11299 assert_eq!(
11300 request.required_capability(),
11301 Some(NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY),
11302 );
11303 }
11304 if matches!(request, NodeRequest::IndexNativeSession { .. }) {
11305 assert_eq!(
11306 request.required_capability(),
11307 Some(NODE_NATIVE_SESSION_INDEX_CAPABILITY),
11308 );
11309 }
11310 }
11311
11312 let native_indexed = NodeResponse::NativeSessionIndexed {
11313 selection: native_selection,
11314 record: record.clone(),
11315 };
11316 assert!(native_indexed.requires_native_session_index_capability());
11317 assert!(native_indexed.native_session_index_contract_is_valid());
11318 let responses = [
11319 NodeResponse::ProviderSessionIndexed {
11320 record: record.clone(),
11321 },
11322 native_indexed,
11323 NodeResponse::SessionRecordUpdated {
11324 record: record.clone(),
11325 },
11326 NodeResponse::SessionRecordResumed {
11327 record: record.clone(),
11328 session: SessionAddress {
11329 workspace_id: record.workspace_id.clone(),
11330 session: SessionKey {
11331 instance_id: AgentInstanceId(8),
11332 generation: SessionGeneration(2),
11333 },
11334 },
11335 },
11336 NodeResponse::SessionRecordForgotten {
11337 record_id: record.record_id.clone(),
11338 },
11339 ];
11340 for response in responses {
11341 let json = serde_json::to_string(&response).unwrap();
11342 assert_eq!(serde_json::from_str::<NodeResponse>(&json).unwrap(), response);
11343 }
11344
11345 let events = [
11346 NodeEvent::SessionRecordUpserted {
11347 record: record.clone(),
11348 },
11349 NodeEvent::SessionRecordRemoved {
11350 record_id: record.record_id.clone(),
11351 },
11352 ];
11353 for event in events {
11354 let json = serde_json::to_string(&event).unwrap();
11355 assert_eq!(serde_json::from_str::<NodeEvent>(&json).unwrap(), event);
11356 }
11357 }
11358
11359 #[test]
11360 fn session_record_ids_are_bounded_validated_wire_values() {
11361 let record_id = SessionRecordId::new("01j4k0jta3eynt5kxef132kr39").unwrap();
11362 assert_eq!(record_id.as_str(), "01j4k0jta3eynt5kxef132kr39");
11363 assert!(SessionRecordId::new("").is_err());
11364 assert!(SessionRecordId::new("Session-1").is_err());
11365 assert!(SessionRecordId::new("-session-1").is_err());
11366 assert!(SessionRecordId::new("x".repeat(MAX_NODE_IDENTIFIER_BYTES + 1)).is_err());
11367
11368 let json = serde_json::to_string(&record_id).unwrap();
11369 assert_eq!(serde_json::from_str::<SessionRecordId>(&json).unwrap(), record_id);
11370 assert!(serde_json::from_str::<SessionRecordId>("\"Session-1\"").is_err());
11371 }
11372
11373 #[test]
11374 fn node_snapshot_defaults_managed_sessions_for_legacy_wire_payloads() {
11375 let legacy = r#"{"node_id":"fixture-node","enabled_providers":[],"workspaces":[]}"#;
11376 let snapshot = serde_json::from_str::<NodeSnapshot>(legacy).unwrap();
11377 assert!(snapshot.session_records.is_empty());
11378 }
11379
11380 fn delivery_manifest() -> DeliveryBundleManifestV2 {
11381 DeliveryBundleManifestV2 {
11382 bundle_id: SpawnBundleId::new("review-bundle").unwrap(),
11383 revision: SpawnBundleRevision::new("review-bundle.r2").unwrap(),
11384 bundle_digest: SpawnBundleDigest::new(format!("sha256:{}", "a".repeat(64)))
11385 .unwrap(),
11386 manifest_digest: DeliveryManifestDigestV2::new(format!(
11387 "sha256:{}",
11388 "b".repeat(64),
11389 ))
11390 .unwrap(),
11391 components: vec![DeliveryComponentV2 {
11392 kind: DeliveryComponentKindV2::AgentDefinition,
11393 scope: DeliveryScopeV2::Workspace,
11394 relative_path: DeliveryRelativePathV2::new("agents/reviewer.md").unwrap(),
11395 blob: DeliveryBlobReceiptV1::new(
11396 DeliveryBlobDigestV1::new(format!("sha256:{}", "c".repeat(64))).unwrap(),
11397 12,
11398 )
11399 .unwrap(),
11400 }],
11401 }
11402 }
11403
11404 #[test]
11405 fn delivery_wire_serialization_is_exact() {
11406 let begin = NodeRequest::BeginDeliveryStage {
11407 manifest: delivery_manifest(),
11408 };
11409 assert_eq!(
11410 serde_json::to_string(&begin).unwrap(),
11411 format!(
11412 r#"{{"kind":"begin-delivery-stage","manifest":{{"bundle_id":"review-bundle","revision":"review-bundle.r2","bundle_digest":"sha256:{}","manifest_digest":"sha256:{}","components":[{{"kind":"agent-definition","scope":"workspace","relative_path":"agents/reviewer.md","blob":{{"digest":"sha256:{}","byte_len":12}}}}]}}}}"#,
11413 "a".repeat(64),
11414 "b".repeat(64),
11415 "c".repeat(64),
11416 ),
11417 );
11418
11419 let chunk = NodeRequest::PutDeliveryBlobChunk {
11420 stage_id: DeliveryStageId::new(format!(
11421 "delivery-stage-{}",
11422 "1".repeat(32),
11423 ))
11424 .unwrap(),
11425 blob_digest: DeliveryBlobDigestV1::new(format!("sha256:{}", "c".repeat(64)))
11426 .unwrap(),
11427 offset: 0,
11428 chunk_hex: DeliveryBlobChunkHexV1::new("00ff").unwrap(),
11429 };
11430 assert_eq!(
11431 serde_json::to_string(&chunk).unwrap(),
11432 format!(
11433 r#"{{"kind":"put-delivery-blob-chunk","stage_id":"delivery-stage-{}","blob_digest":"sha256:{}","offset":0,"chunk_hex":"00ff"}}"#,
11434 "1".repeat(32),
11435 "c".repeat(64),
11436 ),
11437 );
11438
11439 let committed = NodeResponse::DeliveryCommitted {
11440 receipt: DeliveryCommitReceiptV1 {
11441 bundle_id: SpawnBundleId::new("review-bundle").unwrap(),
11442 revision: SpawnBundleRevision::new("review-bundle.r2").unwrap(),
11443 bundle_digest: SpawnBundleDigest::new(format!("sha256:{}", "a".repeat(64)))
11444 .unwrap(),
11445 manifest_digest: DeliveryManifestDigestV2::new(format!(
11446 "sha256:{}",
11447 "b".repeat(64),
11448 ))
11449 .unwrap(),
11450 blobs: vec![DeliveryBlobReceiptV1::new(
11451 DeliveryBlobDigestV1::new(format!("sha256:{}", "c".repeat(64))).unwrap(),
11452 12,
11453 )
11454 .unwrap()],
11455 },
11456 };
11457 assert_eq!(
11458 serde_json::from_str::<NodeResponse>(&serde_json::to_string(&committed).unwrap())
11459 .unwrap(),
11460 committed,
11461 );
11462 }
11463
11464 #[test]
11465 fn delivery_wire_rejects_bounds_order_and_case_fold_collisions() {
11466 assert!(DeliveryRelativePathV2::new("a".repeat(MAX_DELIVERY_RELATIVE_PATH_BYTES))
11467 .is_ok());
11468 assert!(DeliveryRelativePathV2::new("a".repeat(MAX_DELIVERY_RELATIVE_PATH_BYTES + 1))
11469 .is_err());
11470 for invalid in [
11471 "CON",
11472 "con.txt",
11473 "PRN.md",
11474 "AUX",
11475 "NUL.json",
11476 "COM1.txt",
11477 "LPT9",
11478 "trailing.",
11479 "trailing ",
11480 "bad<name",
11481 "bad>name",
11482 "bad\"name",
11483 "bad|name",
11484 "bad?name",
11485 "bad*name",
11486 ] {
11487 assert!(DeliveryRelativePathV2::new(invalid).is_err(), "{invalid}");
11488 }
11489 assert!(DeliveryBlobChunkHexV1::new("00".repeat(MAX_DELIVERY_CHUNK_RAW_BYTES)).is_ok());
11490 assert!(DeliveryBlobChunkHexV1::new("00".repeat(MAX_DELIVERY_CHUNK_RAW_BYTES + 1))
11491 .is_err());
11492 assert!(DeliveryBlobChunkHexV1::new("AA").is_err());
11493 assert!(DeliveryBlobChunkHexV1::new("").is_err());
11494
11495 let mut manifest = delivery_manifest();
11496 let mut collision = manifest.components[0].clone();
11497 collision.relative_path = DeliveryRelativePathV2::new("AGENTS/REVIEWER.MD").unwrap();
11498 manifest.components.push(collision);
11499 manifest.components.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
11500 let json = serde_json::to_string(&manifest).unwrap();
11501 assert!(serde_json::from_str::<DeliveryBundleManifestV2>(&json).is_err());
11502
11503 let mut over_total = delivery_manifest();
11504 over_total.components.clear();
11505 for index in 0..=MAX_DELIVERY_TOTAL_BYTES / MAX_DELIVERY_FILE_BYTES {
11506 over_total.components.push(DeliveryComponentV2 {
11507 kind: DeliveryComponentKindV2::File,
11508 scope: DeliveryScopeV2::Workspace,
11509 relative_path: DeliveryRelativePathV2::new(format!("file-{index:03}.txt"))
11510 .unwrap(),
11511 blob: DeliveryBlobReceiptV1::new(
11512 DeliveryBlobDigestV1::new(format!(
11513 "sha256:{index:064x}",
11514 ))
11515 .unwrap(),
11516 MAX_DELIVERY_FILE_BYTES as u64,
11517 )
11518 .unwrap(),
11519 });
11520 }
11521 assert!(over_total.validate().is_err());
11522 assert!(DeliveryBlobReceiptV1::new(
11523 DeliveryBlobDigestV1::new(format!("sha256:{}", "d".repeat(64))).unwrap(),
11524 MAX_DELIVERY_FILE_BYTES as u64 + 1,
11525 )
11526 .is_err());
11527
11528 let mut too_many = delivery_manifest();
11529 too_many.components = (0..=MAX_DELIVERY_FILES)
11530 .map(|index| DeliveryComponentV2 {
11531 kind: DeliveryComponentKindV2::File,
11532 scope: DeliveryScopeV2::Workspace,
11533 relative_path: DeliveryRelativePathV2::new(format!("file-{index:03}.txt"))
11534 .unwrap(),
11535 blob: DeliveryBlobReceiptV1::new(
11536 DeliveryBlobDigestV1::new(format!("sha256:{index:064x}")).unwrap(),
11537 0,
11538 )
11539 .unwrap(),
11540 })
11541 .collect();
11542 assert!(serde_json::from_str::<DeliveryBundleManifestV2>(
11543 &serde_json::to_string(&too_many).unwrap(),
11544 )
11545 .is_err());
11546
11547 let digest_a = format!("sha256:{}", "a".repeat(64));
11548 let digest_b = format!("sha256:{}", "b".repeat(64));
11549 let unsorted = format!(
11550 r#"{{"kind":"delivery-stage-begun","stage_id":"delivery-stage-{}","manifest_digest":"{}","missing_blobs":["{}","{}"]}}"#,
11551 "1".repeat(32),
11552 digest_a,
11553 digest_b,
11554 digest_a,
11555 );
11556 assert!(serde_json::from_str::<NodeResponse>(&unsorted).is_err());
11557 }
11558
11559 #[test]
11566 fn replay_safety_predicate_admits_only_reads_and_the_harness_mcp_reply() {
11567 assert!(NodeRequest::Snapshot.is_replay_safe());
11568 assert!(NodeRequest::Resync { after_sequence: 0 }.is_replay_safe());
11569 assert!(NodeRequest::ReadWorkspaceFile {
11570 workspace_id: WorkspaceId::new("primary").unwrap(),
11571 path: repository_path("README.md"),
11572 }
11573 .is_replay_safe());
11574 assert!(NodeRequest::ReadContextPack {
11575 digest: SpawnContextDigest::new(format!("sha256:{}", "a".repeat(64))).unwrap(),
11576 }
11577 .is_replay_safe());
11578 assert!(NodeRequest::PutHarnessMcpReplyChunk {
11579 reservation_id: HarnessMcpReservationId::new(format!(
11580 "hmcpres_{}", "a".repeat(24),
11581 )).unwrap(),
11582 activation_digest: HarnessMcpActivationDigest::new(format!(
11583 "sha256:{}", "b".repeat(64),
11584 )).unwrap(),
11585 record_id: SessionRecordId::new("record-a").unwrap(),
11586 session: session_address("primary", 1),
11587 call_id: HarnessMcpCallId::new(format!("hmcpcall_{}", "c".repeat(24))).unwrap(),
11588 offset: 0,
11589 final_chunk: true,
11590 chunk_hex: HarnessMcpReplyChunkHexV1::new("00".repeat(4)).unwrap(),
11591 }
11592 .is_replay_safe());
11593
11594 assert!(!NodeRequest::Shutdown.is_replay_safe());
11595 assert!(!NodeRequest::AcquireController { lease_ms: 1_000 }.is_replay_safe());
11596 assert!(!NodeRequest::ActivateHarnessMcpReservation {
11597 reservation_id: HarnessMcpReservationId::new(format!(
11598 "hmcpres_{}", "a".repeat(24),
11599 )).unwrap(),
11600 activation_digest: HarnessMcpActivationDigest::new(format!(
11601 "sha256:{}", "b".repeat(64),
11602 )).unwrap(),
11603 record_id: SessionRecordId::new("record-a").unwrap(),
11604 session: session_address("primary", 1),
11605 }
11606 .is_replay_safe());
11607 assert!(!NodeRequest::ExportContextPack { session: session_address("primary", 1) }
11608 .is_replay_safe());
11609 }
11610}