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 `[assistant]` section, taken through the real config door.
//!
//! Each document here is parsed by [`ServerConfig::from_slice_with_home_in`] —
//! the same entry an embedded caller and the shipped-config sweep use — so the
//! cells exercise the file door (serde shape, `deny_unknown_fields` and
//! `validate`) rather than a hand-built struct that could stay green while the
//! TOML surface drifted.
//!
//! Two kinds of cell:
//!
//! - **the stock server** — no section at all, and the assistant is served. This
//!   is the round-2 amendment's first pin, and it is asserted at the resolved
//!   configuration rather than at a descriptor, because a config that resolved
//!   "dark" would take the surface down before any descriptor was built.
//! - **refusals** — the load FAILS, the message carries the stable PREFIX a gate
//!   selects it by, and the details name the offending key, harness, account or
//!   variable. The retired knobs are refused here too: a file that still carries
//!   `turn_timeout_ms` must be told the key is gone, never loaded with a value
//!   nothing reads.

use std::path::Path;

use tempfile::TempDir;

use crate::{
    config::{
        ServerConfig,
        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,
        },
    },
    error::ServerError,
};

type TestResult = Result<(), Box<dyn std::error::Error>>;

/// Every key the round-2 amendment retired, with the section it used to sit in.
///
/// The list is the report's own table in executable form: each entry is written
/// into a document and must be refused BY NAME. A knob that came back — or that
/// was quietly tolerated — fails here.
const RETIRED_KEYS: &[(&str, &str)] = &[
    (
        "default_harness",
        "[assistant]\ndefault_harness = \"claude-code\"\n",
    ),
    (
        "spawn_timeout_ms",
        "[assistant]\nspawn_timeout_ms = 30000\n",
    ),
    ("turn_timeout_ms", "[assistant]\nturn_timeout_ms = 600000\n"),
    ("event_buffer", "[assistant]\nevent_buffer = 256\n"),
    (
        "kind",
        "[[assistant.harness]]\nname = \"claude-code\"\nkind = \"acp\"\n",
    ),
    (
        "command",
        "[[assistant.harness]]\nname = \"claude-code\"\ncommand = \"/opt/acp/claude-code-acp\"\n",
    ),
    (
        "args",
        "[[assistant.harness]]\nname = \"claude-code\"\nargs = [\"--verbose\"]\n",
    ),
    (
        "cwd",
        "[[assistant.harness]]\nname = \"claude-code\"\ncwd = \"/srv/work\"\n",
    ),
    (
        "env_pass",
        "[[assistant.harness]]\nname = \"claude-code\"\nenv_pass = [\"PATH\"]\n",
    ),
    (
        "permission",
        "[[assistant.harness]]\nname = \"claude-code\"\npermission = \"deny\"\n",
    ),
    (
        "exit_grace_ms",
        "[[assistant.harness]]\nname = \"claude-code\"\nexit_grace_ms = 10000\n",
    ),
    (
        "tool_confinement",
        "[[assistant.harness]]\nname = \"claude-code\"\ntool_confinement = \"none\"\n",
    ),
    // The whole `[assistant.tools]` table is retired. TOML refuses an unknown
    // table AT ITS OWN NAME and never reads the keys inside it, so the name
    // the operator is refused by is `tools` — asserting the inner key here
    // would demand a message the deserializer cannot produce.
    ("tools", "[assistant.tools]\naion = true\n"),
    (
        "tools",
        "[[assistant.tools.mcp_server]]\nname = \"meridian\"\ntransport = \"http\"\nurl = \"http://127.0.0.1:9\"\n",
    ),
];

/// A private scratch directory that doubles as the Aion home.
fn sandbox() -> std::io::Result<TempDir> {
    crate::test_support::private_tempdir()
}

/// Load a document exactly as an embedded caller would.
fn load(document: &str, scratch: &Path) -> Result<ServerConfig, ServerError> {
    ServerConfig::from_slice_with_home_in(document.as_bytes(), scratch, scratch)
}

