aion-server 0.29.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! The validating resolution of the `[assistant]` section.
//!
//! Resolution IS the validation: [`AssistantConfig::resolve_checked`] settles
//! every declared account and refuses, by key and with a stable message PREFIX,
//! anything it cannot settle. [`AssistantConfig::validate`] runs that pass and
//! throws the value away, so a section that validates is a section that can be
//! resolved and the two can never disagree — the `[worker_supervision]` pattern.
//!
//! Every refusal here is built as `format!("{PREFIX}: …details…")` from a
//! constant in the config `defaults` module, so a gate selects one refusal by
//! its prefix while the details stay free to name the offending harness,
//! account or variable.
//!
//! # What this pass no longer does
//!
//! It settles no timeouts, no buffer sizes, no commands, no working directories
//! and no permission policy, because the round-2 amendment retired all of them:
//! the launch is the catalogue's and the rest is not a decision an operator was
//! ever able to make usefully. A file that still names one of those keys is
//! refused by serde's `deny_unknown_fields` before this pass runs, naming the
//! key — the one refusal that must survive a knob's retirement, so that a value
//! nothing reads cannot sit in a file looking as though it were in force.

use aion_integration_acp::catalogue;

use crate::error::ServerError;

use super::super::{
    config_error,
    defaults::{
        ASSISTANT_ACCOUNT_ENV_CREDENTIAL_SHAPED, ASSISTANT_ACCOUNT_ENV_NAME_INVALID,
        ASSISTANT_ACCOUNT_NAME_DUPLICATE, ASSISTANT_ACCOUNT_NAME_REQUIRED,
        ASSISTANT_HARNESS_NAME_DUPLICATE, ASSISTANT_HARNESS_NAME_REQUIRED,
        ASSISTANT_HARNESS_NAME_UNKNOWN, CREDENTIAL_SHAPED_ENV_NAME_FRAGMENTS,
    },
};
use super::{
    AssistantAccountConfig, AssistantConfig, AssistantHarnessConfig, ResolvedAssistantAccount,
    ResolvedAssistantConfig, ResolvedAssistantHarness,
};

impl AssistantConfig {
    /// Refuse every incoherent `[assistant]` section at LOAD.
    ///
    /// Resolution is the validation: this runs [`Self::resolve_checked`] and
    /// discards the value.
    ///
    /// # Errors
    ///
    /// [`ServerError::Config`] naming the offending key, with a stable message
    /// prefix drawn from the config `defaults` module.
    pub(in crate::config) fn validate(&self) -> Result<(), ServerError> {
        self.resolve_checked().map(drop)
    }

    /// The section with every declared account settled.
    ///
    /// A section that cannot be described resolves to the STOCK form — no
    /// accounts — rather than to a dark one, because there is no longer anything
    /// here that could switch the surface off: the assistant is served either
    /// way, and what an unresolvable section loses is the accounts it tried to
    /// declare. [`Self::validate`] has already refused every such section on
    /// every loaded configuration, so this arm is reachable only from a
    /// hand-constructed config that skipped validation.
    #[must_use]
    pub fn resolved(&self) -> ResolvedAssistantConfig {
        self.resolve_checked().unwrap_or_default()
    }

    /// Validate and resolve in one pass.
    fn resolve_checked(&self) -> Result<ResolvedAssistantConfig, ServerError> {
        let mut harnesses: Vec<ResolvedAssistantHarness> = Vec::with_capacity(self.harnesses.len());
        for harness in &self.harnesses {
            let resolved = resolve_harness(harness)?;
            if harnesses.iter().any(|seen| seen.name == resolved.name) {
                return config_error(format!(
                    "{ASSISTANT_HARNESS_NAME_DUPLICATE}: two [[assistant.harness]] entries are \
                     both named `{}`; accounts are looked up by harness name, so a repeated name \
                     makes the lookup ambiguous — put every account for one harness in a single \
                     entry",
                    resolved.name
                ));
            }
            harnesses.push(resolved);
        }
        Ok(ResolvedAssistantConfig { harnesses })
    }
}

