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