Skip to main content

gate4agent_node/
environment_profiles.rs

1use crate::protocol::{
2    AgentId, SessionMode, SpawnEnvironmentProfileId, SpawnEnvironmentProfileRevision,
3};
4use gate4agent_runtime_native::{
5    NativeLaunchProfile, NativeLaunchProfileId,
6};
7use gate4agent_types::TransportKind;
8use thiserror::Error;
9use crate::session_environment::NodeSessionMaterializationProfile;
10
11pub const MAX_NODE_ENVIRONMENT_PROFILES: usize = 128;
12
13/// Immutable host-local binding from one public profile revision to native
14/// child-environment resolvers. Resolver values remain inside the native
15/// profiles and are never serialized or formatted for diagnostics.
16pub struct NodeEnvironmentProfile {
17    id: SpawnEnvironmentProfileId,
18    revision: SpawnEnvironmentProfileRevision,
19    provider: AgentId,
20    pty: Option<NativeLaunchProfile>,
21    inline: Option<NativeLaunchProfile>,
22    materialization: Option<NodeSessionMaterializationProfile>,
23}
24
25impl NodeEnvironmentProfile {
26    pub fn new(
27        id: SpawnEnvironmentProfileId,
28        revision: SpawnEnvironmentProfileRevision,
29        provider: AgentId,
30        profiles: impl IntoIterator<Item = NativeLaunchProfile>,
31    ) -> Result<Self, NodeEnvironmentProfileError> {
32        Self::new_with_materialization(id, revision, provider, profiles, None)
33    }
34
35    pub fn new_with_materialization(
36        id: SpawnEnvironmentProfileId,
37        revision: SpawnEnvironmentProfileRevision,
38        provider: AgentId,
39        profiles: impl IntoIterator<Item = NativeLaunchProfile>,
40        materialization: Option<NodeSessionMaterializationProfile>,
41    ) -> Result<Self, NodeEnvironmentProfileError> {
42        let mut pty = None;
43        let mut inline = None;
44        for profile in profiles {
45            if profile.agent_id() != &provider {
46                return Err(NodeEnvironmentProfileError::ProviderMismatch);
47            }
48            let destination = match profile.transport() {
49                TransportKind::Pty => &mut pty,
50                TransportKind::Pipe => &mut inline,
51                TransportKind::Acp => {
52                    return Err(NodeEnvironmentProfileError::UnsupportedTransport)
53                }
54            };
55            if destination.replace(profile).is_some() {
56                return Err(NodeEnvironmentProfileError::DuplicateTransport);
57            }
58        }
59        if pty.is_none() && inline.is_none() {
60            return Err(NodeEnvironmentProfileError::Empty);
61        }
62        if pty.as_ref().zip(inline.as_ref()).is_some_and(|(pty, inline)| {
63            pty.id() == inline.id()
64        }) {
65            return Err(NodeEnvironmentProfileError::DuplicateNativeProfileId);
66        }
67        Ok(Self {
68            id,
69            revision,
70            provider,
71            pty,
72            inline,
73            materialization,
74        })
75    }
76
77    pub fn id(&self) -> &SpawnEnvironmentProfileId {
78        &self.id
79    }
80
81    pub fn revision(&self) -> &SpawnEnvironmentProfileRevision {
82        &self.revision
83    }
84
85    pub fn provider(&self) -> &AgentId {
86        &self.provider
87    }
88
89    pub(crate) fn into_parts(
90        self,
91    ) -> (
92        EnvironmentProfileBinding,
93        Vec<NativeLaunchProfile>,
94        Option<NodeSessionMaterializationProfile>,
95    ) {
96        let pty_id = self.pty.as_ref().map(|profile| profile.id().clone());
97        let inline_id = self.inline.as_ref().map(|profile| profile.id().clone());
98        let mut profiles = Vec::with_capacity(usize::from(self.pty.is_some()) + usize::from(self.inline.is_some()));
99        profiles.extend(self.pty);
100        profiles.extend(self.inline);
101        (
102            EnvironmentProfileBinding {
103                id: self.id,
104                revision: self.revision,
105                provider: self.provider,
106                pty_id,
107                inline_id,
108            },
109            profiles,
110            self.materialization,
111        )
112    }
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
116pub(crate) struct EnvironmentProfileBinding {
117    pub(crate) id: SpawnEnvironmentProfileId,
118    pub(crate) revision: SpawnEnvironmentProfileRevision,
119    pub(crate) provider: AgentId,
120    pty_id: Option<NativeLaunchProfileId>,
121    inline_id: Option<NativeLaunchProfileId>,
122}
123
124impl EnvironmentProfileBinding {
125    pub(crate) fn native_profile_id(
126        &self,
127        mode: SessionMode,
128    ) -> Option<&NativeLaunchProfileId> {
129        match mode {
130            SessionMode::Pty => self.pty_id.as_ref(),
131            SessionMode::Inline => self.inline_id.as_ref(),
132            // Environment profiles have no ACP binding yet -- callers already
133            // treat `None` here as "this profile does not cover the
134            // requested mode" (see `EnvironmentProfileBindingMismatch`), the
135            // same outcome a profile declaring only `pty` gets for a
136            // requested `Inline` mode today.
137            SessionMode::Acp => None,
138        }
139    }
140
141    pub(crate) fn native_profile_ids(&self) -> impl Iterator<Item = &NativeLaunchProfileId> {
142        self.pty_id.iter().chain(self.inline_id.iter())
143    }
144}
145
146#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
147pub enum NodeEnvironmentProfileError {
148    #[error("node environment profile requires at least one native transport binding")]
149    Empty,
150    #[error("node environment profile native binding targets another provider")]
151    ProviderMismatch,
152    #[error("node environment profile contains duplicate transport bindings")]
153    DuplicateTransport,
154    #[error("node environment profile transport is unsupported")]
155    UnsupportedTransport,
156    #[error("node environment profile reuses one native profile ID across transports")]
157    DuplicateNativeProfileId,
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use gate4agent_catalog::EnvMutation;
164    use gate4agent_runtime_native::{
165        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
166    };
167    use std::ffi::OsString;
168    use std::sync::Arc;
169
170    struct EmptyResolver;
171
172    impl NativeChildEnvironmentResolver for EmptyResolver {
173        fn resolve_child_environment(
174            &self,
175        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
176            Ok(vec![EnvMutation {
177                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
178                value: None,
179            }])
180        }
181    }
182
183    fn native(id: &str, provider: &str, transport: TransportKind) -> NativeLaunchProfile {
184        NativeLaunchProfile::new(
185            NativeLaunchProfileId::new(id).unwrap(),
186            AgentId::new(provider).unwrap(),
187            transport,
188            vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
189            Arc::new(EmptyResolver),
190        )
191        .unwrap()
192    }
193
194    #[test]
195    fn node_environment_profile_requires_exact_provider_and_transport_bindings() {
196        let id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
197        let revision = SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
198        let provider = AgentId::new("claude").unwrap();
199        let profile = NodeEnvironmentProfile::new(
200            id.clone(),
201            revision.clone(),
202            provider.clone(),
203            [
204                native("local-claude-pty", "claude", TransportKind::Pty),
205                native("local-claude-pipe", "claude", TransportKind::Pipe),
206            ],
207        )
208        .unwrap();
209        let (binding, native_profiles, materialization) = profile.into_parts();
210        assert!(materialization.is_none());
211        assert_eq!(binding.id, id);
212        assert_eq!(binding.revision, revision);
213        assert_eq!(binding.provider, provider);
214        assert_eq!(native_profiles.len(), 2);
215        assert_eq!(
216            binding.native_profile_id(SessionMode::Pty).unwrap().as_str(),
217            "local-claude-pty",
218        );
219        assert_eq!(
220            binding.native_profile_id(SessionMode::Inline).unwrap().as_str(),
221            "local-claude-pipe",
222        );
223
224        let mismatch = match NodeEnvironmentProfile::new(
225            SpawnEnvironmentProfileId::new("mismatch").unwrap(),
226            SpawnEnvironmentProfileRevision::new("r1").unwrap(),
227            AgentId::new("claude").unwrap(),
228            [native("mismatch-pty", "codex", TransportKind::Pty)],
229        ) {
230            Ok(_) => panic!("provider mismatch was accepted"),
231            Err(error) => error,
232        };
233        assert_eq!(mismatch, NodeEnvironmentProfileError::ProviderMismatch);
234    }
235}