Skip to main content

aion_server/config/
assistant.rs

1//! The `[assistant]` section: optional, and about ACCOUNTS only.
2//!
3//! # The section is optional, and a stock server serves the assistant
4//!
5//! RULED 2026-08-29 (Tom, "it needs to work out of the box"): a first install
6//! runs the assistant with no configuration, no environment variables and no
7//! second process. The harness catalogue ships as DATA in
8//! [`aion_integration_acp::catalogue`] — id, launch line, install hint — so
9//! there is no command, no path, no working directory and no timeout for an
10//! operator to type, and nothing to get wrong before the first message.
11//!
12//! What is left in this section is the one thing the product genuinely cannot
13//! know: which named LOGIN ACCOUNTS a deployment wants offered on a harness, and
14//! which environment variables select each one. Everything else that used to
15//! live here — `default_harness`, `spawn_timeout_ms`, `turn_timeout_ms`,
16//! `event_buffer`, and every per-harness `command` / `args` / `cwd` /
17//! `env_pass` / `permission` / `exit_grace_ms` / `tool_confinement` — is
18//! RETIRED, not defaulted: see the round-2 amendment. A file that still carries
19//! one is refused at load naming the key, because a knob that is silently
20//! ignored is worse than one that is gone.
21//!
22//! # Accounts carry NAMES on both sides
23//!
24//! An account's `env` maps the variable name the CHILD gets to the variable name
25//! it is READ FROM in the server's own environment:
26//!
27//! ```toml
28//! [[assistant.harness]]
29//! name = "claude-code"
30//!
31//! [[assistant.harness.account]]
32//! name = "work"
33//! env = { CLAUDE_CONFIG_DIR = "AION_CLAUDE_WORK_DIR" }
34//! ```
35//!
36//! Both sides are names. No value is ever written into this file: a value in a
37//! document gets committed, diffed and deployed, and the value here would be
38//! pointing at somebody's login state. The value comes from the server's own
39//! environment at spawn, and a declared source name the server does not carry is
40//! a TYPED ABSENCE at spawn rather than an empty string handed to an agent.
41//!
42//! A credential-shaped variable NAME is still refused at load. A credential is
43//! the harness's own login state on disk, never ours to carry: the operator logs
44//! the harness in on the server host, under that account's config directory, and
45//! this server neither prompts for nor stores one.
46
47use std::collections::BTreeMap;
48
49use serde::Deserialize;
50
51/// The validating resolution: every `[assistant]` refusal, and the pass that
52/// turns the wire shapes below into the resolved ones.
53#[path = "assistant_resolve.rs"]
54mod resolve;
55
56/// The `[assistant]` section — entirely optional.
57///
58/// [`Default`] is the empty section, which is what a server with no
59/// `[assistant]` at all gets, and it is a COMPLETE configuration: every
60/// catalogue harness is offered, none of them declares an account, and sessions
61/// are served. Absence is not darkness here.
62///
63/// `deny_unknown_fields` is what refuses a retired knob by name: a file carrying
64/// `turn_timeout_ms` is told that no such key exists rather than being loaded
65/// with a value nothing reads.
66#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
67#[serde(default, deny_unknown_fields)]
68pub struct AssistantConfig {
69    /// Per-harness account declarations, from `[[assistant.harness]]`. Empty is
70    /// a complete answer: every harness the catalogue ships is still offered,
71    /// with no accounts on it.
72    #[serde(rename = "harness")]
73    pub harnesses: Vec<AssistantHarnessConfig>,
74}
75
76/// The accounts declared on ONE catalogue harness, from
77/// `[[assistant.harness]]`.
78///
79/// It declares no launch of its own: `name` selects a harness the build already
80/// ships, and everything else about how that harness is started is the
81/// catalogue's.
82#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
83#[serde(default, deny_unknown_fields)]
84pub struct AssistantHarnessConfig {
85    /// The catalogue id these accounts belong to (`claude-code`, `codex`, …).
86    /// REQUIRED, non-empty, and refused when the build ships no such harness.
87    pub name: Option<String>,
88    /// Named login accounts for it, from `[[assistant.harness.account]]`.
89    /// Empty is a complete answer.
90    #[serde(rename = "account")]
91    pub accounts: Vec<AssistantAccountConfig>,
92}
93
94/// One named login account, from `[[assistant.harness.account]]`.
95#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
96#[serde(default, deny_unknown_fields)]
97pub struct AssistantAccountConfig {
98    /// The account's name, unique within its harness. REQUIRED, non-empty.
99    pub name: Option<String>,
100    /// `CHILD_VARIABLE = "SERVER_VARIABLE"`: which variable the spawned agent
101    /// gets, and which variable of the server's own environment its value is
102    /// read from. NAMES on both sides — never a value — and a
103    /// credential-shaped child name is refused at load. Empty is a complete
104    /// answer.
105    pub env: BTreeMap<String, String>,
106}
107
108/// The `[assistant]` section, validated.
109///
110/// [`Default`] is the stock server: no accounts declared anywhere. It is not a
111/// dark form and there is no `enabled` flag — whether a session can be opened is
112/// a question about the STORE and about which catalogue harness the operator
113/// picked, both answered where those facts are, not by a config field.
114#[derive(Clone, Debug, Default, PartialEq, Eq)]
115pub struct ResolvedAssistantConfig {
116    /// The harnesses that declare accounts, in declaration order. A catalogue
117    /// harness absent from this list is still offered; it simply has none.
118    pub harnesses: Vec<ResolvedAssistantHarness>,
119}
120
121impl ResolvedAssistantConfig {
122    /// The accounts declared for catalogue harness `name`, or [`None`] when it
123    /// declares none.
124    #[must_use]
125    pub fn harness(&self, name: &str) -> Option<&ResolvedAssistantHarness> {
126        self.harnesses.iter().find(|harness| harness.name == name)
127    }
128
129    /// The account names declared for `harness`, in declaration order — what
130    /// the descriptor publishes. Empty when none is declared, which is the
131    /// stock answer.
132    #[must_use]
133    pub fn account_names(&self, harness: &str) -> Vec<String> {
134        self.harness(harness).map_or_else(Vec::new, |harness| {
135            harness
136                .accounts
137                .iter()
138                .map(|account| account.name.clone())
139                .collect()
140        })
141    }
142
143    /// The account `account_name` declared on `harness`, or [`None`].
144    #[must_use]
145    pub fn account(&self, harness: &str, account_name: &str) -> Option<&ResolvedAssistantAccount> {
146        self.harness(harness)?.account(account_name)
147    }
148}
149
150/// One catalogue harness's declared accounts, validated.
151#[derive(Clone, Debug, PartialEq, Eq)]
152pub struct ResolvedAssistantHarness {
153    /// The catalogue id.
154    pub name: String,
155    /// Its named login accounts, in declaration order.
156    pub accounts: Vec<ResolvedAssistantAccount>,
157}
158
159impl ResolvedAssistantHarness {
160    /// The account declared under `name` on this harness, or [`None`].
161    #[must_use]
162    pub fn account(&self, name: &str) -> Option<&ResolvedAssistantAccount> {
163        self.accounts.iter().find(|account| account.name == name)
164    }
165}
166
167/// One named login account, validated.
168#[derive(Clone, Debug, PartialEq, Eq)]
169pub struct ResolvedAssistantAccount {
170    /// The account's name, unique within its harness.
171    pub name: String,
172    /// `(child variable, server variable it is read from)`, in sorted child-name
173    /// order. NAMES on both sides: the values are resolved from the server's
174    /// environment at spawn, and a source the server does not carry is a typed
175    /// absence there rather than an empty string here.
176    pub env: Vec<(String, String)>,
177}
178
179impl ResolvedAssistantAccount {
180    /// The server-environment variable names this account reads, in the order
181    /// its pairs are declared.
182    ///
183    /// What the spawn's environment declaration is extended with, so the
184    /// existing `EnvironmentDeclaration` discipline — names resolved against the
185    /// process, absences reported — covers an account's variables exactly as it
186    /// covers the harness's own.
187    #[must_use]
188    pub fn source_names(&self) -> Vec<String> {
189        self.env
190            .iter()
191            .map(|(_child, source)| source.clone())
192            .collect()
193    }
194}
195
196#[cfg(test)]
197#[path = "assistant_tests.rs"]
198mod tests;