/// Load a document that must be refused, and hand back the operator-facing
/// message so a cell can assert the prefix and the named key.
fn refusal(document: &str, scratch: &Path) -> Result<String, Box<dyn std::error::Error>> {
    match load(document, scratch) {
        Ok(_) => Err("expected the configuration to be refused, but it loaded".into()),
        Err(ServerError::Config { message }) => Ok(message),
        Err(other) => Err(format!("expected a configuration refusal, got: {other}").into()),
    }
}

/// The message must carry the gate-selectable prefix and name the offender.
fn assert_refusal(message: &str, prefix: &str, names: &str) {
    assert!(
        message.starts_with(prefix),
        "refusal `{message}` does not carry the stable prefix `{prefix}`"
    );
    assert!(
        message.contains(names),
        "refusal `{message}` does not name `{names}`"
    );
}

#[test]
fn a_stock_server_with_no_assistant_section_still_serves_the_assistant() -> TestResult {
    let scratch = sandbox()?;
    let config = load("[namespaces]\ndefault = \"default\"\n", scratch.path())?;
    assert!(
        config.assistant.is_none(),
        "an absent section parsed as present"
    );
    let (_store, runtime) = config.into_parts();
    // No accounts, and that is the WHOLE of what an absent section means now.
    // There is no `enabled` to be false: the harnesses come from the catalogue,
    // and whether a session can be opened is answered by the store and by the
    // harness the operator picks, not by this struct.
    assert!(
        runtime.assistant.harnesses.is_empty(),
        "a stock server declares no accounts"
    );
    assert!(
        runtime.assistant.account_names("claude-code").is_empty(),
        "a catalogue harness with no declared accounts offers none, which is a complete answer"
    );
    Ok(())
}

#[test]
fn an_empty_assistant_section_is_a_complete_configuration() -> TestResult {
    let scratch = sandbox()?;
    let config = load("[assistant]\n", scratch.path())?;
    assert!(
        config.assistant.is_some(),
        "the section was written, so it parsed"
    );
    let (_store, runtime) = config.into_parts();
    assert!(runtime.assistant.harnesses.is_empty());
    Ok(())
}

#[test]
fn every_retired_knob_is_refused_by_name() -> TestResult {
    let scratch = sandbox()?;
    for (key, document) in RETIRED_KEYS {
        let message = refusal(document, scratch.path())?;
        assert!(
            message.contains(key),
            "a document carrying the retired key `{key}` must be refused NAMING it, so an \
             operator learns the knob is gone rather than believing a value nothing reads is in \
             force; got: {message}"
        );
    }
    Ok(())
}

#[test]
fn the_accounts_a_harness_declares_are_resolved_in_order_with_names_on_both_sides() -> TestResult {
    let scratch = sandbox()?;
    let document = r#"
[[assistant.harness]]
name = "claude-code"

[[assistant.harness.account]]
name = "work"
env = { CLAUDE_CONFIG_DIR = "AION_CLAUDE_WORK_DIR" }

[[assistant.harness.account]]
name = "personal"
env = { CLAUDE_CONFIG_DIR = "AION_CLAUDE_PERSONAL_DIR" }
"#;
    let (_store, runtime) = load(document, scratch.path())?.into_parts();
    assert_eq!(
        runtime.assistant.account_names("claude-code"),
        vec!["work".to_owned(), "personal".to_owned()],
        "accounts are published in declaration order — the order the console offers them in"
    );
    let account = runtime
        .assistant
        .account("claude-code", "work")
        .ok_or("the declared account must resolve")?;
    assert_eq!(
        account.env,
        vec![(
            "CLAUDE_CONFIG_DIR".to_owned(),
            "AION_CLAUDE_WORK_DIR".to_owned()
        )],
        "the pair is (the name the child gets, the name it is read from)"
    );
    assert_eq!(
        account.source_names(),
        vec!["AION_CLAUDE_WORK_DIR".to_owned()],
        "the source names are what the spawn's environment declaration is extended with"
    );
    Ok(())
}

#[test]
fn a_harness_name_the_build_does_not_ship_is_refused_naming_the_catalogue() -> TestResult {
    let scratch = sandbox()?;
    let message = refusal("[[assistant.harness]]\nname = \"claude\"\n", scratch.path())?;
    assert_refusal(&message, ASSISTANT_HARNESS_NAME_UNKNOWN, "claude");
    assert!(
        message.contains("claude-code"),
        "the refusal must list what this build DOES ship, or an operator has to guess the id; \
         got: {message}"
    );
    Ok(())
}

