Skip to main content

aion_server/worker/
workspace_root.rs

1//! `{workspace_root}` expansion — for declared action bodies, and for the
2//! path-valued settings of a worker document's `harness` section.
3//!
4//! The two consumers differ in one way that matters and are otherwise the same
5//! act. A declared BODY is a command string that gets parsed into an argv, so
6//! the splice must not change how it parses ([`WorkspaceRoot::expand`]). A
7//! harness SETTING is one whole path that nothing parses, so those rules do not
8//! apply to it and are not imposed on it ([`WorkspaceRoot::expand_setting`]);
9//! what does apply is the root being resolvable, absolute, spellable and
10//! spawnable. Both read the ONE resolved root, so a body and a setting in the
11//! same document can never disagree about where this box's workspaces live.
12//!
13//! A declared body runs with its environment cleared to `PATH` only, so it
14//! cannot expand `~` or read a variable to learn where session workspaces
15//! live. The workflow's start inputs cannot carry the location either — the
16//! operator's start contract is deliberately minimal, and the workspace root
17//! is a property of the SERVER, not of any one run. The server therefore
18//! states it: a declared command may carry the literal placeholder
19//! [`WORKSPACE_ROOT_PLACEHOLDER`], and the dispatch path replaces every
20//! occurrence with the server's own workspace root before the command is
21//! parsed.
22//!
23//! The root is the aion home's `clones/` directory
24//! ([`crate::config::aion_home`] → `<home>/clones`) — the same location the
25//! crate worker's provision handlers established (#175): durable history
26//! records workspace paths, so they must live somewhere that survives a host
27//! reboot, never the OS temp dir. There is no separate configurable value;
28//! the home is already the operator's one answer to "where does this
29//! server's state live" (#113), and resolving a second answer here is how
30//! two components come to disagree about one path.
31//!
32//! Resolution happens ONCE, at server-state construction, and every consumer
33//! — the declared-body dispatcher, the startup banner — reads that one
34//! value. A resolution failure is carried, not raised: boot proceeds, and
35//! the failure surfaces as a terminal dispatch refusal when (and only when)
36//! a placeholder-bearing body is dispatched. Bodies that do not use the
37//! placeholder are untouched by resolution failure.
38//!
39//! Expansion happens on the raw command STRING, before
40//! [`aion_worker::shell::ShellAction`] parses it into an argv. A root whose
41//! text would change that parse — whitespace splits a word, `{` can pair
42//! with a following `{` to open an interpolation, a quote opens a quoted
43//! region, NUL cannot cross `execve` — is refused rather than spliced,
44//! because a silent reshape of
45//! the declared command is exactly what the template layer exists to
46//! prevent. The root is server-controlled, so the refusal is theoretical;
47//! it is checked because "theoretical" is not "impossible".
48//!
49//! The COMMAND is held to the same standard: every placeholder occurrence
50//! must stand alone as one whole, unquoted argv word of the command as the
51//! template parses it. An occurrence inside a quoted region or glued to
52//! adjacent text would make the spliced root a FRAGMENT of some larger
53//! word, so what executes would not be the path the server resolved — the
54//! dispatch is refused by name instead ([`WorkspaceRootError::PlaceholderMisplaced`]).
55
56use std::path::{Path, PathBuf};
57
58use thiserror::Error;
59
60/// The literal placeholder a declared command carries where the server's
61/// workspace root belongs.
62///
63/// Defined by the LANGUAGE crate, not here. It is a token of the documents
64/// this server executes, and the AWL checker refuses a harness path setting by
65/// the same spelling this module splices — one definition in the crate both
66/// already depend on is that agreement, where a second copy would be the drift.
67pub use aion_awl::WORKSPACE_ROOT_PLACEHOLDER;
68
69/// The directory under the aion home where session workspaces live.
70const CLONES_DIRECTORY: &str = "clones";
71
72/// Why the workspace root could not be resolved or spliced into a declared
73/// command.
74///
75/// Every variant is terminal at dispatch time: the root is a property of the
76/// server's configuration and filesystem, so retrying the dispatch cannot
77/// change it.
78#[derive(Debug, Clone, Error, PartialEq, Eq)]
79pub enum WorkspaceRootError {
80    /// The aion home itself could not be resolved, so there is no root to
81    /// derive.
82    #[error("the aion home cannot be resolved, so there is no workspace root: {reason}")]
83    Unresolvable {
84        /// The home resolution's own diagnosis.
85        reason: String,
86    },
87    /// The resolved root is not an absolute path.
88    ///
89    /// A relative root resolves against the server's current directory, so
90    /// the same recorded history would name a different location after a
91    /// restart from elsewhere.
92    #[error(
93        "the workspace root `{path}` is not an absolute path; a relative root names a \
94         different location after a restart from a different directory"
95    )]
96    NotAbsolute {
97        /// The offending path, rendered for the refusal.
98        path: String,
99    },
100    /// The resolved root is not valid UTF-8, so it has no faithful spelling
101    /// inside a declared command string.
102    #[error(
103        "the workspace root `{path}` is not valid UTF-8, so it cannot be spliced into a \
104         declared command"
105    )]
106    NotUnicode {
107        /// The offending path, rendered lossily for the refusal.
108        path: String,
109    },
110    /// The resolved root contains a character that would change how the
111    /// declared command parses after splicing.
112    #[error(
113        "the workspace root `{path}` contains {character}, which would change the parsed \
114         shape of the declared command it is spliced into"
115    )]
116    ShapeChanging {
117        /// The offending path, rendered for the refusal.
118        path: String,
119        /// Which shape-changing character was found, named for the refusal.
120        character: &'static str,
121    },
122    /// A placeholder occurrence in the declared command is not a whole,
123    /// unquoted argv word, so the spliced root would become a fragment of
124    /// some larger word instead of the path the server resolved.
125    #[error(
126        "the declared command carries {{workspace_root}} {placement}; every occurrence \
127         must stand alone as one whole, unquoted argv word, because the root is spliced \
128         into the command string before it is parsed"
129    )]
130    PlaceholderMisplaced {
131        /// Where the offending occurrence sits, named for the refusal.
132        placement: &'static str,
133    },
134    /// The resolved root contains a NUL byte, so no process can be launched in
135    /// a path spliced with it.
136    ///
137    /// The command-splicing path refuses NUL under
138    /// [`WorkspaceRootError::ShapeChanging`] alongside the characters that
139    /// would reshape a parse. A harness SETTING is never parsed, so NUL is the
140    /// only one of that set which still bites — and it bites for its own
141    /// reason, one layer lower: `execve` takes NUL-terminated strings, so a
142    /// path carrying one cannot be handed to the kernel at all.
143    #[error(
144        "the workspace root `{path}` contains a NUL byte, which cannot cross `execve`, so no \
145         process can be launched in it"
146    )]
147    NotSpawnable {
148        /// The offending path, rendered for the refusal.
149        path: String,
150    },
151    /// The root directory does not exist and could not be created.
152    #[error("the workspace root directory `{path}` could not be created: {error}")]
153    CreationFailed {
154        /// The directory that could not be created.
155        path: String,
156        /// The io error's own diagnosis.
157        error: String,
158    },
159}
160
161/// A declared command with its workspace-root placeholder expanded.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct ExpandedCommand {
164    /// The command with every placeholder occurrence replaced by the root.
165    pub command: String,
166    /// The resolved root that was spliced in, for the dispatch log.
167    pub workspace_root: String,
168}
169
170/// The server's one workspace root, resolved once and read everywhere.
171///
172/// Carries the RESULT of resolution rather than requiring it: a server whose
173/// home cannot resolve must still boot (nothing else needs the root), so the
174/// failure is held here and surfaces as a terminal refusal at the first
175/// dispatch of a placeholder-bearing body.
176#[derive(Debug, Clone)]
177pub struct WorkspaceRoot {
178    resolution: Result<PathBuf, WorkspaceRootError>,
179}
180
181impl WorkspaceRoot {
182    /// Resolve the workspace root from the server's aion home:
183    /// [`crate::config::aion_home`] → `<home>/clones`.
184    ///
185    /// This is the ONE derivation of the value. Everything downstream — the
186    /// declared-body dispatcher, the startup banner, any composition point
187    /// reading the banner — consumes the resolved value rather than
188    /// re-deriving it, so two components can never disagree about where
189    /// workspaces live.
190    #[must_use]
191    pub fn resolve() -> Self {
192        let resolution = crate::config::aion_home()
193            .map(|home| root_under(&home.path))
194            .map_err(|error| WorkspaceRootError::Unresolvable {
195                reason: error.to_string(),
196            });
197        Self { resolution }
198    }
199
200    /// Build a root from an already-decided resolution.
201    ///
202    /// For embedders and tests that need a known root (or a known failure)
203    /// without touching the process environment the production
204    /// [`WorkspaceRoot::resolve`] reads. Single derivation is a convention
205    /// the production path upholds, not an enforced invariant: nothing stops
206    /// a caller constructing a second, disagreeing root here.
207    #[must_use]
208    pub const fn from_resolution(resolution: Result<PathBuf, WorkspaceRootError>) -> Self {
209        Self { resolution }
210    }
211
212    /// The one-line rendering of this root for the startup banner: the
213    /// resolved path's display form, or `unresolvable: <reason>` when
214    /// resolution failed — never a fabricated value.
215    #[must_use]
216    pub fn banner_value(&self) -> String {
217        match self.resolved() {
218            Ok(path) => path.display().to_string(),
219            Err(error) => format!("unresolvable: {error}"),
220        }
221    }
222
223    /// The resolved root, or why there is none.
224    ///
225    /// Read-only: reporting the value (the startup banner) must not create
226    /// the directory. Creation happens at expansion time, where a failure
227    /// has a dispatch to refuse.
228    ///
229    /// # Errors
230    ///
231    /// Returns the held [`WorkspaceRootError`] when resolution failed.
232    pub fn resolved(&self) -> Result<&Path, &WorkspaceRootError> {
233        match &self.resolution {
234            Ok(path) => Ok(path.as_path()),
235            Err(error) => Err(error),
236        }
237    }
238
239    /// Expand every [`WORKSPACE_ROOT_PLACEHOLDER`] occurrence in `command`
240    /// with the resolved root.
241    ///
242    /// A command without the placeholder is untouched — `Ok(None)`, no
243    /// resolution requirement, no filesystem side effect — so an unresolved
244    /// root never affects a body that does not use it. A command WITH the
245    /// placeholder requires the full chain: every occurrence standing alone
246    /// as one whole, unquoted argv word, then a resolved, absolute, UTF-8
247    /// root with no shape-changing characters, and an existing directory
248    /// (created here, idempotently and owner-only `0700`, if missing — an
249    /// already-existing root keeps whatever permissions the operator gave
250    /// it; they are the operator's own).
251    ///
252    /// # Errors
253    ///
254    /// Returns [`WorkspaceRootError`] when a placeholder occurrence is not a
255    /// whole unquoted argv word, when the root is unresolved, not absolute,
256    /// not valid UTF-8, contains a character that would change the command's
257    /// parsed shape, or does not exist and cannot be created.
258    pub fn expand(&self, command: &str) -> Result<Option<ExpandedCommand>, WorkspaceRootError> {
259        if !command.contains(WORKSPACE_ROOT_PLACEHOLDER) {
260            return Ok(None);
261        }
262        if let Some(placement) = misplaced_placeholder(command) {
263            return Err(WorkspaceRootError::PlaceholderMisplaced { placement });
264        }
265        let (root, root_text) = self.spliceable_root()?;
266        if let Some(character) = shape_changing_character(root_text) {
267            return Err(WorkspaceRootError::ShapeChanging {
268                path: root_text.to_owned(),
269                character,
270            });
271        }
272        create_root_directory(root).map_err(|error| WorkspaceRootError::CreationFailed {
273            path: root_text.to_owned(),
274            error: error.to_string(),
275        })?;
276        Ok(Some(ExpandedCommand {
277            command: command.replace(WORKSPACE_ROOT_PLACEHOLDER, root_text),
278            workspace_root: root_text.to_owned(),
279        }))
280    }
281
282    /// Expand every [`WORKSPACE_ROOT_PLACEHOLDER`] occurrence in a harness
283    /// section's PATH-VALUED setting — an ACP `command`, either kind's `cwd`, a
284    /// Norn `binary` — with the resolved root.
285    ///
286    /// A value without the placeholder is untouched: `Ok(None)`, no resolution
287    /// requirement, no filesystem side effect. A document that names an
288    /// absolute path outright is therefore unaffected by an unresolvable root,
289    /// exactly as it is unaffected by one today.
290    ///
291    /// The rules that govern [`WorkspaceRoot::expand`]'s COMMAND splice are
292    /// deliberately NOT applied here, because a setting is not a command:
293    ///
294    /// - No placeholder-placement rule. A command is split into argv words, so
295    ///   an occurrence glued to other text would splice a fragment of some
296    ///   larger word. A setting is one whole value that nothing splits, so
297    ///   `{workspace_root}/agents` is the ordinary way to write it. Where the
298    ///   placeholder may stand is the LANGUAGE's rule, refused at
299    ///   `aion_awl::harness_path_form` — it must lead the value — and this
300    ///   layer does not restate it.
301    /// - No shape-changing-character rule, save NUL. Whitespace, braces and
302    ///   quotes change how a command PARSES; a directory named `/Users/a b` is simply
303    ///   a directory, and refusing it here would refuse a legitimate path for a
304    ///   reason that belongs to a different consumer. NUL survives the cut on
305    ///   its own footing: `execve` cannot carry it.
306    ///
307    /// The ROOT directory is created (idempotently, owner-only `0700`) exactly
308    /// as the command path creates it, and for the harder reason: a process
309    /// cannot be started in a directory that does not exist. Only the root —
310    /// a value naming something BENEATH it (`{workspace_root}/assistant`) is
311    /// the document's claim about a tree that should already be there, and
312    /// conjuring an empty one would turn a mistyped path into an agent working
313    /// against nothing. The adapter refuses a missing working directory by
314    /// name, before it spawns.
315    ///
316    /// # Errors
317    ///
318    /// Returns [`WorkspaceRootError`] when the root is unresolved, not
319    /// absolute, not valid UTF-8, carries a NUL byte, or does not exist and
320    /// cannot be created. Every one of them is terminal — the root is a
321    /// property of this box's configuration and filesystem, so a second attempt
322    /// reads the same answer.
323    pub fn expand_setting(&self, value: &str) -> Result<Option<String>, WorkspaceRootError> {
324        if !value.contains(WORKSPACE_ROOT_PLACEHOLDER) {
325            return Ok(None);
326        }
327        let (root, root_text) = self.spliceable_root()?;
328        if root_text.contains('\0') {
329            return Err(WorkspaceRootError::NotSpawnable {
330                path: root_text.to_owned(),
331            });
332        }
333        create_root_directory(root).map_err(|error| WorkspaceRootError::CreationFailed {
334            path: root_text.to_owned(),
335            error: error.to_string(),
336        })?;
337        Ok(Some(value.replace(WORKSPACE_ROOT_PLACEHOLDER, root_text)))
338    }
339
340    /// The resolved root as both a path and its text, or why it cannot be
341    /// spliced into anything at all.
342    ///
343    /// The three requirements every consumer shares, stated once: resolution
344    /// succeeded, the root is absolute (a relative one would name a different
345    /// place after a restart from elsewhere), and it has a faithful UTF-8
346    /// spelling to splice.
347    fn spliceable_root(&self) -> Result<(&Path, &str), WorkspaceRootError> {
348        let root = self.resolution.as_ref().map_err(Clone::clone)?;
349        if !root.is_absolute() {
350            return Err(WorkspaceRootError::NotAbsolute {
351                path: root.to_string_lossy().into_owned(),
352            });
353        }
354        let root_text = root
355            .to_str()
356            .ok_or_else(|| WorkspaceRootError::NotUnicode {
357                path: root.to_string_lossy().into_owned(),
358            })?;
359        Ok((root.as_path(), root_text))
360    }
361}
362
363/// Derive the workspace root from the aion home: `<home>/clones`.
364///
365/// The ONE derivation rule, factored pure so a test can pin it without
366/// resolving a real home. The location is the crate worker's established one
367/// (#175): durable history records workspace paths, so they live under the
368/// home, never the OS temp dir.
369fn root_under(home: &Path) -> PathBuf {
370    home.join(CLONES_DIRECTORY)
371}
372
373/// Create the root directory (and any missing ancestors), owner-only.
374///
375/// New directories are minted `0700`: the root holds session workspaces —
376/// operator repositories and agent working trees — so nothing else on the
377/// host gets a default read into them. An ALREADY-existing root is left
378/// exactly as found; its permissions are the operator's own configuration,
379/// not this function's to correct.
380fn create_root_directory(root: &Path) -> std::io::Result<()> {
381    let mut builder = std::fs::DirBuilder::new();
382    builder.recursive(true);
383    // The crate's portability gate is target-cfg (see `[target.'cfg(unix)']`
384    // in Cargo.toml); the mode is a unix concept, so it is gated the same way.
385    #[cfg(unix)]
386    {
387        use std::os::unix::fs::DirBuilderExt as _;
388        builder.mode(0o700);
389    }
390    builder.create(root)
391}
392
393/// How a stretch of the raw command string is quoted, by the SAME rules the
394/// worker SDK's command template parses with
395/// (`aion_worker::shell::template::CommandTemplate::parse`): single quotes
396/// are literal until the closing single quote, double quotes likewise, and
397/// each region is entered only from unquoted text.
398#[derive(Clone, Copy, PartialEq, Eq)]
399enum QuoteContext {
400    /// Plain command text: whitespace here splits words.
401    Unquoted,
402    /// Inside a `'…'` region.
403    Single,
404    /// Inside a `"…"` region.
405    Double,
406}
407
408/// The template-rule quoting context at byte `position` of `command`.
409///
410/// An unterminated quote leaves the tail of the command inside the region,
411/// which is also how the template treats it (it refuses the parse) — so a
412/// placeholder after an unclosed quote reads as quoted here and is refused.
413fn quote_context_at(command: &str, position: usize) -> QuoteContext {
414    let mut context = QuoteContext::Unquoted;
415    for (index, character) in command.char_indices() {
416        if index >= position {
417            break;
418        }
419        context = match (context, character) {
420            (QuoteContext::Unquoted, '\'') => QuoteContext::Single,
421            (QuoteContext::Unquoted, '"') => QuoteContext::Double,
422            (QuoteContext::Single, '\'') | (QuoteContext::Double, '"') => QuoteContext::Unquoted,
423            (current, _) => current,
424        };
425    }
426    context
427}
428
429/// Where the first misplaced placeholder occurrence sits, named for the
430/// refusal — or `None` when EVERY occurrence stands alone as one whole,
431/// unquoted argv word of the command as the template parses it.
432///
433/// Walked with the template's own quoting rules rather than a regex guess:
434/// a word boundary is unquoted whitespace (or an end of the command), and a
435/// neighbouring quote character glues the occurrence into a larger word just
436/// as any other adjacent text does.
437fn misplaced_placeholder(command: &str) -> Option<&'static str> {
438    for (start, _) in command.match_indices(WORKSPACE_ROOT_PLACEHOLDER) {
439        let end = start + WORKSPACE_ROOT_PLACEHOLDER.len();
440        // The placeholder contains no quote characters, so the context of its
441        // first byte is the context of the whole occurrence.
442        match quote_context_at(command, start) {
443            QuoteContext::Single => return Some("inside single quotes"),
444            QuoteContext::Double => return Some("inside double quotes"),
445            QuoteContext::Unquoted => {}
446        }
447        // An unquoted position's immediate neighbours are unquoted too (a
448        // quoted region can only end at its own closing quote, which is never
449        // whitespace), so plain whitespace checks are the template's word
450        // boundaries here.
451        let starts_a_word = command[..start]
452            .chars()
453            .next_back()
454            .is_none_or(char::is_whitespace);
455        let ends_a_word = command[end..]
456            .chars()
457            .next()
458            .is_none_or(char::is_whitespace);
459        if !starts_a_word || !ends_a_word {
460            return Some("glued to adjacent text");
461        }
462    }
463    None
464}
465
466/// The first character in `root` that would change a declared command's
467/// parsed shape, named for the refusal — or `None` when the root splices
468/// cleanly.
469///
470/// The set mirrors what the worker SDK's command template gives meaning to:
471/// whitespace splits words, `'` and `"` open quoted regions, and NUL cannot
472/// cross `execve` at all.
473///
474/// `{` is in the set because an interpolation opens on `{{` and a resolved
475/// root ends where the declared command's own text begins: a root ending in
476/// `{` beside a command continuing with `{` is a `{{` neither of them wrote.
477/// A lone `{` on its own means nothing, and that is exactly why the pairing
478/// has to be refused at the seam rather than looked for afterwards.
479///
480/// `$` is NOT in the set and no longer needs to be: it carries no meaning in
481/// a declared command, having stopped opening a parameter reference when the
482/// language collapsed onto one interpolation syntax.
483fn shape_changing_character(root: &str) -> Option<&'static str> {
484    for character in root.chars() {
485        if character.is_whitespace() {
486            return Some("whitespace");
487        }
488        match character {
489            '{' => return Some("`{`"),
490            '\'' => return Some("a single quote"),
491            '"' => return Some("a double quote"),
492            '\0' => return Some("a NUL byte"),
493            _ => {}
494        }
495    }
496    None
497}
498
499#[cfg(test)]
500mod tests {
501    use std::path::PathBuf;
502
503    use std::path::Path;
504
505    use super::{
506        ExpandedCommand, WORKSPACE_ROOT_PLACEHOLDER, WorkspaceRoot, WorkspaceRootError, root_under,
507        shape_changing_character,
508    };
509
510    /// What a test returns. Every fallible step is carried rather than
511    /// unwrapped, because the workspace denies panicking accessors in test
512    /// code as firmly as in library code.
513    type TestResult = Result<(), Box<dyn std::error::Error>>;
514
515    fn unresolved() -> WorkspaceRoot {
516        WorkspaceRoot::from_resolution(Err(WorkspaceRootError::Unresolvable {
517            reason: "AION_HOME must not be empty".to_owned(),
518        }))
519    }
520
521    #[test]
522    fn expansion_replaces_every_occurrence() -> TestResult {
523        let scratch = tempfile::tempdir()?;
524        let root = scratch.path().join("clones");
525        let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
526        let root_text = root.to_string_lossy().into_owned();
527        let expanded = workspace
528            .expand("sh -c 'x' -- {workspace_root} $run_id {workspace_root}")?
529            .ok_or("a placeholder-bearing command must expand")?;
530        assert_eq!(
531            expanded,
532            ExpandedCommand {
533                command: format!("sh -c 'x' -- {root_text} $run_id {root_text}"),
534                workspace_root: root_text,
535            }
536        );
537        Ok(())
538    }
539
540    #[test]
541    fn a_command_without_the_placeholder_is_not_expanded() -> TestResult {
542        let scratch = tempfile::tempdir()?;
543        let resolved = WorkspaceRoot::from_resolution(Ok(scratch.path().join("clones")));
544        assert_eq!(resolved.expand("echo $greeting")?, None);
545        // Resolution failure must not touch a body that does not use the
546        // placeholder — the refusal is scoped to bodies that need the root.
547        assert_eq!(unresolved().expand("echo $greeting")?, None);
548        Ok(())
549    }
550
551    #[test]
552    fn a_setting_expands_to_the_root_and_the_root_directory_is_made() -> TestResult {
553        let scratch = tempfile::tempdir()?;
554        let root = scratch.path().join("clones");
555        let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
556        let root_text = root.to_string_lossy().into_owned();
557        assert!(
558            !root.exists(),
559            "the fixture must start with the root ABSENT, or the creation claim is vacuous"
560        );
561
562        assert_eq!(
563            workspace.expand_setting(WORKSPACE_ROOT_PLACEHOLDER)?,
564            Some(root_text.clone()),
565            "a bare placeholder is the root itself"
566        );
567        assert_eq!(
568            workspace.expand_setting("{workspace_root}/assistant")?,
569            Some(format!("{root_text}/assistant")),
570            "a setting is one whole value: the placeholder leads it and the rest rides along"
571        );
572        assert!(
573            root.is_dir(),
574            "a setting the harness will spawn in must exist by the time it is handed over"
575        );
576        Ok(())
577    }
578
579    #[test]
580    fn a_setting_without_the_placeholder_is_not_expanded() -> TestResult {
581        let scratch = tempfile::tempdir()?;
582        let resolved = WorkspaceRoot::from_resolution(Ok(scratch.path().join("clones")));
583        assert_eq!(resolved.expand_setting("/srv/agents/workspace")?, None);
584        // The document that names its own absolute path is exactly as launchable
585        // on a box with no resolvable home as it was before expansion existed.
586        assert_eq!(unresolved().expand_setting("/srv/agents/workspace")?, None);
587        Ok(())
588    }
589
590    #[test]
591    // No `TestResult`: every arm here is an assertion on a refusal, so there is no `?` to
592    // propagate and a wrapper return would be a shape this test never uses.
593    fn a_setting_carrying_the_placeholder_needs_a_usable_root() {
594        assert!(
595            matches!(
596                unresolved().expand_setting("{workspace_root}/assistant"),
597                Err(WorkspaceRootError::Unresolvable { .. })
598            ),
599            "a box with no resolvable home refuses the launch by name"
600        );
601        assert!(
602            matches!(
603                WorkspaceRoot::from_resolution(Ok(PathBuf::from("relative/clones")))
604                    .expand_setting("{workspace_root}/assistant"),
605                Err(WorkspaceRootError::NotAbsolute { .. })
606            ),
607            "a relative root cannot make an absolute setting"
608        );
609        assert!(
610            matches!(
611                WorkspaceRoot::from_resolution(Ok(PathBuf::from("/srv/clo\0nes")))
612                    .expand_setting("{workspace_root}")
613                    .as_ref(),
614                Err(WorkspaceRootError::NotSpawnable { .. })
615            ),
616            "a NUL cannot cross execve, so no process could ever be started there"
617        );
618    }
619
620    #[test]
621    fn a_setting_accepts_a_root_the_command_splice_refuses() -> TestResult {
622        // The one place the two consumers deliberately diverge, pinned WITH its
623        // control so the pin cannot pass by accident: a root containing a space
624        // would change how a declared command parses and is refused there, while
625        // a directory with a space in its name is just a directory and must be
626        // usable as a working directory. If a future edit routes settings through
627        // the command rules, the first assertion breaks; if it drops the command
628        // rules, the second breaks.
629        let scratch = tempfile::tempdir()?;
630        let root = scratch.path().join("my clones");
631        let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
632        assert!(
633            matches!(
634                workspace.expand("ls {workspace_root}"),
635                Err(WorkspaceRootError::ShapeChanging {
636                    character: "whitespace",
637                    ..
638                })
639            ),
640            "the command splice still refuses a root that would reshape the parse"
641        );
642        assert_eq!(
643            workspace.expand_setting("{workspace_root}")?,
644            Some(root.to_string_lossy().into_owned()),
645            "the same root is a perfectly good directory to stand in"
646        );
647        Ok(())
648    }
649
650    #[test]
651    fn the_root_is_derived_as_the_homes_clones_directory() {
652        // Pins the derivation rule AND the directory name: renaming
653        // `CLONES_DIRECTORY` (or changing the rule) must break here, because
654        // durable history already records paths under this exact location.
655        assert_eq!(
656            root_under(Path::new("/x")),
657            std::path::PathBuf::from("/x/clones")
658        );
659        assert_eq!(
660            root_under(Path::new("/Users/operator/.aion")),
661            std::path::PathBuf::from("/Users/operator/.aion/clones")
662        );
663    }
664
665    #[test]
666    fn the_banner_value_reports_the_path_or_the_failure() -> TestResult {
667        let scratch = tempfile::tempdir()?;
668        let root = scratch.path().join("clones");
669        let resolved = WorkspaceRoot::from_resolution(Ok(root.clone()));
670        assert_eq!(resolved.banner_value(), root.display().to_string());
671        let failed = unresolved().banner_value();
672        assert!(
673            failed.starts_with("unresolvable: "),
674            "an unresolved root must be reported as exactly that: {failed}"
675        );
676        assert!(
677            failed.contains("AION_HOME must not be empty"),
678            "the banner must carry the resolution failure's own reason: {failed}"
679        );
680        Ok(())
681    }
682
683    #[test]
684    fn a_placeholder_that_is_a_whole_bare_word_is_accepted() -> TestResult {
685        let scratch = tempfile::tempdir()?;
686        let workspace = WorkspaceRoot::from_resolution(Ok(scratch.path().join("clones")));
687        assert!(
688            workspace
689                .expand("sh -c 'x' -- {workspace_root} $run_id")?
690                .is_some(),
691            "a bare-word placeholder must expand"
692        );
693        Ok(())
694    }
695
696    #[test]
697    fn a_misplaced_placeholder_is_refused_naming_the_placement() -> TestResult {
698        let scratch = tempfile::tempdir()?;
699        let root = scratch.path().join("clones");
700        for (command, placement) in [
701            // Inside a single-quoted region the template takes it literally,
702            // so the spliced root would hide inside one quoted word.
703            ("sh -c '{workspace_root}'", "inside single quotes"),
704            // Likewise inside double quotes.
705            ("echo \"{workspace_root}\"", "inside double quotes"),
706            // Glued to preceding text.
707            ("echo x{workspace_root}", "glued to adjacent text"),
708            // Glued to following text.
709            ("echo {workspace_root}/sub", "glued to adjacent text"),
710            // Glued on both sides at once.
711            ("echo x{workspace_root}/sub", "glued to adjacent text"),
712            // Glued to a quoted region: adjacency, not quoting, is the defect.
713            ("echo ''{workspace_root}", "glued to adjacent text"),
714            // A `{` prefix would make the template read `{{workspace_root}` as
715            // an interpolation opening, not the placeholder at all.
716            ("echo {{workspace_root}", "glued to adjacent text"),
717            // ONE misplaced occurrence refuses even when another is bare.
718            (
719                "echo {workspace_root} x{workspace_root}",
720                "glued to adjacent text",
721            ),
722        ] {
723            let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
724            let Err(error) = workspace.expand(command) else {
725                return Err(format!("command {command:?} must be refused").into());
726            };
727            assert_eq!(
728                error,
729                WorkspaceRootError::PlaceholderMisplaced { placement },
730                "command {command:?} must be refused as {placement}"
731            );
732        }
733        assert!(
734            !root.exists(),
735            "a refused command must not create the root directory"
736        );
737        Ok(())
738    }
739
740    #[test]
741    fn a_declared_body_from_a_compiled_document_passes_the_placement_guard() -> TestResult {
742        // The bare-word cell above hands `expand` a string this file wrote. This
743        // one hands it a command that came out of `aion_awl::compile` — parsed
744        // from a `run "…"` body, carried through the contract, and read back off
745        // `ActionBodyContract::Run`. That round trip is the one a real dispatch
746        // makes, and a compiler that ever escaped, re-quoted or re-spaced the
747        // placeholder would break the guard without any string in this file
748        // changing.
749        let scratch = tempfile::tempdir()?;
750        let workspace = WorkspaceRoot::from_resolution(Ok(scratch.path().join("clones")));
751        let source = concat!(
752            "//! A declared body carrying the placeholder as a whole bare word.\n",
753            "workflow guard_probe\n",
754            "  input run_id: String\n",
755            "  outcome done: type RunOutcome, route success\n",
756            "\n",
757            "type RunOutcome { exit_code: Int, stdout: String, stderr: String }\n",
758            "\n",
759            "worker prober\n",
760            "  action provision(run_id: String) -> RunOutcome\n",
761            "    run \"sh -c 'printf %s \\\"$1/$2\\\"' -- {workspace_root} {{run_id}}\"\n",
762            "\n",
763            "step probe\n",
764            "  provision(run_id: run_id) -> provisioned\n",
765            "  provisioned |> route done\n",
766        );
767        let compiled = aion_awl::compile(source, Path::new("."))
768            .map_err(|error| format!("the probe document must compile: {error}"))?;
769        let command = compiled
770            .contract
771            .workers
772            .iter()
773            .flat_map(|worker| &worker.actions)
774            .find_map(|action| match &action.body {
775                Some(aion_package::ActionBodyContract::Run { command })
776                    if action.name == "provision" =>
777                {
778                    Some(command.clone())
779                }
780                _ => None,
781            })
782            .ok_or("the probe document must declare a bodied provision")?;
783        assert!(
784            command.contains(WORKSPACE_ROOT_PLACEHOLDER),
785            "the compiled body must still carry the placeholder verbatim: {command}"
786        );
787        assert!(
788            workspace.expand(&command)?.is_some(),
789            "a compiled declared body must pass the placement guard and expand"
790        );
791        Ok(())
792    }
793
794    #[tokio::test]
795    async fn every_accepted_printable_ascii_root_survives_the_real_parser() -> TestResult {
796        // The refusal set is tied to the REAL parser, not to an enumeration:
797        // for every printable ASCII character, either `expand` refuses the
798        // root outright, or the expanded probe command — the placeholder as a
799        // bare argv word — executes through the worker SDK's own
800        // `ShellAction` and observes EXACTLY the root as its argument.
801        let scratch = tempfile::tempdir()?;
802        let mut executed = 0usize;
803        for code in 0x20u8..=0x7Eu8 {
804            let character = char::from(code);
805            let root = scratch.path().join(format!("with{character}char"));
806            let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
807            let probe = format!("printf %s {WORKSPACE_ROOT_PLACEHOLDER}");
808            match workspace.expand(&probe) {
809                Err(_) => {}
810                Ok(expanded) => {
811                    let expanded =
812                        expanded.ok_or("a placeholder-bearing probe must expand or refuse")?;
813                    let action = aion_worker::shell::ShellAction::new(&expanded.command).map_err(
814                        |error| format!("accepted root {root:?} failed to parse: {error}"),
815                    )?;
816                    let (context, _cancellation) = aion_worker::ActivityContext::new(
817                        aion_core::WorkflowId::new_v4(),
818                        aion_core::RunId::new_v4(),
819                        aion_core::ActivityId::from_sequence_position(1),
820                        1,
821                    );
822                    let outcome = action
823                        .run(&std::collections::BTreeMap::new(), &context)
824                        .await
825                        .map_err(|error| {
826                            format!("accepted root {root:?} failed to execute: {error}")
827                        })?;
828                    assert_eq!(
829                        outcome.stdout, expanded.workspace_root,
830                        "the command must observe exactly the accepted root {root:?}"
831                    );
832                    executed += 1;
833                }
834            }
835        }
836        assert!(
837            executed > 0,
838            "at least one printable root must be accepted, or this test refused everything \
839             and proved nothing"
840        );
841        Ok(())
842    }
843
844    #[test]
845    fn an_unresolved_root_refuses_a_placeholder_bearing_command_by_name() -> TestResult {
846        let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
847        let Err(error) = unresolved().expand(&command) else {
848            return Err("an unresolved root must refuse expansion".into());
849        };
850        assert_eq!(
851            error,
852            WorkspaceRootError::Unresolvable {
853                reason: "AION_HOME must not be empty".to_owned(),
854            }
855        );
856        assert!(
857            error.to_string().contains("AION_HOME must not be empty"),
858            "the refusal must carry the resolution failure's own reason: {error}"
859        );
860        Ok(())
861    }
862
863    #[test]
864    fn a_relative_root_is_refused() -> TestResult {
865        let workspace = WorkspaceRoot::from_resolution(Ok(PathBuf::from("relative/clones")));
866        let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
867        let Err(error) = workspace.expand(&command) else {
868            return Err("a relative root must be refused".into());
869        };
870        assert_eq!(
871            error,
872            WorkspaceRootError::NotAbsolute {
873                path: "relative/clones".to_owned(),
874            }
875        );
876        Ok(())
877    }
878
879    #[test]
880    fn a_shape_changing_root_is_refused_naming_the_character() -> TestResult {
881        for (fragment, character) in [
882            ("with space", "whitespace"),
883            ("with\ttab", "whitespace"),
884            ("with\nnewline", "whitespace"),
885            ("with{brace", "`{`"),
886            ("with'single", "a single quote"),
887            ("with\"double", "a double quote"),
888            ("with\0nul", "a NUL byte"),
889        ] {
890            let root = PathBuf::from(format!("/absolute/{fragment}"));
891            let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
892            let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
893            let Err(error) = workspace.expand(&command) else {
894                return Err(format!("root {root:?} must be refused as shape-changing").into());
895            };
896            assert_eq!(
897                error,
898                WorkspaceRootError::ShapeChanging {
899                    path: root.to_string_lossy().into_owned(),
900                    character,
901                },
902                "root {root:?} must be refused naming {character}"
903            );
904        }
905        Ok(())
906    }
907
908    #[test]
909    fn a_clean_root_has_no_shape_changing_character() {
910        assert_eq!(
911            shape_changing_character("/Users/operator/.aion/clones"),
912            None
913        );
914    }
915
916    #[test]
917    fn the_directory_is_created_when_missing() -> TestResult {
918        let scratch = tempfile::tempdir()?;
919        let root = scratch.path().join("nested").join("clones");
920        assert!(!root.exists(), "the root must start absent");
921        let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
922        let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
923        let first = workspace.expand(&command)?;
924        assert!(first.is_some(), "expansion must succeed");
925        assert!(root.is_dir(), "expansion must create the missing root");
926        // A created root is owner-only: it holds session workspaces, so
927        // nothing else on the host gets a default read into it.
928        #[cfg(unix)]
929        {
930            use std::os::unix::fs::PermissionsExt as _;
931            let mode = std::fs::metadata(&root)?.permissions().mode() & 0o777;
932            assert_eq!(
933                mode, 0o700,
934                "a created root must be mode 0700, got {mode:o}"
935            );
936        }
937        // Idempotent: a second expansion over the now-existing directory
938        // succeeds identically.
939        assert_eq!(workspace.expand(&command)?, first);
940        Ok(())
941    }
942
943    #[test]
944    fn a_root_that_cannot_be_created_is_refused_naming_the_io_error() -> TestResult {
945        let scratch = tempfile::tempdir()?;
946        // A ROOT beneath a regular file cannot be created by any retry.
947        let file = scratch.path().join("occupied");
948        std::fs::write(&file, b"not a directory")?;
949        let root = file.join("clones");
950        let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
951        let command = format!("echo {WORKSPACE_ROOT_PLACEHOLDER}");
952        let Err(error) = workspace.expand(&command) else {
953            return Err("creation beneath a regular file must fail".into());
954        };
955        let WorkspaceRootError::CreationFailed { path, error: io } = &error else {
956            return Err(format!("expected CreationFailed, got: {error}").into());
957        };
958        assert_eq!(path, &root.to_string_lossy().into_owned());
959        assert!(!io.is_empty(), "the io error's own words must be carried");
960        Ok(())
961    }
962
963    #[test]
964    fn resolved_reports_without_creating() -> TestResult {
965        let scratch = tempfile::tempdir()?;
966        let root = scratch.path().join("clones");
967        let workspace = WorkspaceRoot::from_resolution(Ok(root.clone()));
968        assert_eq!(workspace.resolved(), Ok(root.as_path()));
969        assert!(
970            !root.exists(),
971            "reporting the root must not create the directory"
972        );
973        let failed = unresolved();
974        let Err(error) = failed.resolved() else {
975            return Err("an unresolved root must report its failure".into());
976        };
977        assert!(matches!(error, WorkspaceRootError::Unresolvable { .. }));
978        Ok(())
979    }
980}