/// Validate and resolve one `[[assistant.harness]]` entry.
fn resolve_harness(
    harness: &AssistantHarnessConfig,
) -> Result<ResolvedAssistantHarness, ServerError> {
    let name = required_name(
        harness.name.as_deref(),
        ASSISTANT_HARNESS_NAME_REQUIRED,
        "assistant.harness.name",
    )?;
    if catalogue::harness(&name).is_none() {
        return config_error(format!(
            "{ASSISTANT_HARNESS_NAME_UNKNOWN}: `{name}` is not a harness this build ships, so \
             nothing could ever be started on it. This build ships: {}. The launch command is the \
             catalogue's own — there is no command, path or argument to declare here, only the \
             accounts an operator may pick from.",
            catalogue::ids()
        ));
    }
    let mut accounts: Vec<ResolvedAssistantAccount> = Vec::with_capacity(harness.accounts.len());
    for account in &harness.accounts {
        let resolved = resolve_account(account, &name)?;
        if accounts.iter().any(|seen| seen.name == resolved.name) {
            return config_error(format!(
                "{ASSISTANT_ACCOUNT_NAME_DUPLICATE}: harness `{name}` declares two accounts both \
                 named `{}`; an account is selected by name, so a repeated name makes the \
                 selection ambiguous",
                resolved.name
            ));
        }
        accounts.push(resolved);
    }
    Ok(ResolvedAssistantHarness { name, accounts })
}

/// A required, non-empty name.
fn required_name(name: Option<&str>, prefix: &str, key: &str) -> Result<String, ServerError> {
    match name {
        Some(name) if !name.is_empty() => Ok(name.to_owned()),
        Some(_) => config_error(format!("{prefix}: {key} is empty")),
        None => config_error(format!("{prefix}: {key} is required and has no default")),
    }
}

/// Validate and resolve one `[[assistant.harness.account]]` entry.
fn resolve_account(
    account: &AssistantAccountConfig,
    harness: &str,
) -> Result<ResolvedAssistantAccount, ServerError> {
    let name = required_name(
        account.name.as_deref(),
        ASSISTANT_ACCOUNT_NAME_REQUIRED,
        &format!("assistant.harness.account.name in harness `{harness}`"),
    )?;
    for (child, source) in &account.env {
        // BOTH sides are variable names, and both are checked. The right-hand
        // side used to be a VALUE; a file written against that shape names a
        // path or a token there, and it is refused here by name rather than
        // silently read as a variable nobody set — which is how a value written
        // by an operator would become an absent one.
        validate_variable_name(child, &name, harness, "the name given to the agent")?;
        validate_variable_name(
            source,
            &name,
            harness,
            "the name it is read from in the server's own environment",
        )?;
        let lowered = child.to_ascii_lowercase();
        if let Some(fragment) = CREDENTIAL_SHAPED_ENV_NAME_FRAGMENTS
            .iter()
            .find(|fragment| lowered.contains(*fragment))
        {
            return config_error(format!(
                "{ASSISTANT_ACCOUNT_ENV_CREDENTIAL_SHAPED}: account `{name}` in harness \
                 `{harness}` declares `{child}`, whose name contains `{fragment}`. A credential is \
                 the harness's OWN login state on disk and is never ours to carry: log in out of \
                 band on the server host under this account's config directory. This env table \
                 says WHICH on-disk login state to use, nothing more"
            ));
        }
    }
    Ok(ResolvedAssistantAccount {
        name,
        env: account
            .env
            .iter()
            .map(|(child, source)| (child.clone(), source.clone()))
            .collect(),
    })
}

/// Refuse anything that is not a usable environment variable name.
///
/// A process environment carries `NAME=VALUE` pairs, so a name may not be empty
/// and may not contain `=`; the remaining characters are refused because a
/// variable name that a shell cannot spell is, in practice, somebody's value or
/// path written where a name belongs.
fn validate_variable_name(
    variable: &str,
    account: &str,
    harness: &str,
    side: &str,
) -> Result<(), ServerError> {
    let usable = !variable.is_empty()
        && !variable.starts_with(|first: char| first.is_ascii_digit())
        && variable
            .chars()
            .all(|character| character.is_ascii_alphanumeric() || character == '_');
    if usable {
        return Ok(());
    }
    config_error(format!(
        "{ASSISTANT_ACCOUNT_ENV_NAME_INVALID}: account `{account}` in harness `{harness}` declares \
         `{variable}` as {side}, which is not an environment variable name (letters, digits and \
         underscores, not starting with a digit). Both sides of this table are NAMES — the value \
         is taken from the server's own environment at spawn, so a path or a secret written here \
         would be a value in a file that gets committed"
    ))
}