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}