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;