1use crate::protocol::{
2 ManagedWorktreeLeaseId, NodeIncarnationId, OpaqueHostPath,
3 ResolvedBundleReceipt, ResolvedContextPackReceipt,
4 ResolvedEnvironmentProfileReceipt, SessionRecordId,
5};
6use crate::bundle_catalog::NodeBundle;
7use crate::bundle_provider::BundleProviderLayout;
8use crate::context_pack::NodeContextPack;
9use gate4agent_catalog::EnvMutation;
10use gate4agent_types::{AgentInstanceId, SessionGeneration};
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use std::collections::{BTreeMap, BTreeSet};
13use std::ffi::{OsStr, OsString};
14use std::fs::{self, File, OpenOptions};
15use std::io::{self, Read, Write};
16use std::path::{Component, Path, PathBuf};
17use std::sync::Arc;
18use thiserror::Error;
19
20pub const MAX_NODE_SECRET_REFERENCE_BYTES: usize = 256;
21pub const MAX_NODE_SECRET_VALUE_BYTES: usize = 256 * 1024;
22pub const MAX_SESSION_ENVIRONMENT_ENTRIES: usize = 128;
23pub const MAX_SESSION_MATERIALIZATION_FILES: usize = 128;
24pub const MAX_SESSION_MATERIALIZATION_FILE_BYTES: usize = 1024 * 1024;
25pub const MAX_SESSION_MATERIALIZATION_RELATIVE_PATH_BYTES: usize = 512;
26pub(crate) const MAX_SESSION_MATERIALIZATIONS: usize = 4_096;
27
28const MATERIALIZATION_ROOT_MARKER: &[u8] = b"gate4agent-node-session-environment-v1\n";
29const MATERIALIZATION_OWNER_MARKER: &str = ".gate4agent-materialization-owner";
30const MATERIALIZATION_ROOT_MARKER_NAME: &str = ".gate4agent-materialization-root";
31const MATERIALIZATION_LOCK_NAME: &str = ".gate4agent-materialization-lock";
32const CODEX_HOME_ENVIRONMENT_KEY: &str = "CODEX_HOME";
56const CONTEXT_ROOT_ENVIRONMENT_KEY: &str = "GATE4AGENT_CONTEXT_ROOT";
57pub(crate) const CONTEXT_PACK_FILE_NAME: &str = "context-pack.json";
58
59#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
60pub struct NodeSecretReference(String);
61
62impl NodeSecretReference {
63 pub fn new(value: impl Into<String>) -> Result<Self, NodeSessionMaterializationProfileError> {
64 let value = value.into();
65 if !valid_opaque_identifier(&value, MAX_NODE_SECRET_REFERENCE_BYTES) {
66 return Err(NodeSessionMaterializationProfileError::InvalidSecretReference);
67 }
68 Ok(Self(value))
69 }
70
71 pub fn as_str(&self) -> &str {
72 &self.0
73 }
74}
75
76pub enum NodeSecretValue {
77 Text(String),
78 Bytes(Vec<u8>),
79}
80
81impl NodeSecretValue {
82 pub fn text(value: impl Into<String>) -> Result<Self, NodeSecretValueError> {
83 let value = value.into();
84 if value.is_empty() || value.len() > MAX_NODE_SECRET_VALUE_BYTES || value.contains('\0') {
85 return Err(NodeSecretValueError::Invalid);
86 }
87 Ok(Self::Text(value))
88 }
89
90 pub fn bytes(value: Vec<u8>) -> Result<Self, NodeSecretValueError> {
91 if value.is_empty() || value.len() > MAX_NODE_SECRET_VALUE_BYTES {
92 return Err(NodeSecretValueError::Invalid);
93 }
94 Ok(Self::Bytes(value))
95 }
96
97 fn into_environment_value(self) -> Result<OsString, SessionEnvironmentMaterializeError> {
98 match self {
99 Self::Text(value) => Ok(OsString::from(value)),
100 Self::Bytes(value) => String::from_utf8(value)
101 .map(OsString::from)
102 .map_err(|_| SessionEnvironmentMaterializeError::InvalidSecretValue),
103 }
104 }
105
106 fn into_file_bytes(self) -> Vec<u8> {
107 match self {
108 Self::Text(value) => value.into_bytes(),
109 Self::Bytes(value) => value,
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
115pub enum NodeSecretValueError {
116 #[error("secret value is outside the bounded value contract")]
117 Invalid,
118}
119
120pub trait NodeSecretResolver: Send + Sync + 'static {
121 fn resolve(
122 &self,
123 reference: &NodeSecretReference,
124 ) -> Result<NodeSecretValue, NodeSecretResolveError>;
125}
126
127#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
128pub enum NodeSecretResolveError {
129 #[error("secret is unavailable")]
130 Unavailable,
131 #[error("secret access is denied")]
132 Denied,
133}
134
135#[derive(Clone)]
136pub enum NodeSessionEnvironmentMutation {
137 SetNonSecret { key: String, value: String },
138 SetSecret { key: String, reference: NodeSecretReference },
139 Remove { key: String },
140}
141
142#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
143#[serde(rename_all = "kebab-case")]
144pub enum NodeSessionPathClass {
145 ProviderHome,
146 Config,
147 Cache,
148 Data,
149 State,
150 Tmp,
151 #[doc(hidden)]
152 BundleRoot,
153 #[doc(hidden)]
154 PluginData,
155 #[doc(hidden)]
156 Context,
157}
158
159impl NodeSessionPathClass {
160 fn directory_name(self) -> &'static str {
161 match self {
162 Self::ProviderHome => "home",
163 Self::Config => "config",
164 Self::Cache => "cache",
165 Self::Data => "data",
166 Self::State => "state",
167 Self::Tmp => "tmp",
168 Self::BundleRoot => "bundle",
169 Self::PluginData => "plugin-data",
170 Self::Context => "context",
171 }
172 }
173}
174
175#[derive(Clone, Debug, Eq, PartialEq)]
176pub struct NodeSessionPathBinding {
177 key: String,
178 class: NodeSessionPathClass,
179}
180
181impl NodeSessionPathBinding {
182 pub fn new(
183 key: impl Into<String>,
184 class: NodeSessionPathClass,
185 ) -> Result<Self, NodeSessionMaterializationProfileError> {
186 let key = key.into();
187 validate_environment_key(&key)?;
188 Ok(Self { key, class })
189 }
190}
191
192#[derive(Clone)]
193pub enum NodeSessionFile {
194 Generated {
195 class: NodeSessionPathClass,
196 relative_path: PathBuf,
197 contents: Vec<u8>,
198 },
199 Secret {
200 class: NodeSessionPathClass,
201 relative_path: PathBuf,
202 reference: NodeSecretReference,
203 },
204}
205
206impl NodeSessionFile {
207 pub fn generated(
208 class: NodeSessionPathClass,
209 relative_path: impl Into<PathBuf>,
210 contents: Vec<u8>,
211 ) -> Result<Self, NodeSessionMaterializationProfileError> {
212 let relative_path = relative_path.into();
213 validate_relative_path(&relative_path)?;
214 if contents.len() > MAX_SESSION_MATERIALIZATION_FILE_BYTES {
215 return Err(NodeSessionMaterializationProfileError::FileTooLarge);
216 }
217 Ok(Self::Generated { class, relative_path, contents })
218 }
219
220 pub fn secret(
221 class: NodeSessionPathClass,
222 relative_path: impl Into<PathBuf>,
223 reference: NodeSecretReference,
224 ) -> Result<Self, NodeSessionMaterializationProfileError> {
225 let relative_path = relative_path.into();
226 validate_relative_path(&relative_path)?;
227 Ok(Self::Secret { class, relative_path, reference })
228 }
229
230 fn declaration(&self) -> MaterializedPathDeclaration {
231 match self {
232 Self::Generated { class, relative_path, .. } => MaterializedPathDeclaration {
233 class: *class,
234 relative_path: relative_path.clone(),
235 kind: MaterializedPathKind::Generated,
236 },
237 Self::Secret { class, relative_path, .. } => MaterializedPathDeclaration {
238 class: *class,
239 relative_path: relative_path.clone(),
240 kind: MaterializedPathKind::Secret,
241 },
242 }
243 }
244}
245
246struct NodeSessionMaterializationProfileInner {
247 environment: Vec<NodeSessionEnvironmentMutation>,
248 path_bindings: Vec<NodeSessionPathBinding>,
249 files: Vec<NodeSessionFile>,
250}
251
252#[derive(Clone)]
253pub struct NodeSessionMaterializationProfile(Arc<NodeSessionMaterializationProfileInner>);
254
255impl NodeSessionMaterializationProfile {
256 pub fn new(
257 environment: Vec<NodeSessionEnvironmentMutation>,
258 path_bindings: Vec<NodeSessionPathBinding>,
259 files: Vec<NodeSessionFile>,
260 ) -> Result<Self, NodeSessionMaterializationProfileError> {
261 if environment.len() + path_bindings.len() > MAX_SESSION_ENVIRONMENT_ENTRIES {
262 return Err(NodeSessionMaterializationProfileError::TooManyEnvironmentEntries);
263 }
264 if files.len() > MAX_SESSION_MATERIALIZATION_FILES {
265 return Err(NodeSessionMaterializationProfileError::TooManyFiles);
266 }
267 let mut environment_keys = BTreeSet::new();
268 for mutation in &environment {
269 let key = match mutation {
270 NodeSessionEnvironmentMutation::SetNonSecret { key, value } => {
271 if value.len() > MAX_NODE_SECRET_VALUE_BYTES || value.contains('\0') {
272 return Err(NodeSessionMaterializationProfileError::InvalidEnvironmentValue);
273 }
274 key
275 }
276 NodeSessionEnvironmentMutation::SetSecret { key, .. }
277 | NodeSessionEnvironmentMutation::Remove { key } => key,
278 };
279 validate_environment_key(key)?;
280 if !environment_keys.insert(normalized_environment_key(key)) {
281 return Err(NodeSessionMaterializationProfileError::DuplicateEnvironmentKey);
282 }
283 }
284 for binding in &path_bindings {
285 if !environment_keys.insert(normalized_environment_key(&binding.key)) {
286 return Err(NodeSessionMaterializationProfileError::DuplicateEnvironmentKey);
287 }
288 }
289 let declarations = files.iter().map(NodeSessionFile::declaration).collect::<Vec<_>>();
290 for (index, left) in declarations.iter().enumerate() {
291 for right in declarations.iter().skip(index + 1) {
292 if left.class == right.class
293 && (left.relative_path == right.relative_path
294 || left.relative_path.starts_with(&right.relative_path)
295 || right.relative_path.starts_with(&left.relative_path))
296 {
297 return Err(NodeSessionMaterializationProfileError::ConflictingFilePath);
298 }
299 }
300 }
301 Ok(Self(Arc::new(NodeSessionMaterializationProfileInner {
302 environment,
303 path_bindings,
304 files,
305 })))
306 }
307
308 pub fn is_empty(&self) -> bool {
309 self.0.environment.is_empty() && self.0.path_bindings.is_empty() && self.0.files.is_empty()
310 }
311
312 pub(crate) fn supports_bundle_layout(&self, layout: BundleProviderLayout) -> bool {
313 layout != BundleProviderLayout::Codex
314 || (self.0.path_bindings.iter().any(|binding| {
315 normalized_environment_key(&binding.key)
316 == normalized_environment_key(CODEX_HOME_ENVIRONMENT_KEY)
317 && binding.class == NodeSessionPathClass::ProviderHome
318 })
319 && self.0.path_bindings.iter().all(|binding| {
320 binding.class != NodeSessionPathClass::BundleRoot
321 })
322 && self.0.files.iter().all(|file| {
323 let declaration = file.declaration();
324 declaration.class != NodeSessionPathClass::BundleRoot
325 && !(declaration.class == NodeSessionPathClass::ProviderHome
326 && declaration.relative_path.starts_with("skills"))
327 }))
328 }
329
330 pub(crate) fn from_bundle(
331 bundle: &NodeBundle,
332 layout: BundleProviderLayout,
333 ) -> Result<Self, BundleProfileCompositionError> {
334 if layout == BundleProviderLayout::Codex {
335 return Err(BundleProfileCompositionError::InvalidCodexProfile);
336 }
337 Ok(Self::new(
338 Vec::new(),
339 Vec::new(),
340 bundle_profile_files(bundle, layout)?,
341 )?)
342 }
343
344 pub(crate) fn with_bundle(
345 &self,
346 bundle: &NodeBundle,
347 layout: BundleProviderLayout,
348 ) -> Result<Self, BundleProfileCompositionError> {
349 if !self.supports_bundle_layout(layout) {
350 return Err(BundleProfileCompositionError::InvalidCodexProfile);
351 }
352 let mut files = self.0.files.clone();
353 files.extend(bundle_profile_files(bundle, layout)?);
354 Ok(Self::new(
355 self.0.environment.clone(),
356 self.0.path_bindings.clone(),
357 files,
358 )?)
359 }
360
361 pub(crate) fn from_context(
362 context: &NodeContextPack,
363 ) -> Result<Self, NodeSessionMaterializationProfileError> {
364 Self::new(
365 Vec::new(),
366 vec![NodeSessionPathBinding::new(
367 CONTEXT_ROOT_ENVIRONMENT_KEY,
368 NodeSessionPathClass::Context,
369 )?],
370 vec![NodeSessionFile::generated(
371 NodeSessionPathClass::Context,
372 CONTEXT_PACK_FILE_NAME,
373 context.bytes().to_vec(),
374 )?],
375 )
376 }
377
378 pub(crate) fn with_context(
379 &self,
380 context: &NodeContextPack,
381 ) -> Result<Self, NodeSessionMaterializationProfileError> {
382 let mut path_bindings = self.0.path_bindings.clone();
383 path_bindings.push(NodeSessionPathBinding::new(
384 CONTEXT_ROOT_ENVIRONMENT_KEY,
385 NodeSessionPathClass::Context,
386 )?);
387 let mut files = self.0.files.clone();
388 files.push(NodeSessionFile::generated(
389 NodeSessionPathClass::Context,
390 CONTEXT_PACK_FILE_NAME,
391 context.bytes().to_vec(),
392 )?);
393 Self::new(self.0.environment.clone(), path_bindings, files)
394 }
395}
396
397fn bundle_profile_files(
398 bundle: &NodeBundle,
399 layout: BundleProviderLayout,
400) -> Result<Vec<NodeSessionFile>, NodeSessionMaterializationProfileError> {
401 bundle
402 .files()
403 .iter()
404 .filter_map(|file| match layout {
405 BundleProviderLayout::Claude | BundleProviderLayout::Kimi => Some((
406 file,
407 NodeSessionPathClass::BundleRoot,
408 PathBuf::from(file.path()),
409 )),
410 BundleProviderLayout::Codex => file.path().strip_prefix("skills/").map(|_| {
411 (
412 file,
413 NodeSessionPathClass::ProviderHome,
414 PathBuf::from(file.path()),
415 )
416 }),
417 })
418 .map(|(file, class, path)| {
419 NodeSessionFile::generated(class, path, file.bytes().to_vec())
420 })
421 .collect()
422}
423
424#[derive(Debug, Error)]
425pub(crate) enum BundleProfileCompositionError {
426 #[error("the Codex bundle layout requires an exclusive CODEX_HOME ProviderHome profile")]
427 InvalidCodexProfile,
428 #[error("the bundle materialization profile is invalid")]
429 Profile(#[from] NodeSessionMaterializationProfileError),
430}
431
432#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
433pub enum NodeSessionMaterializationProfileError {
434 #[error("secret reference is outside the bounded opaque identifier contract")]
435 InvalidSecretReference,
436 #[error("environment key is invalid")]
437 InvalidEnvironmentKey,
438 #[error("environment value is invalid")]
439 InvalidEnvironmentValue,
440 #[error("materialization profile contains duplicate normalized environment keys")]
441 DuplicateEnvironmentKey,
442 #[error("materialization profile contains too many environment entries")]
443 TooManyEnvironmentEntries,
444 #[error("materialization profile contains too many files")]
445 TooManyFiles,
446 #[error("materialization file exceeds the bounded size")]
447 FileTooLarge,
448 #[error("materialization relative path is invalid")]
449 InvalidRelativePath,
450 #[error("materialization file paths overlap")]
451 ConflictingFilePath,
452}
453
454#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
455pub(crate) struct MaterializationId(String);
456
457impl MaterializationId {
458 pub(crate) fn new(value: impl Into<String>) -> Result<Self, MaterializationRecordError> {
459 let value = value.into();
460 if !valid_materialization_id(&value) {
461 return Err(MaterializationRecordError::InvalidId);
462 }
463 Ok(Self(value))
464 }
465
466 pub(crate) fn as_str(&self) -> &str {
467 &self.0
468 }
469}
470
471impl Serialize for MaterializationId {
472 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
473 where
474 S: Serializer,
475 {
476 serializer.serialize_str(&self.0)
477 }
478}
479
480impl<'de> Deserialize<'de> for MaterializationId {
481 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
482 where
483 D: Deserializer<'de>,
484 {
485 let value = String::deserialize(deserializer)?;
486 Self::new(value).map_err(serde::de::Error::custom)
487 }
488}
489
490#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
491#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
492pub(crate) enum MaterializationOwner {
493 Session {
494 incarnation_id: NodeIncarnationId,
495 instance_id: AgentInstanceId,
496 generation: SessionGeneration,
497 },
498 Record {
499 record_id: SessionRecordId,
500 },
501}
502
503#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
504#[serde(rename_all = "kebab-case")]
505pub(crate) enum MaterializationState {
506 Preparing,
507 Ready,
508 CleanupRequired,
509 RecoveryRequired,
510}
511
512#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
513#[serde(rename_all = "kebab-case")]
514pub(crate) enum MaterializedPathKind {
515 Generated,
516 Secret,
517}
518
519#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
520#[serde(deny_unknown_fields)]
521pub(crate) struct MaterializedPathDeclaration {
522 pub(crate) class: NodeSessionPathClass,
523 pub(crate) relative_path: PathBuf,
524 pub(crate) kind: MaterializedPathKind,
525}
526
527#[derive(Clone, Eq, PartialEq)]
528pub(crate) struct MaterializationOwnershipRecord {
529 id: MaterializationId,
530 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
531 bundle: Option<ResolvedBundleReceipt>,
532 context: Option<ResolvedContextPackReceipt>,
533 owner: MaterializationOwner,
534 managed_lease_id: Option<ManagedWorktreeLeaseId>,
535 state: MaterializationState,
536 root: PathBuf,
537 provider_home: PathBuf,
538 bundle_root: PathBuf,
539 plugin_data: PathBuf,
540 declared_paths: Vec<MaterializedPathDeclaration>,
541 created_at_unix_ms: u64,
542 updated_at_unix_ms: u64,
543}
544
545impl MaterializationOwnershipRecord {
546 #[allow(clippy::too_many_arguments)]
547 pub(crate) fn from_persisted(
548 id: MaterializationId,
549 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
550 bundle: Option<ResolvedBundleReceipt>,
551 context: Option<ResolvedContextPackReceipt>,
552 owner: MaterializationOwner,
553 managed_lease_id: Option<ManagedWorktreeLeaseId>,
554 state: MaterializationState,
555 root: PathBuf,
556 provider_home: PathBuf,
557 bundle_root: PathBuf,
558 plugin_data: PathBuf,
559 declared_paths: Vec<MaterializedPathDeclaration>,
560 created_at_unix_ms: u64,
561 updated_at_unix_ms: u64,
562 ) -> Result<Self, MaterializationRecordError> {
563 let record = Self {
564 id,
565 environment_profile,
566 bundle,
567 context,
568 owner,
569 managed_lease_id,
570 state,
571 root,
572 provider_home,
573 bundle_root,
574 plugin_data,
575 declared_paths,
576 created_at_unix_ms,
577 updated_at_unix_ms,
578 };
579 record.validate()?;
580 Ok(record)
581 }
582
583 pub(crate) fn id(&self) -> &MaterializationId { &self.id }
584 pub(crate) fn environment_profile(&self) -> Option<&ResolvedEnvironmentProfileReceipt> { self.environment_profile.as_ref() }
585 pub(crate) fn bundle(&self) -> Option<&ResolvedBundleReceipt> { self.bundle.as_ref() }
586 pub(crate) fn context(&self) -> Option<&ResolvedContextPackReceipt> { self.context.as_ref() }
587 pub(crate) fn owner(&self) -> &MaterializationOwner { &self.owner }
588 pub(crate) fn managed_lease_id(&self) -> Option<&ManagedWorktreeLeaseId> { self.managed_lease_id.as_ref() }
589 pub(crate) fn state(&self) -> MaterializationState { self.state }
590 pub(crate) fn root(&self) -> &Path { &self.root }
591 pub(crate) fn provider_home(&self) -> &Path { &self.provider_home }
592 pub(crate) fn bundle_root(&self) -> &Path { &self.bundle_root }
593 pub(crate) fn plugin_data(&self) -> &Path { &self.plugin_data }
594 pub(crate) fn declared_paths(&self) -> &[MaterializedPathDeclaration] { &self.declared_paths }
595 pub(crate) fn created_at_unix_ms(&self) -> u64 { self.created_at_unix_ms }
596 pub(crate) fn updated_at_unix_ms(&self) -> u64 { self.updated_at_unix_ms }
597
598 pub(crate) fn mark_ready(&mut self, now: u64) -> Result<(), MaterializationRecordError> {
599 self.transition(MaterializationState::Preparing, MaterializationState::Ready, now)
600 }
601
602 pub(crate) fn transfer_to_record(
603 &mut self,
604 record_id: SessionRecordId,
605 now: u64,
606 ) -> Result<(), MaterializationRecordError> {
607 if self.state != MaterializationState::Ready || !matches!(self.owner, MaterializationOwner::Session { .. }) {
608 return Err(MaterializationRecordError::InvalidTransition);
609 }
610 self.owner = MaterializationOwner::Record { record_id };
611 self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
612 Ok(())
613 }
614
615 pub(crate) fn transfer_record_owner(
616 &mut self,
617 expected_record_id: &SessionRecordId,
618 replacement_record_id: SessionRecordId,
619 now: u64,
620 ) -> Result<(), MaterializationRecordError> {
621 if self.state != MaterializationState::Ready
622 || !matches!(
623 &self.owner,
624 MaterializationOwner::Record { record_id }
625 if record_id == expected_record_id
626 )
627 {
628 return Err(MaterializationRecordError::InvalidTransition);
629 }
630 self.owner = MaterializationOwner::Record {
631 record_id: replacement_record_id,
632 };
633 self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
634 Ok(())
635 }
636
637 pub(crate) fn mark_cleanup_required(&mut self, now: u64) -> Result<(), MaterializationRecordError> {
638 if !matches!(self.state, MaterializationState::Preparing | MaterializationState::Ready | MaterializationState::RecoveryRequired) {
639 return Err(MaterializationRecordError::InvalidTransition);
640 }
641 self.state = MaterializationState::CleanupRequired;
642 self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
643 Ok(())
644 }
645
646 pub(crate) fn mark_recovery_required(&mut self, now: u64) -> Result<(), MaterializationRecordError> {
647 if self.state == MaterializationState::RecoveryRequired {
648 return Ok(());
649 }
650 self.state = MaterializationState::RecoveryRequired;
651 self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
652 Ok(())
653 }
654
655 fn transition(&mut self, from: MaterializationState, to: MaterializationState, now: u64) -> Result<(), MaterializationRecordError> {
656 if self.state != from { return Err(MaterializationRecordError::InvalidTransition); }
657 self.state = to;
658 self.updated_at_unix_ms = checked_timestamp(self.created_at_unix_ms, now)?;
659 Ok(())
660 }
661
662 pub(crate) fn validate(&self) -> Result<(), MaterializationRecordError> {
663 if (self.environment_profile.is_none() && self.bundle.is_none() && self.context.is_none())
664 || !self.root.is_absolute()
665 || !self.provider_home.is_absolute()
666 || !self.bundle_root.is_absolute()
667 || !self.plugin_data.is_absolute()
668 || self.provider_home != self.root.join(NodeSessionPathClass::ProviderHome.directory_name())
669 || self.bundle_root != self.root.join(NodeSessionPathClass::BundleRoot.directory_name())
670 || self.plugin_data != self.root.join(NodeSessionPathClass::PluginData.directory_name())
671 || self.created_at_unix_ms > self.updated_at_unix_ms
672 || self.declared_paths.len() > MAX_SESSION_MATERIALIZATION_FILES
673 {
674 return Err(MaterializationRecordError::InvalidRecord);
675 }
676 let mut seen = BTreeSet::new();
677 for (index, declaration) in self.declared_paths.iter().enumerate() {
678 validate_relative_path(&declaration.relative_path).map_err(|_| MaterializationRecordError::InvalidRecord)?;
679 if !seen.insert((declaration.class, declaration.relative_path.clone())) {
680 return Err(MaterializationRecordError::InvalidRecord);
681 }
682 for other in self.declared_paths.iter().skip(index + 1) {
683 if declaration.class == other.class
684 && (declaration.relative_path.starts_with(&other.relative_path)
685 || other.relative_path.starts_with(&declaration.relative_path))
686 {
687 return Err(MaterializationRecordError::InvalidRecord);
688 }
689 }
690 }
691 Ok(())
692 }
693}
694
695#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
696pub(crate) enum MaterializationRecordError {
697 #[error("materialization ID is invalid")]
698 InvalidId,
699 #[error("materialization ownership record is invalid")]
700 InvalidRecord,
701 #[error("materialization state transition is invalid")]
702 InvalidTransition,
703}
704
705#[cfg(test)]
706struct PreparedSessionEnvironment {
707 environment: Vec<EnvMutation>,
708 ownership: MaterializationOwnershipRecord,
709}
710
711#[cfg(test)]
712impl PreparedSessionEnvironment {
713 fn environment(&self) -> &[EnvMutation] { &self.environment }
714 fn into_parts(self) -> (Vec<EnvMutation>, MaterializationOwnershipRecord) {
715 (self.environment, self.ownership)
716 }
717}
718
719pub(crate) struct SessionEnvironmentMaterializer {
720 root: PathBuf,
721 resolver: Arc<dyn NodeSecretResolver>,
722 _lock: MaterializationRootLock,
723}
724
725impl SessionEnvironmentMaterializer {
726 pub(crate) fn new(
727 root: PathBuf,
728 resolver: Arc<dyn NodeSecretResolver>,
729 ) -> Result<Self, SessionEnvironmentMaterializeError> {
730 if !root.is_absolute() {
731 return Err(SessionEnvironmentMaterializeError::InvalidRoot);
732 }
733 ensure_materialization_root(&root)?;
734 let lock = MaterializationRootLock::acquire(&root.join(MATERIALIZATION_LOCK_NAME))?;
735 verify_or_create_exact_file(&root.join(MATERIALIZATION_ROOT_MARKER_NAME), MATERIALIZATION_ROOT_MARKER)?;
736 Ok(Self { root, resolver, _lock: lock })
737 }
738
739 #[allow(clippy::too_many_arguments)]
740 pub(crate) fn begin(
741 &self,
742 id: MaterializationId,
743 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
744 bundle: Option<ResolvedBundleReceipt>,
745 context: Option<ResolvedContextPackReceipt>,
746 owner: MaterializationOwner,
747 managed_lease_id: Option<ManagedWorktreeLeaseId>,
748 profile: &NodeSessionMaterializationProfile,
749 now: u64,
750 ) -> Result<MaterializationOwnershipRecord, SessionEnvironmentMaterializeError> {
751 let materialization_root = self.root.join(id.as_str());
752 MaterializationOwnershipRecord::from_persisted(
753 id,
754 environment_profile,
755 bundle,
756 context,
757 owner,
758 managed_lease_id,
759 MaterializationState::Preparing,
760 materialization_root.clone(),
761 materialization_root.join(NodeSessionPathClass::ProviderHome.directory_name()),
762 materialization_root.join(NodeSessionPathClass::BundleRoot.directory_name()),
763 materialization_root.join(NodeSessionPathClass::PluginData.directory_name()),
764 profile.0.files.iter().map(NodeSessionFile::declaration).collect(),
765 now,
766 now,
767 )
768 .map_err(Into::into)
769 }
770
771 pub(crate) fn materialize(
772 &self,
773 ownership: &mut MaterializationOwnershipRecord,
774 profile: &NodeSessionMaterializationProfile,
775 now: u64,
776 ) -> Result<Vec<EnvMutation>, SessionEnvironmentMaterializeError> {
777 ownership.validate()?;
778 if ownership.state != MaterializationState::Preparing
779 || ownership.root.parent() != Some(self.root.as_path())
780 || ownership.root.file_name() != Some(OsStr::new(ownership.id.as_str()))
781 || ownership.declared_paths
782 != profile.0.files.iter().map(NodeSessionFile::declaration).collect::<Vec<_>>()
783 {
784 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
785 }
786 let materialization_root = ownership.root.clone();
787 if let Err(error) = secure_create_directory(&materialization_root) {
788 mark_materialization_failure(ownership, now, !materialization_root.exists());
789 return Err(error.into());
790 }
791 let marker = materialization_owner_marker(
792 &ownership.id,
793 ownership.environment_profile.as_ref(),
794 ownership.bundle.as_ref(),
795 ownership.context.as_ref(),
796 );
797 if let Err(error) = secure_create_file(&materialization_root.join(MATERIALIZATION_OWNER_MARKER), marker.as_bytes()) {
798 let absence_proven = fs::remove_dir(&materialization_root).is_ok()
799 && !materialization_root.exists();
800 mark_materialization_failure(ownership, now, absence_proven);
801 return Err(error.into());
802 }
803 let result = self.populate(&materialization_root, profile);
804 if let Err(error) = result {
805 let absence_proven = remove_owned_tree(&materialization_root, marker.as_bytes()).is_ok()
806 && !materialization_root.exists();
807 mark_materialization_failure(ownership, now, absence_proven);
808 return Err(error);
809 }
810 ownership.mark_ready(now)?;
811 result
812 }
813
814 #[cfg(test)]
815 #[allow(clippy::too_many_arguments)]
816 fn prepare(
817 &self,
818 id: MaterializationId,
819 environment_profile: ResolvedEnvironmentProfileReceipt,
820 owner: MaterializationOwner,
821 managed_lease_id: Option<ManagedWorktreeLeaseId>,
822 profile: &NodeSessionMaterializationProfile,
823 now: u64,
824 ) -> Result<PreparedSessionEnvironment, SessionEnvironmentMaterializeError> {
825 let mut ownership = self.begin(
826 id,
827 Some(environment_profile),
828 None,
829 None,
830 owner,
831 managed_lease_id,
832 profile,
833 now,
834 )?;
835 let environment = self.materialize(&mut ownership, profile, now)?;
836 Ok(PreparedSessionEnvironment { environment, ownership })
837 }
838
839 fn populate(
840 &self,
841 materialization_root: &Path,
842 profile: &NodeSessionMaterializationProfile,
843 ) -> Result<Vec<EnvMutation>, SessionEnvironmentMaterializeError> {
844 let mut class_roots = Vec::new();
845 for class in [
846 NodeSessionPathClass::ProviderHome,
847 NodeSessionPathClass::Config,
848 NodeSessionPathClass::Cache,
849 NodeSessionPathClass::Data,
850 NodeSessionPathClass::State,
851 NodeSessionPathClass::Tmp,
852 NodeSessionPathClass::BundleRoot,
853 NodeSessionPathClass::PluginData,
854 NodeSessionPathClass::Context,
855 ] {
856 let path = materialization_root.join(class.directory_name());
857 secure_create_directory(&path)?;
858 class_roots.push((class, path));
859 }
860 let path_for = |class| class_roots.iter().find(|(candidate, _)| *candidate == class).map(|(_, path)| path).expect("all classes exist");
861 let environment = self.resolve_overlay(profile, &|class| path_for(class).clone())?;
862 for file in &profile.0.files {
863 let (class, relative_path, bytes) = match file {
864 NodeSessionFile::Generated { class, relative_path, contents } => (*class, relative_path, contents.clone()),
865 NodeSessionFile::Secret { class, relative_path, reference } => (*class, relative_path, self.resolver.resolve(reference)?.into_file_bytes()),
866 };
867 let destination = path_for(class).join(relative_path);
868 secure_create_parent_directories(path_for(class), relative_path.parent())?;
869 secure_create_file(&destination, &bytes)?;
870 }
871 Ok(environment)
872 }
873
874 pub(crate) fn resolve_environment(
875 &self,
876 ownership: &MaterializationOwnershipRecord,
877 profile: &NodeSessionMaterializationProfile,
878 ) -> Result<Vec<EnvMutation>, SessionEnvironmentMaterializeError> {
879 if ownership.state != MaterializationState::Ready
880 || ownership.declared_paths
881 != profile.0.files.iter().map(NodeSessionFile::declaration).collect::<Vec<_>>()
882 {
883 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
884 }
885 self.revalidate(ownership)?;
886 let path_for = |class: NodeSessionPathClass| ownership.root.join(class.directory_name());
887 for file in &profile.0.files {
888 if let NodeSessionFile::Secret { class, relative_path, reference } = file {
889 let bytes = self.resolver.resolve(reference)?.into_file_bytes();
890 secure_replace_file(&path_for(*class).join(relative_path), &bytes)?;
891 }
892 }
893 self.resolve_overlay(profile, &|class| path_for(class))
894 }
895
896 fn resolve_overlay<P>(
897 &self,
898 profile: &NodeSessionMaterializationProfile,
899 path_for: &P,
900 ) -> Result<Vec<EnvMutation>, SessionEnvironmentMaterializeError>
901 where
902 P: Fn(NodeSessionPathClass) -> PathBuf,
903 {
904 let mut environment = Vec::with_capacity(profile.0.environment.len() + profile.0.path_bindings.len());
905 for mutation in &profile.0.environment {
906 environment.push(match mutation {
907 NodeSessionEnvironmentMutation::SetNonSecret { key, value } => EnvMutation { key: OsString::from(key), value: Some(OsString::from(value)) },
908 NodeSessionEnvironmentMutation::SetSecret { key, reference } => EnvMutation {
909 key: OsString::from(key),
910 value: Some(self.resolver.resolve(reference)?.into_environment_value()?),
911 },
912 NodeSessionEnvironmentMutation::Remove { key } => EnvMutation { key: OsString::from(key), value: None },
913 });
914 }
915 for binding in &profile.0.path_bindings {
916 environment.push(EnvMutation { key: OsString::from(&binding.key), value: Some(path_for(binding.class).into_os_string()) });
917 }
918 Ok(environment)
919 }
920
921 pub(crate) fn revalidate(
922 &self,
923 ownership: &MaterializationOwnershipRecord,
924 ) -> Result<(), SessionEnvironmentMaterializeError> {
925 ownership.validate()?;
926 if ownership.state != MaterializationState::Ready
927 || ownership.root.parent() != Some(self.root.as_path())
928 || ownership.root.file_name() != Some(OsStr::new(ownership.id.as_str()))
929 {
930 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
931 }
932 validate_secure_directory(&ownership.root)?;
933 let expected = materialization_owner_marker(
934 &ownership.id,
935 ownership.environment_profile.as_ref(),
936 ownership.bundle.as_ref(),
937 ownership.context.as_ref(),
938 );
939 let marker = ownership.root.join(MATERIALIZATION_OWNER_MARKER);
940 validate_secure_file(&marker)?;
941 let mut bytes = Vec::new();
942 File::open(marker)?.take(4096).read_to_end(&mut bytes)?;
943 if bytes != expected.as_bytes() {
944 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
945 }
946 for class in [
947 NodeSessionPathClass::ProviderHome,
948 NodeSessionPathClass::Config,
949 NodeSessionPathClass::Cache,
950 NodeSessionPathClass::Data,
951 NodeSessionPathClass::State,
952 NodeSessionPathClass::Tmp,
953 NodeSessionPathClass::BundleRoot,
954 NodeSessionPathClass::PluginData,
955 NodeSessionPathClass::Context,
956 ] {
957 validate_secure_directory(&ownership.root.join(class.directory_name()))?;
958 }
959 for declaration in &ownership.declared_paths {
960 validate_secure_file(
961 &ownership.root
962 .join(declaration.class.directory_name())
963 .join(&declaration.relative_path),
964 )?;
965 }
966 Ok(())
967 }
968
969 pub(crate) fn revalidate_bundle(
970 &self,
971 ownership: &MaterializationOwnershipRecord,
972 bundle: &NodeBundle,
973 layout: BundleProviderLayout,
974 ) -> Result<(), SessionEnvironmentMaterializeError> {
975 if ownership.bundle() != Some(&bundle.receipt()) {
976 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
977 }
978 self.revalidate(ownership)?;
979 let (root, expected, declared) = match layout {
980 BundleProviderLayout::Claude | BundleProviderLayout::Kimi => (
981 ownership.bundle_root.clone(),
982 bundle.files().iter()
983 .map(|file| (PathBuf::from(file.path()), file.bytes()))
984 .collect::<BTreeMap<_, _>>(),
985 ownership.declared_paths.iter().filter_map(|path| {
986 (path.class == NodeSessionPathClass::BundleRoot)
987 .then(|| path.relative_path.clone())
988 }).collect::<BTreeSet<_>>(),
989 ),
990 BundleProviderLayout::Codex => {
991 if !collect_secure_files(&ownership.bundle_root)?.is_empty() {
992 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
993 }
994 (
995 ownership.provider_home.join("skills"),
996 bundle.files().iter().filter_map(|file| {
997 file.path().strip_prefix("skills/")
998 .map(|path| (PathBuf::from(path), file.bytes()))
999 }).collect::<BTreeMap<_, _>>(),
1000 ownership.declared_paths.iter().filter_map(|path| {
1001 (path.class == NodeSessionPathClass::ProviderHome)
1002 .then(|| path.relative_path.strip_prefix("skills").ok())
1003 .flatten()
1004 .map(Path::to_path_buf)
1005 }).collect::<BTreeSet<_>>(),
1006 )
1007 }
1008 };
1009 revalidate_exact_files(&root, &expected, &declared)
1010 }
1011
1012 pub(crate) fn revalidate_context(
1013 &self,
1014 ownership: &MaterializationOwnershipRecord,
1015 receipt: &ResolvedContextPackReceipt,
1016 ) -> Result<NodeContextPack, SessionEnvironmentMaterializeError> {
1017 if ownership.context() != Some(receipt) {
1018 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
1019 }
1020 self.revalidate(ownership)?;
1021 let path = ownership
1022 .root
1023 .join(NodeSessionPathClass::Context.directory_name())
1024 .join(CONTEXT_PACK_FILE_NAME);
1025 validate_secure_file(&path)?;
1026 let mut bytes = Vec::with_capacity(receipt.byte_len as usize);
1027 File::open(path)?
1028 .take(u64::from(crate::protocol::MAX_CONTEXT_PACK_BYTES) + 1)
1029 .read_to_end(&mut bytes)?;
1030 NodeContextPack::from_materialized(receipt.clone(), bytes)
1031 .map_err(|_| SessionEnvironmentMaterializeError::OwnershipMismatch)
1032 }
1033
1034 pub(crate) fn cleanup(&self, ownership: &MaterializationOwnershipRecord) -> Result<(), SessionEnvironmentMaterializeError> {
1035 ownership.validate()?;
1036 if ownership.root.parent() != Some(self.root.as_path())
1037 || ownership.root.file_name() != Some(OsStr::new(ownership.id.as_str()))
1038 {
1039 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
1040 }
1041 if !ownership.root.exists() && ownership.state == MaterializationState::CleanupRequired {
1042 return Ok(());
1043 }
1044 let expected = materialization_owner_marker(
1045 &ownership.id,
1046 ownership.environment_profile.as_ref(),
1047 ownership.bundle.as_ref(),
1048 ownership.context.as_ref(),
1049 );
1050 remove_owned_tree(&ownership.root, expected.as_bytes())?;
1051 Ok(())
1052 }
1053}
1054
1055fn collect_secure_files(root: &Path) -> io::Result<BTreeSet<PathBuf>> {
1056 fn visit(base: &Path, relative: &Path, files: &mut BTreeSet<PathBuf>) -> io::Result<()> {
1057 let directory = base.join(relative);
1058 validate_secure_directory(&directory)?;
1059 for entry in fs::read_dir(&directory)? {
1060 let entry = entry?;
1061 let path = entry.path();
1062 let metadata = fs::symlink_metadata(&path)?;
1063 let child = relative.join(entry.file_name());
1064 if metadata.file_type().is_symlink() {
1065 return Err(io::Error::new(
1066 io::ErrorKind::PermissionDenied,
1067 "materialization bundle contains a link-like entry",
1068 ));
1069 }
1070 if metadata.is_dir() {
1071 visit(base, &child, files)?;
1072 } else if metadata.is_file() {
1073 validate_secure_file(&path)?;
1074 if files.len() == MAX_SESSION_MATERIALIZATION_FILES
1075 || !files.insert(child)
1076 {
1077 return Err(io::Error::new(
1078 io::ErrorKind::InvalidData,
1079 "materialization bundle file set is invalid",
1080 ));
1081 }
1082 } else {
1083 return Err(io::Error::new(
1084 io::ErrorKind::PermissionDenied,
1085 "materialization bundle contains an unsupported entry",
1086 ));
1087 }
1088 }
1089 Ok(())
1090 }
1091
1092 let mut files = BTreeSet::new();
1093 visit(root, Path::new(""), &mut files)?;
1094 Ok(files)
1095}
1096
1097fn revalidate_exact_files(
1098 root: &Path,
1099 expected: &BTreeMap<PathBuf, &[u8]>,
1100 declared: &BTreeSet<PathBuf>,
1101) -> Result<(), SessionEnvironmentMaterializeError> {
1102 let expected_paths = expected.keys().cloned().collect::<BTreeSet<_>>();
1103 let actual = collect_secure_files(root)?;
1104 if expected_paths != *declared || expected_paths != actual {
1105 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
1106 }
1107 for (relative_path, expected_bytes) in expected {
1108 let mut bytes = Vec::new();
1109 File::open(root.join(relative_path))?
1110 .take((MAX_SESSION_MATERIALIZATION_FILE_BYTES + 1) as u64)
1111 .read_to_end(&mut bytes)?;
1112 if bytes != *expected_bytes {
1113 return Err(SessionEnvironmentMaterializeError::OwnershipMismatch);
1114 }
1115 }
1116 Ok(())
1117}
1118
1119#[derive(Debug, Error)]
1120pub(crate) enum SessionEnvironmentMaterializeError {
1121 #[error("materialization root is invalid")]
1122 InvalidRoot,
1123 #[error("materialization ownership does not match the configured root")]
1124 OwnershipMismatch,
1125 #[error("secret is unavailable")]
1126 SecretUnavailable,
1127 #[error("secret access is denied")]
1128 SecretDenied,
1129 #[error("secret value is invalid for its destination")]
1130 InvalidSecretValue,
1131 #[error("materialization filesystem operation failed")]
1132 Filesystem(#[source] io::Error),
1133 #[error("materialization ownership record is invalid")]
1134 Record(#[source] MaterializationRecordError),
1135}
1136
1137impl From<NodeSecretResolveError> for SessionEnvironmentMaterializeError {
1138 fn from(value: NodeSecretResolveError) -> Self {
1139 match value {
1140 NodeSecretResolveError::Unavailable => Self::SecretUnavailable,
1141 NodeSecretResolveError::Denied => Self::SecretDenied,
1142 }
1143 }
1144}
1145
1146impl From<io::Error> for SessionEnvironmentMaterializeError {
1147 fn from(value: io::Error) -> Self { Self::Filesystem(value) }
1148}
1149
1150impl From<MaterializationRecordError> for SessionEnvironmentMaterializeError {
1151 fn from(value: MaterializationRecordError) -> Self { Self::Record(value) }
1152}
1153
1154fn valid_opaque_identifier(value: &str, max: usize) -> bool {
1155 !value.is_empty()
1156 && value.len() <= max
1157 && value.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1158}
1159
1160fn valid_materialization_id(value: &str) -> bool {
1161 let mut bytes = value.bytes();
1162 let Some(first) = bytes.next() else {
1163 return false;
1164 };
1165 value.len() <= 128
1166 && (first.is_ascii_lowercase() || first.is_ascii_digit())
1167 && bytes.all(|byte| {
1168 byte.is_ascii_lowercase()
1169 || byte.is_ascii_digit()
1170 || matches!(byte, b'-' | b'_' | b'.')
1171 })
1172}
1173
1174fn validate_environment_key(key: &str) -> Result<(), NodeSessionMaterializationProfileError> {
1175 if key.is_empty()
1176 || key.len() > 256
1177 || key.bytes().any(|byte| byte == 0 || byte == b'=' || !byte.is_ascii())
1178 {
1179 return Err(NodeSessionMaterializationProfileError::InvalidEnvironmentKey);
1180 }
1181 Ok(())
1182}
1183
1184fn normalized_environment_key(key: &str) -> String { key.to_ascii_uppercase() }
1185
1186fn validate_relative_path(path: &Path) -> Result<(), NodeSessionMaterializationProfileError> {
1187 let Some(encoded) = path.to_str() else {
1188 return Err(NodeSessionMaterializationProfileError::InvalidRelativePath);
1189 };
1190 if path.as_os_str().is_empty()
1191 || encoded.len() > MAX_SESSION_MATERIALIZATION_RELATIVE_PATH_BYTES
1192 || path.components().any(|component| !matches!(component, Component::Normal(_)))
1193 {
1194 return Err(NodeSessionMaterializationProfileError::InvalidRelativePath);
1195 }
1196 Ok(())
1197}
1198
1199fn checked_timestamp(created: u64, now: u64) -> Result<u64, MaterializationRecordError> {
1200 if now < created { Err(MaterializationRecordError::InvalidRecord) } else { Ok(now) }
1201}
1202
1203fn mark_materialization_failure(
1204 ownership: &mut MaterializationOwnershipRecord,
1205 now: u64,
1206 absence_proven: bool,
1207) {
1208 let transition = if absence_proven {
1209 ownership.mark_cleanup_required(now)
1210 } else {
1211 ownership.mark_recovery_required(now)
1212 };
1213 if transition.is_err() {
1214 ownership.state = MaterializationState::RecoveryRequired;
1215 ownership.updated_at_unix_ms = ownership.created_at_unix_ms.max(now);
1216 }
1217}
1218
1219fn materialization_owner_marker(
1220 id: &MaterializationId,
1221 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
1222 bundle: Option<&ResolvedBundleReceipt>,
1223 context: Option<&ResolvedContextPackReceipt>,
1224) -> String {
1225 if let (Some(profile), None, None) = (environment_profile, bundle, context) {
1226 return format!(
1227 "{}\n{}\n{}\n",
1228 id.as_str(),
1229 profile.profile_id.as_str(),
1230 profile.profile_revision.as_str(),
1231 );
1232 }
1233 let marker_version = if context.is_some() {
1234 "materialization-v3"
1235 } else {
1236 "materialization-v2"
1237 };
1238 let mut marker = format!("{}\n{}\n", id.as_str(), marker_version);
1239 if let Some(profile) = environment_profile {
1240 marker.push_str(&format!(
1241 "environment\n{}\n{}\n",
1242 profile.profile_id.as_str(),
1243 profile.profile_revision.as_str(),
1244 ));
1245 }
1246 if let Some(bundle) = bundle {
1247 marker.push_str(&format!(
1248 "bundle\n{}\n{}\n{}\n",
1249 bundle.id.as_str(),
1250 bundle.revision.as_str(),
1251 bundle.digest.as_str(),
1252 ));
1253 }
1254 if let Some(context) = context {
1255 marker.push_str(&format!(
1256 "context\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n",
1257 context.id.as_str(),
1258 context.digest.as_str(),
1259 context.lineage.source_node_id.as_str(),
1260 context.lineage.source_session.workspace_id.as_str(),
1261 context.lineage.source_session.session.instance_id.0,
1262 context.lineage.source_session.session.generation.0,
1263 context.lineage.source_provider.as_str(),
1264 context.source_message_count,
1265 context.retained_message_count,
1266 context.byte_len,
1267 ));
1268 marker.push_str(if context.truncated { "true\n" } else { "false\n" });
1269 }
1270 marker
1271}
1272
1273pub(crate) fn ensure_materialization_root(root: &Path) -> io::Result<()> {
1274 if root.exists() {
1275 validate_secure_directory(root)
1276 } else {
1277 secure_create_directory(root)
1278 }
1279}
1280
1281pub(crate) fn secure_create_parent_directories(base: &Path, parent: Option<&Path>) -> io::Result<()> {
1282 let Some(parent) = parent else { return Ok(()); };
1283 let mut current = base.to_path_buf();
1284 for component in parent.components() {
1285 let Component::Normal(name) = component else { return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid materialization path")); };
1286 current.push(name);
1287 match secure_create_directory(¤t) {
1288 Ok(()) => {}
1289 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => validate_secure_directory(¤t)?,
1290 Err(error) => return Err(error),
1291 }
1292 }
1293 Ok(())
1294}
1295
1296pub(crate) fn verify_or_create_exact_file(path: &Path, expected: &[u8]) -> io::Result<()> {
1297 match secure_create_file(path, expected) {
1298 Ok(()) => Ok(()),
1299 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
1300 validate_secure_file(path)?;
1301 let mut bytes = Vec::new();
1302 File::open(path)?.take(4096).read_to_end(&mut bytes)?;
1303 if bytes == expected { Ok(()) } else { Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization marker mismatch")) }
1304 }
1305 Err(error) => Err(error),
1306 }
1307}
1308
1309pub(crate) fn secure_replace_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
1310 validate_secure_file(path)?;
1311 #[cfg(unix)]
1312 let mut file = {
1313 use std::os::unix::fs::OpenOptionsExt;
1314 OpenOptions::new().write(true).truncate(true).custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW).open(path)?
1315 };
1316 #[cfg(windows)]
1317 let mut file = {
1318 use std::os::windows::fs::OpenOptionsExt;
1319 OpenOptions::new().write(true).truncate(true).custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT).open(path)?
1320 };
1321 file.write_all(bytes)?;
1322 file.sync_all()
1323}
1324
1325fn remove_owned_tree(root: &Path, expected_marker: &[u8]) -> io::Result<()> {
1326 validate_secure_directory(root)?;
1327 let marker = root.join(MATERIALIZATION_OWNER_MARKER);
1328 validate_secure_file(&marker)?;
1329 let mut bytes = Vec::new();
1330 File::open(&marker)?.take(4096).read_to_end(&mut bytes)?;
1331 if bytes != expected_marker {
1332 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization owner marker mismatch"));
1333 }
1334 remove_tree_no_links(root)?;
1335 fs::remove_dir(root)
1336}
1337
1338pub(crate) fn remove_tree_no_links(directory: &Path) -> io::Result<()> {
1339 validate_secure_directory(directory)?;
1340 for entry in fs::read_dir(directory)? {
1341 let entry = entry?;
1342 let path = entry.path();
1343 let metadata = fs::symlink_metadata(&path)?;
1344 if metadata.file_type().is_symlink() || is_reparse_point(&metadata) {
1345 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization contains link-like entry"));
1346 }
1347 if metadata.is_dir() {
1348 remove_tree_no_links(&path)?;
1349 fs::remove_dir(&path)?;
1350 } else if metadata.is_file() {
1351 validate_secure_file(&path)?;
1352 fs::remove_file(&path)?;
1353 } else {
1354 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization contains unsupported entry"));
1355 }
1356 }
1357 Ok(())
1358}
1359
1360#[cfg(unix)]
1361pub(crate) fn secure_create_directory(path: &Path) -> io::Result<()> {
1362 use std::os::unix::fs::PermissionsExt;
1363 fs::create_dir(path)?;
1364 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
1365 validate_secure_directory(path)
1366}
1367
1368#[cfg(unix)]
1369pub(crate) fn validate_secure_directory(path: &Path) -> io::Result<()> {
1370 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1371 let metadata = fs::symlink_metadata(path)?;
1372 if !metadata.is_dir() || metadata.file_type().is_symlink() || metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o7777 != 0o700 {
1373 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization directory is not owner-only"));
1374 }
1375 Ok(())
1376}
1377
1378#[cfg(unix)]
1379pub(crate) fn secure_create_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
1380 use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
1381 let mut file = OpenOptions::new().create_new(true).write(true).mode(0o600).custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW).open(path)?;
1382 file.write_all(bytes)?;
1383 file.sync_all()?;
1384 file.set_permissions(fs::Permissions::from_mode(0o600))?;
1385 let metadata = file.metadata()?;
1386 if !metadata.is_file() || metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o7777 != 0o600 {
1387 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization file is not owner-only"));
1388 }
1389 Ok(())
1390}
1391
1392#[cfg(unix)]
1393pub(crate) fn validate_secure_file(path: &Path) -> io::Result<()> {
1394 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1395 let metadata = fs::symlink_metadata(path)?;
1396 if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o7777 != 0o600 {
1397 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization file is not owner-only"));
1398 }
1399 Ok(())
1400}
1401
1402#[cfg(unix)]
1403fn is_reparse_point(_: &fs::Metadata) -> bool { false }
1404
1405#[cfg(windows)]
1406pub(crate) fn secure_create_directory(path: &Path) -> io::Result<()> { windows_secure::create_directory(path) }
1407#[cfg(windows)]
1408pub(crate) fn validate_secure_directory(path: &Path) -> io::Result<()> { windows_secure::validate_path(path, true) }
1409#[cfg(windows)]
1410pub(crate) fn secure_create_file(path: &Path, bytes: &[u8]) -> io::Result<()> { windows_secure::create_file(path, bytes) }
1411#[cfg(windows)]
1412pub(crate) fn validate_secure_file(path: &Path) -> io::Result<()> { windows_secure::validate_path(path, false) }
1413#[cfg(windows)]
1414fn is_reparse_point(metadata: &fs::Metadata) -> bool {
1415 use std::os::windows::fs::MetadataExt;
1416 metadata.file_attributes() & windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT != 0
1417}
1418
1419pub(crate) struct MaterializationRootLock { file: Option<File>, #[cfg(windows)] path: PathBuf }
1420
1421impl MaterializationRootLock {
1422 pub(crate) fn acquire(path: &Path) -> io::Result<Self> {
1423 verify_or_create_exact_file(path, b"")?;
1424 #[cfg(windows)]
1425 let file = {
1426 use std::os::windows::fs::OpenOptionsExt;
1427 OpenOptions::new()
1428 .read(true)
1429 .write(true)
1430 .share_mode(0)
1431 .custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT)
1432 .open(path)?
1433 };
1434 #[cfg(unix)]
1435 let file = {
1436 use std::os::fd::AsRawFd;
1437 use std::os::unix::fs::OpenOptionsExt;
1438 let file = OpenOptions::new().read(true).write(true).custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW).open(path)?;
1439 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { return Err(io::Error::last_os_error()); }
1440 file
1441 };
1442 Ok(Self { file: Some(file), #[cfg(windows)] path: path.to_path_buf() })
1443 }
1444}
1445
1446impl Drop for MaterializationRootLock {
1447 fn drop(&mut self) {
1448 #[cfg(unix)]
1449 if let Some(file) = self.file.as_ref() {
1450 use std::os::fd::AsRawFd;
1451 unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN); }
1452 }
1453 drop(self.file.take());
1454 #[cfg(windows)]
1455 let _ = fs::remove_file(&self.path);
1456 }
1457}
1458
1459pub(crate) fn path_to_opaque(path: &Path) -> io::Result<OpaqueHostPath> {
1460 #[cfg(unix)]
1461 {
1462 use std::os::unix::ffi::OsStrExt;
1463 OpaqueHostPath::unix_bytes(path.as_os_str().as_bytes().to_vec()).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
1464 }
1465 #[cfg(windows)]
1466 {
1467 OpaqueHostPath::utf8(path.to_string_lossy().into_owned()).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
1468 }
1469}
1470
1471pub(crate) fn opaque_to_path(path: &OpaqueHostPath) -> io::Result<PathBuf> {
1472 if let Some(value) = path.as_utf8() { return Ok(PathBuf::from(value)); }
1473 #[cfg(unix)]
1474 if let Some(value) = path.as_unix_bytes() {
1475 use std::os::unix::ffi::OsStringExt;
1476 return Ok(PathBuf::from(OsString::from_vec(value.to_vec())));
1477 }
1478 Err(io::Error::new(io::ErrorKind::InvalidData, "materialization path encoding is unsupported"))
1479}
1480
1481#[cfg(windows)]
1482mod windows_secure {
1483 use super::*;
1484 use std::os::windows::ffi::OsStrExt;
1485 use std::os::windows::io::FromRawHandle;
1486 use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, ERROR_ALREADY_EXISTS, HANDLE, INVALID_HANDLE_VALUE};
1487 use windows_sys::Win32::Security::Authorization::{
1488 ConvertStringSecurityDescriptorToSecurityDescriptorW, GetNamedSecurityInfoW,
1489 SDDL_REVISION_1, SE_FILE_OBJECT,
1490 };
1491 use windows_sys::Win32::Security::{
1492 CreateWellKnownSid, EqualSid, GetAce, GetAclInformation, GetSecurityDescriptorControl,
1493 ACCESS_ALLOWED_ACE, ACL_SIZE_INFORMATION, AclSizeInformation,
1494 DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
1495 SECURITY_ATTRIBUTES, SECURITY_MAX_SID_SIZE, SE_DACL_PROTECTED,
1496 WinCreatorOwnerRightsSid,
1497 };
1498 use windows_sys::Win32::Storage::FileSystem::{
1499 CreateDirectoryW, CreateFileW, GetFileAttributesW, CREATE_NEW, FILE_ATTRIBUTE_NORMAL,
1500 FILE_ATTRIBUTE_REPARSE_POINT, FILE_GENERIC_WRITE, FILE_SHARE_READ, OPEN_EXISTING,
1501 FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_ALL_ACCESS,
1502 };
1503
1504 const OWNER_ONLY_SDDL: &str = "D:P(A;;FA;;;OW)";
1505
1506 struct SecurityDescriptor(PSECURITY_DESCRIPTOR);
1507 impl SecurityDescriptor {
1508 fn new() -> io::Result<Self> {
1509 let sddl = wide(OsStr::new(OWNER_ONLY_SDDL));
1510 let mut descriptor = std::ptr::null_mut();
1511 if unsafe { ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.as_ptr(), SDDL_REVISION_1, &mut descriptor, std::ptr::null_mut()) } == 0 {
1512 return Err(io::Error::last_os_error());
1513 }
1514 Ok(Self(descriptor))
1515 }
1516 fn attributes(&mut self) -> SECURITY_ATTRIBUTES {
1517 SECURITY_ATTRIBUTES { nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32, lpSecurityDescriptor: self.0, bInheritHandle: 0 }
1518 }
1519 }
1520 impl Drop for SecurityDescriptor { fn drop(&mut self) { unsafe { LocalFree(self.0); } } }
1521
1522 pub(super) fn create_directory(path: &Path) -> io::Result<()> {
1523 let path = wide(path.as_os_str());
1524 let mut descriptor = SecurityDescriptor::new()?;
1525 let attributes = descriptor.attributes();
1526 if unsafe { CreateDirectoryW(path.as_ptr(), &attributes) } == 0 {
1527 let error = io::Error::last_os_error();
1528 if error.raw_os_error() == Some(ERROR_ALREADY_EXISTS as i32) { return Err(io::Error::new(io::ErrorKind::AlreadyExists, error)); }
1529 return Err(error);
1530 }
1531 validate_wide_path(&path, true)
1532 }
1533
1534 pub(super) fn create_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
1535 let path = wide(path.as_os_str());
1536 let mut descriptor = SecurityDescriptor::new()?;
1537 let attributes = descriptor.attributes();
1538 let handle = unsafe { CreateFileW(path.as_ptr(), FILE_GENERIC_WRITE, 0, &attributes, CREATE_NEW, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, std::ptr::null_mut()) };
1539 if handle == INVALID_HANDLE_VALUE { return Err(io::Error::last_os_error()); }
1540 let mut file = unsafe { File::from_raw_handle(handle as _) };
1541 file.write_all(bytes)?;
1542 file.sync_all()?;
1543 Ok(())
1544 }
1545
1546 pub(super) fn validate_path(path: &Path, directory: bool) -> io::Result<()> {
1547 validate_wide_path(&wide(path.as_os_str()), directory)
1548 }
1549
1550 fn validate_wide_path(path: &[u16], directory: bool) -> io::Result<()> {
1551 let attributes = unsafe { GetFileAttributesW(path.as_ptr()) };
1552 if attributes == u32::MAX || attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
1553 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization path is reparse or unavailable"));
1554 }
1555 let flags = FILE_FLAG_OPEN_REPARSE_POINT | if directory { FILE_FLAG_BACKUP_SEMANTICS } else { 0 };
1556 let handle: HANDLE = unsafe { CreateFileW(path.as_ptr(), 0, FILE_SHARE_READ, std::ptr::null(), OPEN_EXISTING, flags, std::ptr::null_mut()) };
1557 if handle == INVALID_HANDLE_VALUE { return Err(io::Error::last_os_error()); }
1558 unsafe { CloseHandle(handle); }
1559 validate_owner_only_dacl(path)
1560 }
1561
1562 fn validate_owner_only_dacl(path: &[u16]) -> io::Result<()> {
1563 let mut owner = std::ptr::null_mut();
1564 let mut dacl = std::ptr::null_mut();
1565 let mut descriptor = std::ptr::null_mut();
1566 let status = unsafe {
1567 GetNamedSecurityInfoW(
1568 path.as_ptr(),
1569 SE_FILE_OBJECT,
1570 OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
1571 &mut owner,
1572 std::ptr::null_mut(),
1573 &mut dacl,
1574 std::ptr::null_mut(),
1575 &mut descriptor,
1576 )
1577 };
1578 if status != 0 { return Err(io::Error::from_raw_os_error(status as i32)); }
1579 let result = (|| {
1580 if owner.is_null() || dacl.is_null() {
1581 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization DACL is missing"));
1582 }
1583 let mut control = 0u16;
1584 let mut revision = 0u32;
1585 if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0
1586 || control & SE_DACL_PROTECTED == 0
1587 {
1588 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization DACL is not protected"));
1589 }
1590 let mut information = ACL_SIZE_INFORMATION::default();
1591 if unsafe {
1592 GetAclInformation(
1593 dacl,
1594 &mut information as *mut _ as *mut _,
1595 std::mem::size_of::<ACL_SIZE_INFORMATION>() as u32,
1596 AclSizeInformation,
1597 )
1598 } == 0 || information.AceCount != 1 {
1599 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization DACL is not owner-only"));
1600 }
1601 let mut ace = std::ptr::null_mut();
1602 if unsafe { GetAce(dacl, 0, &mut ace) } == 0 || ace.is_null() {
1603 return Err(io::Error::last_os_error());
1604 }
1605 let allowed = unsafe { &*(ace as *const ACCESS_ALLOWED_ACE) };
1606 let sid = &allowed.SidStart as *const u32 as *mut _;
1607 let mut owner_rights = [0u8; SECURITY_MAX_SID_SIZE as usize];
1608 let mut owner_rights_len = owner_rights.len() as u32;
1609 if unsafe {
1610 CreateWellKnownSid(
1611 WinCreatorOwnerRightsSid,
1612 std::ptr::null_mut(),
1613 owner_rights.as_mut_ptr() as *mut _,
1614 &mut owner_rights_len,
1615 )
1616 } == 0 {
1617 return Err(io::Error::last_os_error());
1618 }
1619 if allowed.Header.AceType != 0
1620 || allowed.Header.AceFlags != 0
1621 || allowed.Mask != FILE_ALL_ACCESS
1622 || (unsafe { EqualSid(owner, sid) } == 0
1623 && unsafe { EqualSid(owner_rights.as_mut_ptr() as *mut _, sid) } == 0)
1624 {
1625 return Err(io::Error::new(io::ErrorKind::PermissionDenied, "materialization DACL is not owner-only"));
1626 }
1627 Ok(())
1628 })();
1629 unsafe { LocalFree(descriptor); }
1630 result
1631 }
1632
1633 fn wide(value: &OsStr) -> Vec<u16> { value.encode_wide().chain(std::iter::once(0)).collect() }
1634}
1635
1636#[cfg(test)]
1637mod tests {
1638 use super::*;
1639 use std::sync::atomic::{AtomicU64, Ordering};
1640
1641 static NEXT_TEST: AtomicU64 = AtomicU64::new(1);
1642
1643 struct FakeResolver;
1644 impl NodeSecretResolver for FakeResolver {
1645 fn resolve(&self, reference: &NodeSecretReference) -> Result<NodeSecretValue, NodeSecretResolveError> {
1646 if reference.as_str() == "fixture-token" { NodeSecretValue::text("fixture-secret").map_err(|_| NodeSecretResolveError::Unavailable) } else { Err(NodeSecretResolveError::Unavailable) }
1647 }
1648 }
1649
1650 struct UnavailableResolver;
1651 impl NodeSecretResolver for UnavailableResolver {
1652 fn resolve(&self, _: &NodeSecretReference) -> Result<NodeSecretValue, NodeSecretResolveError> {
1653 Err(NodeSecretResolveError::Unavailable)
1654 }
1655 }
1656
1657 fn temp_root() -> PathBuf {
1658 std::env::temp_dir().join(format!("gate4agent-materializer-{}-{}", std::process::id(), NEXT_TEST.fetch_add(1, Ordering::Relaxed)))
1659 }
1660
1661 fn receipt() -> ResolvedEnvironmentProfileReceipt {
1662 ResolvedEnvironmentProfileReceipt {
1663 profile_id: crate::protocol::SpawnEnvironmentProfileId::new("fixture").unwrap(),
1664 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new("r1").unwrap(),
1665 network_allowlist: None,
1666 browser_profile_id: None,
1667 }
1668 }
1669
1670 fn owner() -> MaterializationOwner {
1671 MaterializationOwner::Session {
1672 incarnation_id: NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
1673 instance_id: AgentInstanceId(4),
1674 generation: SessionGeneration(2),
1675 }
1676 }
1677
1678 #[test]
1679 fn materialization_profile_rejects_traversal_prefix_collisions_and_normalized_env_duplicates() {
1680 assert_eq!(NodeSessionFile::generated(NodeSessionPathClass::Config, "../escape", vec![]).err(), Some(NodeSessionMaterializationProfileError::InvalidRelativePath));
1681 let duplicate = NodeSessionMaterializationProfile::new(
1682 vec![NodeSessionEnvironmentMutation::Remove { key: "Path".to_owned() }],
1683 vec![NodeSessionPathBinding::new("PATH", NodeSessionPathClass::ProviderHome).unwrap()],
1684 vec![],
1685 );
1686 assert_eq!(duplicate.err(), Some(NodeSessionMaterializationProfileError::DuplicateEnvironmentKey));
1687 let collision = NodeSessionMaterializationProfile::new(
1688 vec![], vec![], vec![
1689 NodeSessionFile::generated(NodeSessionPathClass::Config, "a", vec![]).unwrap(),
1690 NodeSessionFile::generated(NodeSessionPathClass::Config, "a/b", vec![]).unwrap(),
1691 ],
1692 );
1693 assert_eq!(collision.err(), Some(NodeSessionMaterializationProfileError::ConflictingFilePath));
1694 #[cfg(unix)]
1695 {
1696 use std::os::unix::ffi::OsStringExt;
1697 let non_utf8 = PathBuf::from(OsString::from_vec(vec![b'a', 0xff]));
1698 assert_eq!(
1699 NodeSessionFile::generated(NodeSessionPathClass::Config, non_utf8, vec![]).err(),
1700 Some(NodeSessionMaterializationProfileError::InvalidRelativePath),
1701 );
1702 }
1703 }
1704
1705 #[test]
1706 fn materialization_id_rejects_noncanonical_case() {
1707 assert_eq!(
1708 MaterializationId::new("Mat-a").err(),
1709 Some(MaterializationRecordError::InvalidId),
1710 );
1711 assert_eq!(
1712 MaterializationId::new("mat-a").unwrap().as_str(),
1713 "mat-a",
1714 );
1715 }
1716
1717 #[test]
1718 fn codex_bundle_layout_requires_exact_home_binding_and_reserved_skill_tree() {
1719 let exact = NodeSessionMaterializationProfile::new(
1720 Vec::new(),
1721 vec![NodeSessionPathBinding::new(
1722 CODEX_HOME_ENVIRONMENT_KEY,
1723 NodeSessionPathClass::ProviderHome,
1724 )
1725 .unwrap()],
1726 vec![NodeSessionFile::generated(
1727 NodeSessionPathClass::ProviderHome,
1728 "auth.json",
1729 b"explicit-auth-is-optional".to_vec(),
1730 )
1731 .unwrap()],
1732 )
1733 .unwrap();
1734 assert!(exact.supports_bundle_layout(BundleProviderLayout::Codex));
1735
1736 let no_auth = NodeSessionMaterializationProfile::new(
1737 Vec::new(),
1738 vec![NodeSessionPathBinding::new(
1739 CODEX_HOME_ENVIRONMENT_KEY,
1740 NodeSessionPathClass::ProviderHome,
1741 )
1742 .unwrap()],
1743 Vec::new(),
1744 )
1745 .unwrap();
1746 assert!(no_auth.supports_bundle_layout(BundleProviderLayout::Codex));
1747
1748 let missing = NodeSessionMaterializationProfile::new(Vec::new(), Vec::new(), Vec::new())
1749 .unwrap();
1750 assert!(!missing.supports_bundle_layout(BundleProviderLayout::Codex));
1751
1752 let wrong_class = NodeSessionMaterializationProfile::new(
1753 Vec::new(),
1754 vec![NodeSessionPathBinding::new(
1755 CODEX_HOME_ENVIRONMENT_KEY,
1756 NodeSessionPathClass::Config,
1757 )
1758 .unwrap()],
1759 Vec::new(),
1760 )
1761 .unwrap();
1762 assert!(!wrong_class.supports_bundle_layout(BundleProviderLayout::Codex));
1763
1764 let reserved_skill = NodeSessionMaterializationProfile::new(
1765 Vec::new(),
1766 vec![NodeSessionPathBinding::new(
1767 CODEX_HOME_ENVIRONMENT_KEY,
1768 NodeSessionPathClass::ProviderHome,
1769 )
1770 .unwrap()],
1771 vec![NodeSessionFile::generated(
1772 NodeSessionPathClass::ProviderHome,
1773 "skills/unmanaged/SKILL.md",
1774 Vec::new(),
1775 )
1776 .unwrap()],
1777 )
1778 .unwrap();
1779 assert!(!reserved_skill.supports_bundle_layout(BundleProviderLayout::Codex));
1780
1781 let exposed_bundle_root = NodeSessionMaterializationProfile::new(
1782 Vec::new(),
1783 vec![
1784 NodeSessionPathBinding::new(
1785 CODEX_HOME_ENVIRONMENT_KEY,
1786 NodeSessionPathClass::ProviderHome,
1787 )
1788 .unwrap(),
1789 NodeSessionPathBinding::new(
1790 "UNMANAGED_BUNDLE_ROOT",
1791 NodeSessionPathClass::BundleRoot,
1792 )
1793 .unwrap(),
1794 ],
1795 Vec::new(),
1796 )
1797 .unwrap();
1798 assert!(!exposed_bundle_root.supports_bundle_layout(BundleProviderLayout::Codex));
1799 }
1800
1801 #[test]
1802 fn legacy_environment_owner_marker_bytes_remain_v6_compatible() {
1803 let id = MaterializationId::new("legacy-environment").unwrap();
1804 let receipt = receipt();
1805 assert_eq!(
1806 materialization_owner_marker(&id, Some(&receipt), None, None),
1807 "legacy-environment\nfixture\nr1\n",
1808 );
1809 }
1810
1811 #[test]
1812 fn secret_reference_and_content_containers_have_no_debug_surface() {
1813 fn assert_no_debug<T>() {
1814 let name = std::any::type_name::<T>();
1815 assert!(!name.is_empty());
1816 }
1817 assert_no_debug::<NodeSecretReference>();
1818 assert_no_debug::<NodeSecretValue>();
1819 assert_no_debug::<NodeSessionEnvironmentMutation>();
1820 assert_no_debug::<NodeSessionFile>();
1821 assert_no_debug::<NodeSessionMaterializationProfile>();
1822 }
1823
1824 #[test]
1825 fn owner_only_materialization_resolves_and_cleans_without_persisting_secret_content() {
1826 let root = temp_root();
1827 let materializer = SessionEnvironmentMaterializer::new(root.clone(), Arc::new(FakeResolver)).unwrap();
1828 let profile = NodeSessionMaterializationProfile::new(
1829 vec![NodeSessionEnvironmentMutation::SetSecret { key: "FIXTURE_TOKEN".to_owned(), reference: NodeSecretReference::new("fixture-token").unwrap() }],
1830 vec![NodeSessionPathBinding::new("FIXTURE_HOME", NodeSessionPathClass::ProviderHome).unwrap()],
1831 vec![NodeSessionFile::secret(NodeSessionPathClass::Config, "auth/token", NodeSecretReference::new("fixture-token").unwrap()).unwrap()],
1832 ).unwrap();
1833 let prepared = materializer.prepare(MaterializationId::new("fixture-1").unwrap(), receipt(), owner(), None, &profile, 10).unwrap();
1834 assert_eq!(prepared.environment().len(), 2);
1835 let (_, ownership) = prepared.into_parts();
1836 assert!(ownership.root().join("config/auth/token").is_file());
1837 #[cfg(unix)] {
1838 use std::os::unix::fs::PermissionsExt;
1839 assert_eq!(fs::metadata(ownership.root()).unwrap().permissions().mode() & 0o777, 0o700);
1840 assert_eq!(fs::metadata(ownership.root().join("config/auth/token")).unwrap().permissions().mode() & 0o777, 0o600);
1841 }
1842 materializer.cleanup(&ownership).unwrap();
1843 assert!(!ownership.root().exists());
1844 drop(materializer);
1845 let _ = fs::remove_dir_all(root);
1846 }
1847
1848 #[test]
1849 fn provider_home_path_bindings_pass_claude_kimi_grok_relocation_env_keys() {
1850 let root = temp_root();
1854 let materializer =
1855 SessionEnvironmentMaterializer::new(root.clone(), Arc::new(FakeResolver)).unwrap();
1856 for key in ["CLAUDE_CONFIG_DIR", "KIMI_CODE_HOME", "GROK_HOME"] {
1857 let profile = NodeSessionMaterializationProfile::new(
1858 Vec::new(),
1859 vec![NodeSessionPathBinding::new(key, NodeSessionPathClass::ProviderHome).unwrap()],
1860 Vec::new(),
1861 )
1862 .unwrap();
1863 assert!(profile.supports_bundle_layout(BundleProviderLayout::Claude));
1865 assert!(profile.supports_bundle_layout(BundleProviderLayout::Kimi));
1866 assert!(!profile.supports_bundle_layout(BundleProviderLayout::Codex));
1867
1868 let id = MaterializationId::new(format!(
1869 "reloc-{}",
1870 key.to_ascii_lowercase().replace('_', "-")
1871 ))
1872 .unwrap();
1873 let prepared = materializer
1874 .prepare(id, receipt(), owner(), None, &profile, 40)
1875 .unwrap();
1876 let env = prepared.environment();
1877 assert_eq!(env.len(), 1, "key={key}");
1878 assert_eq!(env[0].key, OsString::from(key));
1879 let value = env[0].value.as_ref().expect("path binding must set a value");
1880 let provider_home = PathBuf::from(value);
1881 assert!(provider_home.is_absolute());
1882 assert_eq!(
1883 provider_home.file_name().and_then(|n| n.to_str()),
1884 Some(NodeSessionPathClass::ProviderHome.directory_name()),
1885 );
1886 assert!(
1887 provider_home.starts_with(&root),
1888 "materialized home must stay under temp root, not the real user profile"
1889 );
1890 let (_, ownership) = prepared.into_parts();
1891 materializer.cleanup(&ownership).unwrap();
1892 assert!(!ownership.root().exists());
1893 }
1894 drop(materializer);
1895 let _ = fs::remove_dir_all(root);
1896 }
1897
1898 #[test]
1899 fn failed_materialization_retains_cleanup_state_until_absence_is_reconciled() {
1900 let root = temp_root();
1901 let materializer = SessionEnvironmentMaterializer::new(root.clone(), Arc::new(UnavailableResolver)).unwrap();
1902 let profile = NodeSessionMaterializationProfile::new(
1903 vec![NodeSessionEnvironmentMutation::SetSecret {
1904 key: "FIXTURE_TOKEN".to_owned(),
1905 reference: NodeSecretReference::new("opaque-reference-never-in-errors").unwrap(),
1906 }],
1907 vec![],
1908 vec![],
1909 ).unwrap();
1910 let mut ownership = materializer.begin(
1911 MaterializationId::new("fixture-failure").unwrap(),
1912 Some(receipt()),
1913 None,
1914 None,
1915 owner(),
1916 None,
1917 &profile,
1918 20,
1919 ).unwrap();
1920 let error = materializer.materialize(&mut ownership, &profile, 21).unwrap_err();
1921 assert_eq!(ownership.state(), MaterializationState::CleanupRequired);
1922 assert!(!ownership.root().exists());
1923 assert!(!error.to_string().contains("opaque-reference-never-in-errors"));
1924 materializer.cleanup(&ownership).unwrap();
1925 drop(materializer);
1926 let _ = fs::remove_dir_all(root);
1927 }
1928
1929 #[test]
1930 fn bundle_materialization_uses_private_roots_revalidates_and_cleans() {
1931 let root = temp_root();
1932 let materializer = SessionEnvironmentMaterializer::new(
1933 root.clone(),
1934 Arc::new(FakeResolver),
1935 )
1936 .unwrap();
1937 let profile = NodeSessionMaterializationProfile::new(
1938 Vec::new(),
1939 Vec::new(),
1940 vec![NodeSessionFile::generated(
1941 NodeSessionPathClass::BundleRoot,
1942 "skills/review/SKILL.md",
1943 b"---\nname: review\ndescription: review\n---\n".to_vec(),
1944 )
1945 .unwrap()],
1946 )
1947 .unwrap();
1948 let bundle = ResolvedBundleReceipt {
1949 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
1950 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
1951 digest: crate::protocol::SpawnBundleDigest::new(format!(
1952 "sha256:{}",
1953 "0".repeat(64),
1954 ))
1955 .unwrap(),
1956 };
1957 let mut ownership = materializer
1958 .begin(
1959 MaterializationId::new("bundle-fixture").unwrap(),
1960 None,
1961 Some(bundle),
1962 None,
1963 owner(),
1964 None,
1965 &profile,
1966 30,
1967 )
1968 .unwrap();
1969 let environment = materializer
1970 .materialize(&mut ownership, &profile, 31)
1971 .unwrap();
1972 assert!(environment.is_empty());
1973 assert!(ownership.bundle_root().join("skills/review/SKILL.md").is_file());
1974 assert!(ownership.plugin_data().is_dir());
1975 materializer.revalidate(&ownership).unwrap();
1976 #[cfg(unix)] {
1977 use std::os::unix::fs::PermissionsExt;
1978 assert_eq!(
1979 fs::metadata(ownership.bundle_root()).unwrap().permissions().mode() & 0o777,
1980 0o700,
1981 );
1982 }
1983 ownership.mark_cleanup_required(32).unwrap();
1984 materializer.cleanup(&ownership).unwrap();
1985 assert!(!ownership.root().exists());
1986 drop(materializer);
1987 let _ = fs::remove_dir_all(root);
1988 }
1989
1990 #[test]
1991 fn codex_skill_tree_revalidation_is_exact_but_allows_other_home_state() {
1992 let root = temp_root();
1993 let materializer = SessionEnvironmentMaterializer::new(
1994 root.clone(),
1995 Arc::new(FakeResolver),
1996 )
1997 .unwrap();
1998 let profile = NodeSessionMaterializationProfile::new(
1999 Vec::new(),
2000 vec![NodeSessionPathBinding::new(
2001 CODEX_HOME_ENVIRONMENT_KEY,
2002 NodeSessionPathClass::ProviderHome,
2003 )
2004 .unwrap()],
2005 vec![NodeSessionFile::generated(
2006 NodeSessionPathClass::ProviderHome,
2007 "skills/review/SKILL.md",
2008 b"review-skill".to_vec(),
2009 )
2010 .unwrap()],
2011 )
2012 .unwrap();
2013 let bundle = ResolvedBundleReceipt {
2014 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
2015 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
2016 digest: crate::protocol::SpawnBundleDigest::new(format!(
2017 "sha256:{}",
2018 "1".repeat(64),
2019 ))
2020 .unwrap(),
2021 };
2022 let mut ownership = materializer
2023 .begin(
2024 MaterializationId::new("codex-bundle-fixture").unwrap(),
2025 None,
2026 Some(bundle),
2027 None,
2028 owner(),
2029 None,
2030 &profile,
2031 40,
2032 )
2033 .unwrap();
2034 materializer.materialize(&mut ownership, &profile, 41).unwrap();
2035 assert!(collect_secure_files(ownership.bundle_root()).unwrap().is_empty());
2036
2037 let skills_root = ownership.provider_home().join("skills");
2038 let skill_path = skills_root.join("review/SKILL.md");
2039 let mut expected = BTreeMap::<PathBuf, &[u8]>::new();
2040 expected.insert(PathBuf::from("review/SKILL.md"), b"review-skill");
2041 let declared = BTreeSet::from([PathBuf::from("review/SKILL.md")]);
2042 secure_create_file(
2043 &ownership.provider_home().join("session-state.json"),
2044 b"writable state",
2045 )
2046 .unwrap();
2047 revalidate_exact_files(&skills_root, &expected, &declared).unwrap();
2048
2049 secure_replace_file(&skill_path, b"changed").unwrap();
2050 assert!(revalidate_exact_files(&skills_root, &expected, &declared).is_err());
2051 secure_replace_file(&skill_path, b"review-skill").unwrap();
2052
2053 let extra = skills_root.join("review/extra.txt");
2054 secure_create_file(&extra, b"extra").unwrap();
2055 assert!(revalidate_exact_files(&skills_root, &expected, &declared).is_err());
2056 fs::remove_file(&extra).unwrap();
2057
2058 fs::remove_file(&skill_path).unwrap();
2059 assert!(revalidate_exact_files(&skills_root, &expected, &declared).is_err());
2060 #[cfg(unix)]
2061 {
2062 std::os::unix::fs::symlink("missing-target", &skill_path).unwrap();
2063 assert!(revalidate_exact_files(&skills_root, &expected, &declared).is_err());
2064 fs::remove_file(&skill_path).unwrap();
2065 }
2066 secure_create_file(&skill_path, b"review-skill").unwrap();
2067
2068 ownership.mark_cleanup_required(42).unwrap();
2069 materializer.cleanup(&ownership).unwrap();
2070 drop(materializer);
2071 let _ = fs::remove_dir_all(root);
2072 }
2073
2074 #[test]
2075 fn context_pack_materialization_is_private_exact_and_byte_revalidated() {
2076 let root = temp_root();
2077 let materializer = SessionEnvironmentMaterializer::new(
2078 root.clone(),
2079 Arc::new(FakeResolver),
2080 )
2081 .unwrap();
2082 let lineage = crate::protocol::ContextPackLineageReceipt {
2083 source_node_id: crate::protocol::NodeId::new("node-source").unwrap(),
2084 source_session: crate::protocol::SessionAddress {
2085 workspace_id: crate::protocol::WorkspaceId::new("source").unwrap(),
2086 session: crate::protocol::SessionKey {
2087 instance_id: AgentInstanceId(11),
2088 generation: SessionGeneration(3),
2089 },
2090 },
2091 source_provider: gate4agent_types::AgentId::new("codex").unwrap(),
2092 };
2093 let history = gate4agent_types::HistorySessionRecord {
2094 session_id: "fixture-session".to_owned(),
2095 title: Some("review".to_owned()),
2096 cwd: Some(r"C:\private\source".to_owned()),
2097 model: Some("codex-5".to_owned()),
2098 message_count: 2,
2099 completed_turn_count: None,
2100 total_tokens: 17,
2101 messages: vec![
2102 gate4agent_types::HistoryMessageRecord {
2103 role: gate4agent_types::HistoryMessageRole::User,
2104 text: "review the bounded patch".to_owned(),
2105 },
2106 gate4agent_types::HistoryMessageRecord {
2107 role: gate4agent_types::HistoryMessageRole::Assistant,
2108 text: "the bounded patch is ready".to_owned(),
2109 },
2110 ],
2111 };
2112 let pack = NodeContextPack::export(lineage, &history).unwrap();
2113 let receipt = pack.receipt().clone();
2114 let profile = NodeSessionMaterializationProfile::from_context(&pack).unwrap();
2115 let mut ownership = materializer
2116 .begin(
2117 MaterializationId::new("context-fixture").unwrap(),
2118 None,
2119 None,
2120 Some(receipt.clone()),
2121 owner(),
2122 None,
2123 &profile,
2124 50,
2125 )
2126 .unwrap();
2127 let environment = materializer
2128 .materialize(&mut ownership, &profile, 51)
2129 .unwrap();
2130 assert_eq!(environment.len(), 1);
2131 assert_eq!(environment[0].key, OsString::from(CONTEXT_ROOT_ENVIRONMENT_KEY));
2132 assert_eq!(
2133 environment[0].value.as_deref(),
2134 Some(ownership.root().join("context").as_os_str()),
2135 );
2136 let context_path = ownership.root().join("context").join(CONTEXT_PACK_FILE_NAME);
2137 let bytes = fs::read(&context_path).unwrap();
2138 assert!(!String::from_utf8_lossy(&bytes).contains(r"C:\private\source"));
2139 assert_eq!(
2140 materializer.revalidate_context(&ownership, &receipt).unwrap(),
2141 pack,
2142 );
2143
2144 secure_replace_file(&context_path, b"tampered-context").unwrap();
2145 assert!(materializer.revalidate_context(&ownership, &receipt).is_err());
2146 ownership.mark_cleanup_required(52).unwrap();
2147 materializer.cleanup(&ownership).unwrap();
2148 assert!(!ownership.root().exists());
2149 drop(materializer);
2150 let _ = fs::remove_dir_all(root);
2151 }
2152}