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