aion-server 0.31.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! What the server says about the assistant it carries.
//!
//! The description is DERIVED on every read, and it is a description of a
//! SESSION surface: which harnesses this build ships and whether this machine
//! can run each one, what tools a session's agent is handed, whether sessions
//! can be opened at all, and what the reading caller is authorized to do.
//! Nothing is remembered from boot — the harness availability is measured while
//! the description is built, and the grant rows are read off this request's own
//! identity — so a description cannot go stale behind an operator installing a
//! harness or a grant changing between restarts.
//!
//! There is no workflow here. The assistant is a server-owned session
//! (`super::sessions`), not a document the engine runs, so this carries no
//! workflow type, no task queue, no content hash and no catalog residency.

use aion_integration_acp::catalogue;
use serde::Serialize;

use super::sessions::{AssistantSessionError, AssistantSessions};
use crate::namespace::CallerIdentity;
use crate::namespace::grants::GRANT_WORDS;

/// One harness this server can open an assistant session on.
///
/// The list IS the catalogue this build ships
/// ([`aion_integration_acp::catalogue`]) — an operator never types a command —
/// and each entry carries the two facts a picker needs beside the name: whether
/// this machine can actually run it, and what to install when it cannot.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AssistantHarnessDescriptor {
    /// The catalogue id, which a create request selects by.
    pub name: String,
    /// The adapter that runs it.
    pub kind: String,
    /// The declared login account names, in declaration order. Empty when the
    /// harness declares none, which is a complete answer: a session on it then
    /// names no account.
    pub accounts: Vec<String>,
    /// Whether [`Self::launch`]'s program resolves on THIS server's `PATH`,
    /// measured while this description was being built.
    ///
    /// Never cached: an operator who installs Node.js and reloads the console
    /// must see the entry come alive without restarting the server, and a server
    /// that remembered a boot-time reading would be reporting a machine as it
    /// was rather than as it is.
    pub available: bool,
    /// The catalogue's own sentence naming what to install, or `None` when the
    /// harness is available. Present exactly when [`Self::available`] is false,
    /// so a client renders a hint or nothing and never both.
    pub install_hint: Option<String>,
    /// The exact line this server would run for it. DISPLAY only: it is shown
    /// so an operator can see what a session starts, and it is not something any
    /// request may set.
    pub launch: String,
}

/// The tool wiring every session on this server is handed.
///
/// NAMES only, never a command line or a URL: what tools an agent is given is
/// something an operator must be able to see from the console, and how they are
/// reached is a spawn detail that would leak a local path or an internal
/// endpoint to every reader of this description.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AssistantToolsDescriptor {
    /// Whether this server's own general MCP endpoint is handed to the harness
    /// — the aion tools an agent drives workflows with.
    pub aion: bool,
    /// The assistant's OWN tool server: the second MCP endpoint every session's
    /// agent is handed, separate from the general one.
    pub assistant: AssistantOwnToolsDescriptor,
}

/// The assistant-only MCP server handed to every session's agent.
///
/// Published so an operator can see, without reading the source, that a session
/// hands its agent a tool for reading the console screen — what it is called,
/// where it is served, and what credential it takes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AssistantOwnToolsDescriptor {
    /// The name the agent shows for it.
    pub server: String,
    /// The route it is served on.
    pub route: String,
    /// Every tool in its catalogue. Exactly one, and it is not in the general
    /// catalogue — a general caller asking for it is refused by name.
    pub tools: Vec<String>,
    /// Whether it is actually handed over on this server. `false` when no
    /// dialable address can be stated (a configured port of zero).
    pub handed_over: bool,
    /// Why it is not handed over, or `None` when it is.
    pub unavailable_reason: Option<String>,
    /// The credential it accepts.
    pub token: AssistantSessionTokenDescriptor,
}

/// The credential the assistant tool route accepts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AssistantSessionTokenDescriptor {
    /// The wire word for the kind, for a client that branches on it.
    pub kind: String,
    /// Who mints it: this server, never a caller.
    pub minted_by: String,
    /// What it authorizes: one session, never a namespace or a person.
    pub scope: String,
    /// The one sentence describing it, from
    /// [`crate::assistant::mcp::SESSION_TOKEN_DESCRIPTION`] — the SAME sentence
    /// `docs/operations/API.md` quotes, so there is one description of one
    /// credential rather than two that could drift.
    pub description: String,
}

impl AssistantSessionTokenDescriptor {
    /// The description of the credential this binary mints.
    #[must_use]
    pub fn current() -> Self {
        Self {
            kind: crate::assistant::mcp::SESSION_TOKEN_KIND.to_owned(),
            minted_by: TOKEN_MINTED_BY.to_owned(),
            scope: TOKEN_SCOPE.to_owned(),
            description: crate::assistant::mcp::SESSION_TOKEN_DESCRIPTION.to_owned(),
        }
    }
}

/// Who mints the session bearer. The server, always: a caller cannot present
/// one it made, because verification is against a digest only the server wrote.
const TOKEN_MINTED_BY: &str = "server";
/// What the session bearer authorizes.
const TOKEN_SCOPE: &str = "session";
/// Why the assistant tool server is not handed over when it is not.
const NO_DIALABLE_ADDRESS: &str = "this server cannot state an address an agent could dial back on (`server.listen_address` \
     names port 0, whose real port is only known after bind), so no MCP server of ours is handed \
     to a session's agent and it cannot read what is on the operator's screen";

