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}