#[test]
fn a_harness_entry_with_no_name_is_refused() -> TestResult {
    let scratch = sandbox()?;
    let message = refusal("[[assistant.harness]]\n", scratch.path())?;
    assert_refusal(
        &message,
        ASSISTANT_HARNESS_NAME_REQUIRED,
        "assistant.harness.name",
    );
    Ok(())
}

#[test]
fn two_harness_entries_with_one_name_are_refused() -> TestResult {
    let scratch = sandbox()?;
    let message = refusal(
        "[[assistant.harness]]\nname = \"codex\"\n\n[[assistant.harness]]\nname = \"codex\"\n",
        scratch.path(),
    )?;
    assert_refusal(&message, ASSISTANT_HARNESS_NAME_DUPLICATE, "codex");
    Ok(())
}

#[test]
fn an_account_with_no_name_is_refused_naming_its_harness() -> TestResult {
    let scratch = sandbox()?;
    let message = refusal(
        "[[assistant.harness]]\nname = \"codex\"\n\n[[assistant.harness.account]]\n",
        scratch.path(),
    )?;
    assert_refusal(&message, ASSISTANT_ACCOUNT_NAME_REQUIRED, "codex");
    Ok(())
}

#[test]
fn two_accounts_with_one_name_in_a_harness_are_refused() -> TestResult {
    let scratch = sandbox()?;
    let message = refusal(
        r#"
[[assistant.harness]]
name = "codex"

[[assistant.harness.account]]
name = "work"

[[assistant.harness.account]]
name = "work"
"#,
        scratch.path(),
    )?;
    assert_refusal(&message, ASSISTANT_ACCOUNT_NAME_DUPLICATE, "work");
    Ok(())
}

#[test]
fn a_credential_shaped_account_env_name_refuses_under_the_pinned_prefix() -> TestResult {
    let scratch = sandbox()?;
    let message = refusal(
        r#"
[[assistant.harness]]
name = "claude-code"

[[assistant.harness.account]]
name = "work"
env = { ANTHROPIC_API_KEY = "AION_ANTHROPIC_KEY_SOURCE" }
"#,
        scratch.path(),
    )?;
    // The prefix is asserted LITERALLY, not through the constant alone: a gate
    // selects this refusal by these exact words, so a rename has to be a
    // deliberate act that fails here first.
    assert!(
        message
            .starts_with("assistant.harness.account.env refuses a credential-shaped variable name"),
        "the credential refusal's stable prefix changed: {message}"
    );
    assert_refusal(
        &message,
        ASSISTANT_ACCOUNT_ENV_CREDENTIAL_SHAPED,
        "ANTHROPIC_API_KEY",
    );
    Ok(())
}

#[test]
fn a_value_written_where_a_source_name_belongs_is_refused_rather_than_read_as_a_variable()
-> TestResult {
    // The shape a round-1 file carries: the right-hand side used to be the
    // VALUE. Read as a variable name it would be a name nothing sets, so the
    // account would silently carry nothing; refused by name, the operator is
    // told what the table means now.
    let scratch = sandbox()?;
    let message = refusal(
        r#"
[[assistant.harness]]
name = "claude-code"

[[assistant.harness.account]]
name = "work"
env = { CLAUDE_CONFIG_DIR = "/srv/aion/claude-work" }
"#,
        scratch.path(),
    )?;
    assert_refusal(
        &message,
        ASSISTANT_ACCOUNT_ENV_NAME_INVALID,
        "/srv/aion/claude-work",
    );
    Ok(())
}

#[test]
fn an_account_env_name_the_child_could_not_carry_is_refused() -> TestResult {
    let scratch = sandbox()?;
    let message = refusal(
        r#"
[[assistant.harness]]
name = "claude-code"

[[assistant.harness.account]]
name = "work"
env = { "1CONFIG" = "AION_SOURCE" }
"#,
        scratch.path(),
    )?;
    assert_refusal(&message, ASSISTANT_ACCOUNT_ENV_NAME_INVALID, "1CONFIG");
    Ok(())
}