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