Skip to main content

aion_integrations/
environment.rs

1//! The DECLARED environment a harness subprocess is launched with.
2//!
3//! # Why inheritance is not a policy
4//!
5//! A spawned process inherits its parent's whole environment unless something stops it,
6//! and that inheritance is invisible: nobody wrote it down, nobody reviewed it, and it
7//! changes with whatever shell happened to start the worker. On 2026-08-11 that cost a
8//! live run — an agent's own shell exports `CLAUDECODE`, the worker inherited it, the
9//! spawned `claude-code-acp` saw it, refused to launch nested, and every `session/new`
10//! came back `-32603`. Nothing in the worker's configuration mentioned `CLAUDECODE`;
11//! nothing could have, because the variable was never declared anywhere.
12//!
13//! So a harness child's environment is CONSTRUCTED, never inherited. The document
14//! declares an allow-list of variable NAMES; the launcher resolves those names — and
15//! only those — against its own context; the child starts from empty and receives
16//! exactly the resolved pairs. A variable that is not named is absent in the child,
17//! whoever the parent is.
18//!
19//! # Names, not values
20//!
21//! The declaration carries names alone. A value belongs to the box the worker runs on
22//! (a credential, a home directory, a `PATH` assembled by that machine's login shell),
23//! and writing it into a document would put a secret in a file that gets committed,
24//! diffed and deployed. The document says WHICH variables cross the boundary; the
25//! launching context says what they are worth.
26//!
27//! # A missing pass-through is named, never discovered at exec
28//!
29//! An allow-list that omits `PATH` is a legitimate, honest declaration — an absolute
30//! command needs no lookup — but a program named without a path cannot be found without
31//! it, and the kernel's answer to that is a bare `No such file or directory` against a
32//! command the operator can see is spelled correctly. [`ChildEnvironment::require_for_program`]
33//! makes that refusal say `PATH` instead, before anything is spawned.
34
35use std::collections::BTreeMap;
36use std::path::Path;
37
38use crate::error::HarnessError;
39
40/// The variable a program without a path separator needs in order to be found.
41const PATH_VARIABLE: &str = "PATH";
42
43/// A fault in an environment DECLARATION, or in launching against one.
44///
45/// Separate from [`HarnessError`] because a declaration is validated where it is read —
46/// in a document checker, in a CLI, in a launcher — long before any harness exists to
47/// report through. [`From`] carries it onto the seam when a launch is what failed.
48#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum EnvironmentError {
51    /// A declared entry has an empty variable name.
52    ///
53    /// No process environment can carry one, so the entry is an unfilled template rather
54    /// than a variable anybody meant to pass through.
55    #[error(
56        "an environment pass-through entry has an empty variable name; every entry names one \
57         variable to carry into the harness child"
58    )]
59    EmptyName,
60    /// A declared entry is a `KEY=VALUE` pair rather than a name.
61    ///
62    /// The declaration passes a variable THROUGH: its value comes from the launching
63    /// context. Splitting the pair silently would leave the operator believing a value
64    /// they wrote is in force when the box's value is.
65    #[error(
66        "the environment pass-through entry `{entry}` contains `=`; entries are variable NAMES \
67         only — the value is taken from the launching context, so a KEY=VALUE pair here is a \
68         mistake to correct rather than reinterpret"
69    )]
70    NameContainsEquals {
71        /// The offending entry, verbatim.
72        entry: String,
73    },
74    /// The declaration omits a variable this particular launch cannot proceed without.
75    ///
76    /// Raised BEFORE the spawn, so the refusal names the variable instead of arriving as
77    /// the kernel's `No such file or directory` against a correctly spelled command.
78    #[error(
79        "cannot launch `{program}`: it is named without a path, so it is looked up on \
80         ${variable} — and ${variable} is not in the harness `env_pass` declaration, which \
81         carries {declared}. Add \"{variable}\" to `env_pass`, or give `command` an absolute path."
82    )]
83    MissingForExec {
84        /// The variable the launch needs and the declaration omits.
85        variable: &'static str,
86        /// The program that cannot be resolved without it.
87        program: String,
88        /// What the declaration does carry, rendered for the refusal.
89        declared: String,
90    },
91}
92
93impl From<EnvironmentError> for HarnessError {
94    fn from(error: EnvironmentError) -> Self {
95        // DETERMINISTIC, against `HarnessError::is_deterministic`'s own rule: every variant
96        // of this error is a property of how the run is CONFIGURED, and the next attempt
97        // reads the same document. A blank pass-through entry cannot become non-blank by
98        // being retried; a `KEY=VALUE` entry cannot become a name; a declaration that omits
99        // the variable the program is looked up on omits it just as hard a second time.
100        //
101        // It is deliberately not `Transport` — the channel never opened, and a config
102        // refusal presenting as a flaky pipe tells the operator the wrong story and spends
103        // the whole attempt budget confirming it.
104        Self::configuration(error.to_string())
105    }
106}
107
108/// The environment pass-through allow-list a document declares: variable NAMES, in the
109/// order they were written.
110///
111/// Order is preserved because it is the operator's own; duplicates are preserved for the
112/// same reason and collapse when the environment is built, where a later name simply
113/// resolves to the same value. Nothing here reads a value: this is the declaration, and
114/// [`Self::resolve`] is where it meets a launching context.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct EnvironmentDeclaration {
117    names: Vec<String>,
118}
119
120impl EnvironmentDeclaration {
121    /// The declaration carrying `names`.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`EnvironmentError::EmptyName`] for a blank entry and
126    /// [`EnvironmentError::NameContainsEquals`] for a `KEY=VALUE` pair — the two ways an
127    /// entry can look like a declaration without being one.
128    pub fn new(
129        names: impl IntoIterator<Item = impl Into<String>>,
130    ) -> Result<Self, EnvironmentError> {
131        let names = names.into_iter().map(Into::into).collect::<Vec<_>>();
132        for name in &names {
133            if name.trim().is_empty() {
134                return Err(EnvironmentError::EmptyName);
135            }
136            if name.contains('=') {
137                return Err(EnvironmentError::NameContainsEquals {
138                    entry: name.clone(),
139                });
140            }
141        }
142        Ok(Self { names })
143    }
144
145    /// The declaration naming NO variable.
146    ///
147    /// Infallible, because there is no entry to be malformed. A child launched under it
148    /// gets a genuinely empty environment, which is a coherent thing for an absolute
149    /// command that needs nothing at all.
150    ///
151    /// It is reachable only from a hand-written composition root, and that is deliberate
152    /// rather than an oversight in the grammar: a worker DOCUMENT cannot express it,
153    /// because `env_pass` takes one or more names and there is no spelling for none. An
154    /// empty list in a file is indistinguishable from an unfinished line, and the cost of
155    /// reading one as the other is an agent launched with no `PATH`, no `HOME` and no
156    /// credentials, failing three layers down for a reason nobody wrote. A caller
157    /// constructing this in code has stated it unambiguously; an author typing it into a
158    /// document has not. If the hermetic-empty case is ever wanted from a document it
159    /// needs its own explicit spelling, not a permissive reading of an empty list.
160    #[must_use]
161    pub fn empty() -> Self {
162        Self { names: Vec::new() }
163    }
164
165    /// The declared names, in the order they were written.
166    #[must_use]
167    pub fn names(&self) -> &[String] {
168        &self.names
169    }
170
171    /// Resolves the declaration against `context` — a snapshot of the launching process's
172    /// environment — into the exact, total environment a child receives.
173    ///
174    /// A declared name absent from `context` contributes NO variable: an unset variable
175    /// and a variable set to the empty string are different things, and inventing one for
176    /// the other would be a value nobody wrote. Which names came up absent is kept on the
177    /// result so a launcher can say so rather than leave the operator guessing.
178    #[must_use]
179    pub fn resolve(&self, context: &BTreeMap<String, String>) -> ChildEnvironment {
180        let mut pairs = Vec::new();
181        let mut absent = Vec::new();
182        let mut seen = Vec::new();
183        for name in &self.names {
184            if seen.iter().any(|already| already == name) {
185                continue;
186            }
187            seen.push(name.clone());
188            match context.get(name) {
189                Some(value) => pairs.push((name.clone(), value.clone())),
190                None => absent.push(name.clone()),
191            }
192        }
193        ChildEnvironment {
194            declared: self.names.clone(),
195            pairs,
196            absent,
197        }
198    }
199
200    /// Resolves the declaration against THIS process's environment.
201    ///
202    /// The launching context of a worker that spawns its own harness children. Non-UTF-8
203    /// names and values are skipped rather than lossily transcoded: a mangled credential
204    /// is worse than an absent one, and an absent one is reported.
205    #[must_use]
206    pub fn resolve_from_process(&self) -> ChildEnvironment {
207        self.resolve(&process_environment())
208    }
209}
210
211/// A snapshot of this process's environment, UTF-8 entries only.
212#[must_use]
213pub fn process_environment() -> BTreeMap<String, String> {
214    std::env::vars_os()
215        .filter_map(|(name, value)| Some((name.into_string().ok()?, value.into_string().ok()?)))
216        .collect()
217}
218
219/// The complete environment one harness child is launched with.
220///
221/// "Complete" is the whole point: a spawner applying this clears the child's environment
222/// first, so [`Self::pairs`] is not an overlay on an inherited set — it IS the set.
223#[derive(Clone, Debug, PartialEq, Eq)]
224pub struct ChildEnvironment {
225    declared: Vec<String>,
226    pairs: Vec<(String, String)>,
227    absent: Vec<String>,
228}
229
230impl ChildEnvironment {
231    /// The resolved `(name, value)` pairs — the child's entire environment.
232    #[must_use]
233    pub fn pairs(&self) -> &[(String, String)] {
234        &self.pairs
235    }
236
237    /// Every name the declaration carried, in declaration order.
238    #[must_use]
239    pub fn declared(&self) -> &[String] {
240        &self.declared
241    }
242
243    /// The declared names that the launching context did not carry, so a launcher can
244    /// report them rather than let a silently-absent credential surface as an agent
245    /// failure three layers down.
246    #[must_use]
247    pub fn absent(&self) -> &[String] {
248        &self.absent
249    }
250
251    /// Whether `name` is carried into the child.
252    #[must_use]
253    pub fn carries(&self, name: &str) -> bool {
254        self.pairs.iter().any(|(key, _)| key == name)
255    }
256
257    /// Refuses the launch when `program` cannot be executed under this environment,
258    /// naming the variable that is missing.
259    ///
260    /// The one mechanically decidable case is program lookup: a program written without a
261    /// path separator is resolved on `PATH`, and a child with no `PATH` cannot resolve it.
262    /// An absolute or relative path needs no lookup and is not gated here — the operator's
263    /// hermetic, `PATH`-free declaration is legitimate and stays legitimate.
264    ///
265    /// # Errors
266    ///
267    /// Returns [`EnvironmentError::MissingForExec`] naming `PATH` when `program` needs a
268    /// lookup this environment cannot perform.
269    pub fn require_for_program(&self, program: &Path) -> Result<(), EnvironmentError> {
270        if program.components().count() > 1 || program.is_absolute() {
271            return Ok(());
272        }
273        if self.carries(PATH_VARIABLE) {
274            return Ok(());
275        }
276        Err(EnvironmentError::MissingForExec {
277            variable: PATH_VARIABLE,
278            program: program.display().to_string(),
279            declared: self.rendered_declaration(),
280        })
281    }
282
283    /// The declaration as a refusal reads it out.
284    fn rendered_declaration(&self) -> String {
285        if self.declared.is_empty() {
286            return "no variables".to_owned();
287        }
288        self.declared.join(", ")
289    }
290}
291
292#[cfg(test)]
293mod tests;