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