Skip to main content

aion_server/config/
assistant_resolve.rs

1//! The validating resolution of the `[assistant]` section.
2//!
3//! Resolution IS the validation: [`AssistantConfig::resolve_checked`] settles
4//! every declared account and refuses, by key and with a stable message PREFIX,
5//! anything it cannot settle. [`AssistantConfig::validate`] runs that pass and
6//! throws the value away, so a section that validates is a section that can be
7//! resolved and the two can never disagree — the `[worker_supervision]` pattern.
8//!
9//! Every refusal here is built as `format!("{PREFIX}: …details…")` from a
10//! constant in the config `defaults` module, so a gate selects one refusal by
11//! its prefix while the details stay free to name the offending harness,
12//! account or variable.
13//!
14//! # What this pass no longer does
15//!
16//! It settles no timeouts, no buffer sizes, no commands, no working directories
17//! and no permission policy, because the round-2 amendment retired all of them:
18//! the launch is the catalogue's and the rest is not a decision an operator was
19//! ever able to make usefully. A file that still names one of those keys is
20//! refused by serde's `deny_unknown_fields` before this pass runs, naming the
21//! key — the one refusal that must survive a knob's retirement, so that a value
22//! nothing reads cannot sit in a file looking as though it were in force.
23
24use aion_integration_acp::catalogue;
25
26use crate::error::ServerError;
27
28use super::super::{
29    config_error,
30    defaults::{
31        ASSISTANT_ACCOUNT_ENV_CREDENTIAL_SHAPED, ASSISTANT_ACCOUNT_ENV_NAME_INVALID,
32        ASSISTANT_ACCOUNT_NAME_DUPLICATE, ASSISTANT_ACCOUNT_NAME_REQUIRED,
33        ASSISTANT_HARNESS_NAME_DUPLICATE, ASSISTANT_HARNESS_NAME_REQUIRED,
34        ASSISTANT_HARNESS_NAME_UNKNOWN, CREDENTIAL_SHAPED_ENV_NAME_FRAGMENTS,
35    },
36};
37use super::{
38    AssistantAccountConfig, AssistantConfig, AssistantHarnessConfig, ResolvedAssistantAccount,
39    ResolvedAssistantConfig, ResolvedAssistantHarness,
40};
41
42impl AssistantConfig {
43    /// Refuse every incoherent `[assistant]` section at LOAD.
44    ///
45    /// Resolution is the validation: this runs [`Self::resolve_checked`] and
46    /// discards the value.
47    ///
48    /// # Errors
49    ///
50    /// [`ServerError::Config`] naming the offending key, with a stable message
51    /// prefix drawn from the config `defaults` module.
52    pub(in crate::config) fn validate(&self) -> Result<(), ServerError> {
53        self.resolve_checked().map(drop)
54    }
55
56    /// The section with every declared account settled.
57    ///
58    /// A section that cannot be described resolves to the STOCK form — no
59    /// accounts — rather than to a dark one, because there is no longer anything
60    /// here that could switch the surface off: the assistant is served either
61    /// way, and what an unresolvable section loses is the accounts it tried to
62    /// declare. [`Self::validate`] has already refused every such section on
63    /// every loaded configuration, so this arm is reachable only from a
64    /// hand-constructed config that skipped validation.
65    #[must_use]
66    pub fn resolved(&self) -> ResolvedAssistantConfig {
67        self.resolve_checked().unwrap_or_default()
68    }
69
70    /// Validate and resolve in one pass.
71    fn resolve_checked(&self) -> Result<ResolvedAssistantConfig, ServerError> {
72        let mut harnesses: Vec<ResolvedAssistantHarness> = Vec::with_capacity(self.harnesses.len());
73        for harness in &self.harnesses {
74            let resolved = resolve_harness(harness)?;
75            if harnesses.iter().any(|seen| seen.name == resolved.name) {
76                return config_error(format!(
77                    "{ASSISTANT_HARNESS_NAME_DUPLICATE}: two [[assistant.harness]] entries are \
78                     both named `{}`; accounts are looked up by harness name, so a repeated name \
79                     makes the lookup ambiguous — put every account for one harness in a single \
80                     entry",
81                    resolved.name
82                ));
83            }
84            harnesses.push(resolved);
85        }
86        Ok(ResolvedAssistantConfig { harnesses })
87    }
88}
89
90/// Validate and resolve one `[[assistant.harness]]` entry.
91fn resolve_harness(
92    harness: &AssistantHarnessConfig,
93) -> Result<ResolvedAssistantHarness, ServerError> {
94    let name = required_name(
95        harness.name.as_deref(),
96        ASSISTANT_HARNESS_NAME_REQUIRED,
97        "assistant.harness.name",
98    )?;
99    if catalogue::harness(&name).is_none() {
100        return config_error(format!(
101            "{ASSISTANT_HARNESS_NAME_UNKNOWN}: `{name}` is not a harness this build ships, so \
102             nothing could ever be started on it. This build ships: {}. The launch command is the \
103             catalogue's own — there is no command, path or argument to declare here, only the \
104             accounts an operator may pick from.",
105            catalogue::ids()
106        ));
107    }
108    let mut accounts: Vec<ResolvedAssistantAccount> = Vec::with_capacity(harness.accounts.len());
109    for account in &harness.accounts {
110        let resolved = resolve_account(account, &name)?;
111        if accounts.iter().any(|seen| seen.name == resolved.name) {
112            return config_error(format!(
113                "{ASSISTANT_ACCOUNT_NAME_DUPLICATE}: harness `{name}` declares two accounts both \
114                 named `{}`; an account is selected by name, so a repeated name makes the \
115                 selection ambiguous",
116                resolved.name
117            ));
118        }
119        accounts.push(resolved);
120    }
121    Ok(ResolvedAssistantHarness { name, accounts })
122}
123
124/// A required, non-empty name.
125fn required_name(name: Option<&str>, prefix: &str, key: &str) -> Result<String, ServerError> {
126    match name {
127        Some(name) if !name.is_empty() => Ok(name.to_owned()),
128        Some(_) => config_error(format!("{prefix}: {key} is empty")),
129        None => config_error(format!("{prefix}: {key} is required and has no default")),
130    }
131}
132
133/// Validate and resolve one `[[assistant.harness.account]]` entry.
134fn resolve_account(
135    account: &AssistantAccountConfig,
136    harness: &str,
137) -> Result<ResolvedAssistantAccount, ServerError> {
138    let name = required_name(
139        account.name.as_deref(),
140        ASSISTANT_ACCOUNT_NAME_REQUIRED,
141        &format!("assistant.harness.account.name in harness `{harness}`"),
142    )?;
143    for (child, source) in &account.env {
144        // BOTH sides are variable names, and both are checked. The right-hand
145        // side used to be a VALUE; a file written against that shape names a
146        // path or a token there, and it is refused here by name rather than
147        // silently read as a variable nobody set — which is how a value written
148        // by an operator would become an absent one.
149        validate_variable_name(child, &name, harness, "the name given to the agent")?;
150        validate_variable_name(
151            source,
152            &name,
153            harness,
154            "the name it is read from in the server's own environment",
155        )?;
156        let lowered = child.to_ascii_lowercase();
157        if let Some(fragment) = CREDENTIAL_SHAPED_ENV_NAME_FRAGMENTS
158            .iter()
159            .find(|fragment| lowered.contains(*fragment))
160        {
161            return config_error(format!(
162                "{ASSISTANT_ACCOUNT_ENV_CREDENTIAL_SHAPED}: account `{name}` in harness \
163                 `{harness}` declares `{child}`, whose name contains `{fragment}`. A credential is \
164                 the harness's OWN login state on disk and is never ours to carry: log in out of \
165                 band on the server host under this account's config directory. This env table \
166                 says WHICH on-disk login state to use, nothing more"
167            ));
168        }
169    }
170    Ok(ResolvedAssistantAccount {
171        name,
172        env: account
173            .env
174            .iter()
175            .map(|(child, source)| (child.clone(), source.clone()))
176            .collect(),
177    })
178}
179
180/// Refuse anything that is not a usable environment variable name.
181///
182/// A process environment carries `NAME=VALUE` pairs, so a name may not be empty
183/// and may not contain `=`; the remaining characters are refused because a
184/// variable name that a shell cannot spell is, in practice, somebody's value or
185/// path written where a name belongs.
186fn validate_variable_name(
187    variable: &str,
188    account: &str,
189    harness: &str,
190    side: &str,
191) -> Result<(), ServerError> {
192    let usable = !variable.is_empty()
193        && !variable.starts_with(|first: char| first.is_ascii_digit())
194        && variable
195            .chars()
196            .all(|character| character.is_ascii_alphanumeric() || character == '_');
197    if usable {
198        return Ok(());
199    }
200    config_error(format!(
201        "{ASSISTANT_ACCOUNT_ENV_NAME_INVALID}: account `{account}` in harness `{harness}` declares \
202         `{variable}` as {side}, which is not an environment variable name (letters, digits and \
203         underscores, not starting with a digit). Both sides of this table are NAMES — the value \
204         is taken from the server's own environment at spawn, so a path or a secret written here \
205         would be a value in a file that gets committed"
206    ))
207}