Skip to main content

aion_server/assistant/sessions/
launch.rs

1//! Composing the ACP harness one session runs on.
2//!
3//! Everything a spawn needs is decided here, once, from the CATALOGUE entry the
4//! operator picked plus the session's own identity — so no other module builds a
5//! `Command`, and the facts that are easy to get wrong are in one place with
6//! their reasons beside them.
7//!
8//! # Nothing here is configuration any more
9//!
10//! The launch line is the catalogue's (`aion_integration_acp::catalogue`), which
11//! is what makes a fresh install usable: no command, no arguments, no working
12//! directory, no timeouts. What is left are three product decisions, each stated
13//! out loud below rather than defaulted quietly — the environment the child
14//! gets, where it is rooted, and how its permission requests are answered.
15//!
16//! # The child's environment is CONSTRUCTED
17//!
18//! `env_clear()` then exactly [`AGENT_ENVIRONMENT`] resolved out of the server's
19//! own environment, plus the account's declared variables. A variable nobody
20//! named cannot reach the agent whatever this server carries — the rule that
21//! exists because one agent's own shell variable once reached a nested
22//! `claude-code-acp` and made it refuse every `session/new`.
23//!
24//! An account names its variables on BOTH sides (the name the child gets, the
25//! name it is read from), so no value is ever written into a config file. A
26//! source the server does not carry is a TYPED ABSENCE here — never an empty
27//! string handed to an agent that would then look logged out for a reason
28//! nobody could see.
29//!
30//! # `cwd` is NOT a boundary
31//!
32//! An ACP agent's tools are not confined by the directory it was started in, and
33//! the catalogue's launch lines carry no confinement flag of their own (they are
34//! each agent's documented ACP invocation, verbatim, and `--workspace-root` is
35//! not a flag `npx @agentclientprotocol/claude-agent-acp` accepts). What
36//! confines the agent is the CONTAINMENT — it runs in its own process group and
37//! the session takes the group with it — and what roots it is the server's own
38//! working directory, which is the one directory an operator has already chosen
39//! by starting the server there. Nothing here asks which directory the repo is
40//! in; the console tells the agent through `assistant_context`.
41//!
42//! # Values never reach a log line
43//!
44//! The declaration is NAMES; the resolved values are placed on the `Command` and
45//! never rendered. Nothing here logs an environment value, and nothing puts the
46//! session bearer anywhere but into the MCP server specification.
47//! `launch_tests.rs` pins both.
48
49use std::path::PathBuf;
50use std::time::Duration;
51
52use aion_core::AssistantSessionId;
53use aion_integration_acp::catalogue::CatalogueHarness;
54use aion_integration_acp::{AcpHarness, McpServerSpec, PermissionPolicy};
55use aion_integrations::{EnvironmentDeclaration, HarnessWorkspace};
56
57use crate::config::ResolvedAssistantAccount;
58
59use super::error::AssistantSessionError;
60use super::token::{self, MintedSessionToken};
61
62/// The environment variable NAMES every assistant agent is launched with.
63///
64/// A fixed, stated set rather than an operator knob, because the round-2
65/// amendment retired the knob and because every name here is needed for the
66/// catalogue's own launch lines to work at all:
67///
68/// - `PATH` — `npx` and `opencode` are looked up on it; without it nothing execs.
69/// - `HOME` — where the harness keeps its OWN login state (`~/.claude`,
70///   `~/.codex`) and where `npx` caches the adapter it fetches. This is the
71///   variable that makes "log the harness in on the server host" true.
72/// - `TMPDIR` — `npx` unpacks into it; on macOS it is per-user and absent from a
73///   cleared environment.
74/// - `SHELL`, `USER`, `LOGNAME` — an agent that runs a tool runs it through a
75///   shell, and the tools it runs (git, most of all) identify the user by these.
76/// - `LANG`, `LC_ALL` — text encoding; an agent reading source under the C
77///   locale mangles anything that is not ASCII.
78/// - `TERM` — some agents refuse to start with no terminal type at all.
79/// - `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_CACHE_HOME`, `XDG_STATE_HOME` —
80///   where a harness keeps its login state on Linux when the operator moved it.
81/// - `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase spellings — on
82///   a corporate network an agent with no proxy cannot reach its own API, and
83///   the failure surfaces as an inscrutable timeout.
84/// - `SSL_CERT_FILE`, `SSL_CERT_DIR`, `NODE_EXTRA_CA_CERTS` — the same story for
85///   a private certificate authority.
86///
87/// A name that is not here can still reach a particular agent: an account
88/// declares it, which is what the `[assistant]` section is now FOR.
89const AGENT_ENVIRONMENT: &[&str] = &[
90    "PATH",
91    "HOME",
92    "TMPDIR",
93    "SHELL",
94    "USER",
95    "LOGNAME",
96    "LANG",
97    "LC_ALL",
98    "TERM",
99    "XDG_CONFIG_HOME",
100    "XDG_DATA_HOME",
101    "XDG_CACHE_HOME",
102    "XDG_STATE_HOME",
103    "HTTP_PROXY",
104    "HTTPS_PROXY",
105    "NO_PROXY",
106    "http_proxy",
107    "https_proxy",
108    "no_proxy",
109    "SSL_CERT_FILE",
110    "SSL_CERT_DIR",
111    "NODE_EXTRA_CA_CERTS",
112];
113
114/// How long the agent's exit is awaited after its stdin closes and its process
115/// group has been asked to stop, before the group is killed.
116///
117/// NOT an operator knob and not a policy: it is the distribution's ONE
118/// termination grace, imported by reference from the process-group containment
119/// every declared body already runs under, so the assistant's agent and a
120/// worker-owned command are stopped on the same ladder with the same window.
121/// The round-2 amendment retired `exit_grace` as configuration; what is left is
122/// the mechanism's own bound, and a second copy of that number is what would
123/// make the two drift.
124const TERMINATION_GRACE: Duration = aion_worker::PROCESS_GROUP_TERMINATION_GRACE;
125
126/// How an assistant agent's permission requests are answered.
127///
128/// `allow-once` — approve one-shot requests, never a durable "remember this"
129/// grant — and the reasoning is worth stating, because it is the one product
130/// decision here that is not forced.
131///
132/// The operator summoned this agent from their own console, in their own
133/// session, on their own machine, and every request and decision is recorded on
134/// the transcript they are watching. `deny` would make a stock server's
135/// assistant unable to read a file or run a check — the whole of what it is for
136/// — and, with the knob retired, would leave the operator nothing to change. A
137/// durable grant would outlive the conversation that gave it. So: one-shot,
138/// recorded, and bounded by the process group the session takes with it.
139///
140/// An in-console permission prompt is a stated non-goal of this cut (contract
141/// §3); this is the policy that stands in its place, and the surface says so.
142const PERMISSION: PermissionPolicy = PermissionPolicy::AllowOnce;
143
144/// Where a spawned agent dials this server back.
145///
146/// TWO routes, one address. The general `/mcp` catalogue is the workflow tool
147/// surface and is dark unless `[mcp] enabled`; `/assistant/mcp` is the
148/// assistant's OWN catalogue and is served whenever this server can state an
149/// address at all, because it is how a session's agent learns what is on the
150/// operator's screen — the answer to "never ask which directory the repo is in".
151///
152/// `None` for the whole struct means this server cannot state a dialable address
153/// (a configured port of zero, whose real port is only known after bind), and
154/// then no MCP server of ours is handed over at all.
155#[derive(Clone, Debug)]
156pub struct AssistantEndpoints {
157    /// The dialable origin, `http://host:port`, with no trailing slash.
158    pub base: String,
159    /// Whether the general `/mcp` route is mounted on it.
160    pub aion_mcp_enabled: bool,
161}
162
163impl AssistantEndpoints {
164    /// This server's general MCP route, when it is mounted.
165    #[must_use]
166    pub fn aion_mcp_url(&self) -> Option<String> {
167        self.aion_mcp_enabled
168            .then(|| format!("{}{}", self.base, crate::mcp::MCP_PATH))
169    }
170
171    /// This server's ASSISTANT-ONLY MCP route.
172    ///
173    /// Always present: its catalogue is one tool about this session, and it is
174    /// authorized by the session's own bearer rather than by anything the
175    /// general surface's `[mcp] enabled` switch governs.
176    #[must_use]
177    pub fn assistant_mcp_url(&self) -> String {
178        format!("{}{}", self.base, crate::assistant::mcp::ASSISTANT_MCP_PATH)
179    }
180}
181
182/// Everything one spawn needs.
183pub(crate) struct HarnessPlan {
184    /// The configured harness, ready to start.
185    pub(crate) harness: AcpHarness,
186    /// The session bearer minted for it — `None` when the session was given no
187    /// MCP server of ours and so needs no identity of its own.
188    pub(crate) token: Option<MintedSessionToken>,
189}
190
191/// Build the harness one session runs on.
192///
193/// # Errors
194///
195/// [`AssistantSessionError::HarnessUnavailable`] when the catalogue entry's
196/// program does not resolve on this server's `PATH` — re-measured HERE, at the
197/// spawn, because availability is a fact about this machine now and not about
198/// the machine as it was when the session was created.
199/// [`AssistantSessionError::AccountEnvironmentAbsent`] when an account names a
200/// server variable this server does not carry.
201/// [`AssistantSessionError::Internal`] when the server has no working directory
202/// to root the agent in, or the declaration cannot be built.
203pub(crate) fn plan(
204    session_id: AssistantSessionId,
205    harness: &'static CatalogueHarness,
206    account: Option<&ResolvedAssistantAccount>,
207    endpoints: Option<&AssistantEndpoints>,
208) -> Result<HarnessPlan, AssistantSessionError> {
209    if !harness.available() {
210        return Err(AssistantSessionError::HarnessUnavailable {
211            harness: harness.id.to_owned(),
212            launch: harness.launch(),
213            install_hint: harness.install_hint.to_owned(),
214        });
215    }
216    let (environment, account_variables) = resolve_environment(harness, account)?;
217    let workspace = server_workspace()?;
218    let (mcp_servers, token) = mcp_servers(session_id, endpoints);
219
220    let mut built = AcpHarness::new(
221        harness.program,
222        harness.args.iter().copied(),
223        HarnessWorkspace::Fixed(workspace),
224        PERMISSION,
225        TERMINATION_GRACE,
226    )
227    .with_declared_environment(environment)
228    // The agent is a LAUNCHER (`npx` execs node, node runs the adapter, the
229    // adapter runs tools), so ending this session has to take the group and not
230    // the child. This is the assistant's own containment: the server owns the
231    // process directly, and nothing outside it would stop the tree.
232    .with_process_group_containment()
233    .with_mcp_servers(mcp_servers);
234
235    // The account's variables go on LAST, over the stated set, because that is
236    // what an account IS: the one or two variables that point a harness at a
237    // different login on disk. They are values by this point, and they are
238    // placed on the command rather than logged.
239    for (child, value) in account_variables {
240        built = built.with_env(child, value);
241    }
242
243    Ok(HarnessPlan {
244        harness: built,
245        token,
246    })
247}
248
249/// The child's declared environment, and the account's variables resolved into
250/// the names the child is given.
251///
252/// Two passes through the SAME [`EnvironmentDeclaration`] discipline, because
253/// they answer two different questions. The stated set is an allow-list: a
254/// server with no `TERM` is a server with no `TERM`, and the child simply does
255/// not get one. An ACCOUNT's names are a REQUIREMENT: the operator declared them
256/// to select a login, so a source this server does not carry is a typed absence
257/// naming the variable — never an empty string, which would present to the
258/// operator as a harness that is mysteriously logged out.
259///
260/// The account's value is then carried under the name the account said, which is
261/// what lets two accounts select two different login directories through the one
262/// variable a harness actually reads.
263fn resolve_environment(
264    harness: &CatalogueHarness,
265    account: Option<&ResolvedAssistantAccount>,
266) -> Result<(aion_integrations::ChildEnvironment, Vec<(String, String)>), AssistantSessionError> {
267    let declaration =
268        EnvironmentDeclaration::new(AGENT_ENVIRONMENT.iter().copied()).map_err(|error| {
269            AssistantSessionError::Internal(format!(
270                "the assistant's own environment declaration cannot be built ({error}); it is a \
271                 constant in this module, so reaching this is a server defect"
272            ))
273        })?;
274    let environment = declaration.resolve_from_process();
275    let Some(account) = account else {
276        return Ok((environment, Vec::new()));
277    };
278    let sources = EnvironmentDeclaration::new(account.source_names())
279        .map_err(|error| {
280            AssistantSessionError::Internal(format!(
281                "the account `{}` on harness `{}` declares an environment the launcher cannot \
282                 build ({error}); the names are checked at config load, so reaching this is a \
283                 server defect",
284                account.name, harness.id
285            ))
286        })?
287        .resolve_from_process();
288    if !sources.absent().is_empty() {
289        let variables = account
290            .env
291            .iter()
292            .filter(|(_child, source)| sources.absent().iter().any(|name| name == source))
293            .map(|(child, source)| {
294                format!("`{source}` (which the agent would receive as `{child}`)")
295            })
296            .collect::<Vec<_>>()
297            .join(", ");
298        return Err(AssistantSessionError::AccountEnvironmentAbsent {
299            harness: harness.id.to_owned(),
300            account: account.name.clone(),
301            variables,
302        });
303    }
304    let carried = account
305        .env
306        .iter()
307        .filter_map(|(child, source)| {
308            sources
309                .pairs()
310                .iter()
311                .find(|(name, _value)| name == source)
312                .map(|(_name, value)| (child.clone(), value.clone()))
313        })
314        .collect();
315    Ok((environment, carried))
316}
317
318/// The directory an assistant agent is rooted at: the server's own.
319///
320/// The one directory an operator has already chosen — they started the server
321/// there — and the only one available without asking a question the amendment
322/// forbids asking. It is not a boundary (see the module docs) and is not
323/// pretended to be one.
324fn server_workspace() -> Result<PathBuf, AssistantSessionError> {
325    std::env::current_dir().map_err(|error| {
326        AssistantSessionError::Internal(format!(
327            "this server cannot read its own working directory ({error}), so it cannot say where \
328             an assistant agent would run; ACP requires an absolute session root and this server \
329             will not invent one"
330        ))
331    })
332}
333
334/// The MCP servers this session's agent is told about, and the bearer that
335/// identifies it to both of this server's own routes.
336///
337/// TWO specs of ours, never one merged catalogue: the general `/mcp` tools are
338/// the workflow surface any authorized caller reaches, and `assistant_context`
339/// is about THIS conversation and belongs to nobody else. Publishing it in the
340/// general catalogue would make one session's screen a tool every caller could
341/// see listed; giving it its own route with its own catalogue and the session
342/// bearer as the only credential is what keeps the two apart by construction
343/// rather than by a filter somebody has to remember to apply.
344///
345/// ONE bearer covers both, because both are this server and the identity being
346/// asserted — "I am session X's agent" — is the same on each.
347///
348/// The general route is handed over exactly when it is MOUNTED. It used to take
349/// an `[assistant.tools] aion` switch as well; with that knob retired, `[mcp]
350/// enabled` is the one place an operator says whether this server publishes
351/// workflow tools at all, and a second switch saying it again is a second thing
352/// to keep in step.
353fn mcp_servers(
354    session_id: AssistantSessionId,
355    endpoints: Option<&AssistantEndpoints>,
356) -> (Vec<McpServerSpec>, Option<MintedSessionToken>) {
357    let mut servers = Vec::new();
358    let Some(endpoints) = endpoints else {
359        // Sessions still run; the agent simply cannot ask what is on screen.
360        // Said out loud rather than left as a tool that silently is not there.
361        tracing::warn!(
362            "this server can state no dialable address, so assistant sessions are opened without \
363             the `assistant_context` tool; the agent will not be able to read what is on the \
364             operator's screen"
365        );
366        return (servers, None);
367    };
368    let minted = MintedSessionToken::mint();
369    let headers = session_headers(session_id, &minted);
370    if let Some(url) = endpoints.aion_mcp_url() {
371        servers.push(McpServerSpec::Http {
372            name: AION_MCP_SERVER_NAME.to_owned(),
373            url,
374            headers: headers.clone(),
375        });
376    }
377    servers.push(McpServerSpec::Http {
378        name: ASSISTANT_MCP_SERVER_NAME.to_owned(),
379        url: endpoints.assistant_mcp_url(),
380        headers,
381    });
382    (servers, Some(minted))
383}
384
385/// The headers that say WHICH session is calling, and prove it.
386fn session_headers(
387    session_id: AssistantSessionId,
388    minted: &MintedSessionToken,
389) -> Vec<(String, String)> {
390    vec![
391        (
392            AUTHORIZATION_HEADER.to_owned(),
393            token::authorization_value(minted.secret()),
394        ),
395        (
396            token::SESSION_ID_HEADER.to_owned(),
397            token::session_header_value(session_id),
398        ),
399    ]
400}
401
402/// The name the agent shows for this server's own workflow tools.
403pub(crate) const AION_MCP_SERVER_NAME: &str = "aion";
404
405/// The name the agent shows for the assistant's own tools.
406pub(crate) const ASSISTANT_MCP_SERVER_NAME: &str = "assistant";
407
408/// The header the session bearer rides on.
409const AUTHORIZATION_HEADER: &str = "authorization";
410
411#[cfg(test)]
412#[path = "launch_tests.rs"]
413mod tests;