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;