Skip to main content

hatchery_node/
session_environment.rs

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";
36/// Exclusive ProviderHome env key for **Codex bundle layout only**
37/// (`supports_bundle_layout`). That gate is layout composition (Codex home must
38/// own skills paths); it is **not** the multi-config collision policy.
39///
40/// `CLAUDE_CONFIG_DIR` / `KIMI_CODE_HOME` / `GROK_HOME` are ordinary
41/// `NodeSessionPathBinding` → `ProviderHome` profile data — **no** exclusive
42/// layout constant. One-owner/org multi-config = multiple environment profiles.
43/// Same-cwd collision across different homes is spawn policy (refuse + managed
44/// worktree), not a second exclusivity key. See hatchery-websession-docs
45/// research `multi-account-node-vs-hatchery-2026-10-02` and plan
46/// `n-accounts-one-workspace-collision-2026-10-02`.
47///
48/// Station-profile axes (design ledger): this materializer owns
49/// **providerHome** (+ path classes) and pairs with workspace **cwd**.
50/// **sandbox** is the separate `ApprovalLevel` / catalog argv axis;
51/// optional **network** allowlist + dig2browser **browserProfile** ids are
52/// typed on `SpawnOverrides` / env-profile receipts; resolve echoes ids onto
53/// `ResolvedEnvironmentProfileReceipt` (refuse empty/whitespace at type
54/// construction; dig2browser-station reachability refuse stubbed until a
55/// cheap g4a-local probe exists). See hatchery-websession-docs
56/// `research/station-profile-and-os-sandbox-matrix-2026-10-02.md` and plan
57/// `station-network-and-browser-profile-knobs-2026-10-02.md` (ids on node;
58/// secrets never on C2; `C2 → node → drivers` + local station IPC later).
59const 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(&current) {
992            Ok(()) => {}
993            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => validate_secure_directory(&current)?,
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        // NODE-level multi-config: same generic ProviderHome binding used for
1533        // CODEX_HOME also relocates Claude/Kimi/Grok homes via profile data.
1534        // Temp materialization root only — never touches real ~/.claude etc.
1535        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            // Claude/Kimi layouts do not require an exclusive home key; Codex does.
1546            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}