/// One grant word this deployment defines, and whether the caller reading this
/// description holds it.
///
/// Built by walking [`GRANT_WORDS`], never a hand-written list: a word that
/// existed in the grammar and not here would be grantable and undiscoverable.
/// The `description` is the grammar row's own sentence, so the console states
/// what a word authorises rather than inventing a meaning for it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AssistantGrantDescriptor {
    /// The stable word an operator and an audit line spell.
    pub name: String,
    /// Whether this caller holds it.
    pub held: bool,
    /// One sentence naming what the word authorises, from the grammar row.
    pub description: String,
}

/// The adapter kind every resolved harness runs.
///
/// [`crate::config::ResolvedAssistantHarness`] carries no kind because there is
/// nothing per-harness left to remember: `config/assistant_resolve.rs` refuses
/// every value but this one at load, so a RESOLVED harness is an ACP harness by
/// construction. It is published anyway — a client must not have to know that
/// rule to read this description — and pinned against the resolver itself by
/// `the_published_harness_kind_is_the_one_resolution_accepts` in
/// `api/http/assistant_sessions_tests.rs`, so the two cannot drift.
const HARNESS_KIND: &str = "acp";

/// The name the agent shows for the assistant's own tool server.
///
/// Pinned against `launch.rs`'s own constant by
/// `the_published_assistant_server_name_is_the_one_a_spawn_hands_over`, so the
/// description and the spawn cannot name two different servers.
const ASSISTANT_TOOL_SERVER_NAME: &str = "assistant";

/// The served description of the built-in assistant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AssistantDescriptor {
    /// Every harness this server can open an assistant session on — the
    /// build's catalogue, in catalogue order, each with its availability on this
    /// machine.
    pub harnesses: Vec<AssistantHarnessDescriptor>,
    /// The harness THIS caller last opened a session on, or `None` before they
    /// have opened one.
    ///
    /// A memory, not a policy: it is written by `createSession` from what the
    /// operator actually did, and it is caller-scoped, so one operator's habit
    /// never preselects another's console. `None` is a complete answer — a
    /// client preselects the first available entry rather than the server
    /// inventing a choice nobody made.
    pub default_harness: Option<String>,
    /// The tool wiring every session on this server is handed.
    pub tools: AssistantToolsDescriptor,
    /// Whether this server can open an assistant session at all.
    pub sessions_enabled: bool,
    /// Why it cannot, in words an operator can act on, or `None` when it can.
    ///
    /// Reserved for a refusal the PRODUCT can name — today, a durable store this
    /// server could not read at start-up. It is never "not configured": a stock
    /// server with no `[assistant]` section serves the assistant, and a harness
    /// this machine cannot run is said per entry, with its install hint, rather
    /// than as one sentence about the whole surface.
    pub sessions_disabled_reason: Option<String>,
    /// Every grant word this deployment defines, and whether the caller reading
    /// this description holds it.
    pub grants: Vec<AssistantGrantDescriptor>,
}

/// Describes this build's harness catalogue against `sessions`' configuration
/// and availability, and what `caller` is authorized to do.
///
/// Everything is DERIVED at request time and nothing is remembered from boot —
/// including the grant rows, which are read off this request's own resolved
/// identity, so one description can never report another caller's
/// authorization.
///
/// # Errors
///
/// Whatever the store reports while reading this caller's last harness pick.
/// The pick is a stored fact about the caller, so it is read rather than
/// remembered, and a store that cannot answer is reported rather than being
/// rendered as "no pick" — which is a different, and wrong, thing to tell a
/// console.
pub async fn describe(
    sessions: &AssistantSessions,
    caller: &CallerIdentity,
) -> Result<AssistantDescriptor, AssistantSessionError> {
    let config = sessions.config();
    let availability = sessions.availability();
    let default_harness = sessions.last_harness_pick(caller.subject()).await?;
    Ok(AssistantDescriptor {
        harnesses: catalogue::CATALOGUE
            .iter()
            .map(|entry| AssistantHarnessDescriptor {
                name: entry.id.to_owned(),
                kind: HARNESS_KIND.to_owned(),
                accounts: config.account_names(entry.id),
                // MEASURED here, on every read. The hint is carried exactly when
                // it is needed, so a client cannot render "install Node.js"
                // beside a harness that is already running.
                available: entry.available(),
                install_hint: (!entry.available()).then(|| entry.install_hint.to_owned()),
                launch: entry.launch(),
            })
            .collect(),
        default_harness,
        tools: AssistantToolsDescriptor {
            aion: sessions.hands_over_general_mcp(),
            assistant: AssistantOwnToolsDescriptor {
                server: ASSISTANT_TOOL_SERVER_NAME.to_owned(),
                route: crate::assistant::mcp::ASSISTANT_MCP_PATH.to_owned(),
                tools: crate::assistant::mcp::SESSION_TOOL_NAMES
                    .iter()
                    .map(|name| (*name).to_owned())
                    .collect(),
                handed_over: sessions.hands_over_assistant_tools(),
                unavailable_reason: (!sessions.hands_over_assistant_tools())
                    .then(|| NO_DIALABLE_ADDRESS.to_owned()),
                token: AssistantSessionTokenDescriptor::current(),
            },
        },
        sessions_enabled: availability.is_available(),
        sessions_disabled_reason: availability.reason().map(ToOwned::to_owned),
        grants: GRANT_WORDS
            .iter()
            .map(|grant| AssistantGrantDescriptor {
                name: grant.word().to_owned(),
                held: grant.granted_for(caller),
                description: grant.description().to_owned(),
            })
            .collect(),
    })
}