Skip to main content

gate4agent_node/
session_environment.rs

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