Skip to main content

liminal_cli/agent/
config.rs

1//! The host reads the same seat fields without claiming ownership of the ticket parser.
2
3use crate::{Config, Credential, Limits};
4use serde::Deserialize;
5use std::path::{Path, PathBuf};
6
7/// A launch declaration and its positive resource bounds. No field grants permission.
8#[derive(Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct Harness {
11    /// One catalogue id; cdx uses channel wake instead of this host.
12    pub id: String,
13    /// The executable, resolved at launch without searching the current directory.
14    pub program: PathBuf,
15    /// Arguments passed without a shell. They never enter diagnostics.
16    pub args: Vec<String>,
17    /// Session root; absent inherits the directory selected by the Lys launch profile.
18    pub cwd: Option<PathBuf>,
19    /// A prior session explicitly requested for resume, never silently replaced.
20    pub session: Option<String>,
21    /// Unread prompts; 32 is one seat's unread mail, not a durable mailbox.
22    #[serde(default = "prompt_queue")]
23    pub prompt_queue: usize,
24    /// Text bytes per stream item and prompt; 64 KiB covers one model turn, not a file transfer.
25    #[serde(default = "content_bytes")]
26    pub content_bytes: usize,
27    /// ACP frame bytes; 256 KiB admits tool metadata without permitting an unbounded pipe allocation.
28    #[serde(default = "frame_bytes")]
29    pub acp_frame_bytes: usize,
30    /// Held permissions; 1024 admits the 729-request pipe contract while bounding approval memory.
31    #[serde(default = "permissions")]
32    pub pending_permissions: usize,
33    /// Pipe responses awaiting write; 2048 covers a permission burst without blocking stdout reads.
34    #[serde(default = "writes")]
35    pub write_queue: usize,
36    /// Frames awaiting dispatch; one prevents raw tool frames from becoming a second transcript.
37    #[serde(default = "read_queue")]
38    pub read_queue: usize,
39    /// Stream publications awaiting the network; 128 limits memory when a watcher is slow.
40    #[serde(default = "streams")]
41    pub stream_queue: usize,
42    /// Issuer response bytes; 64 KiB admits structured refusals without retaining arbitrary bodies.
43    #[serde(default = "content_bytes")]
44    pub issuer_response_bytes: usize,
45    /// Conversations one history listing names, newest first; 50 fills the panel's list
46    /// while one stream item stays within `content_bytes`.
47    #[serde(default = "listed_sessions")]
48    pub listed_sessions: usize,
49    /// Characters kept of each listed title; 120 is one line of the panel's list.
50    #[serde(default = "title_chars")]
51    pub title_chars: usize,
52    /// Variables set on the harness process (AGT-007 R4); values never enter
53    /// a diagnostic.
54    #[serde(default)]
55    pub env: std::collections::BTreeMap<String, String>,
56    /// Variables removed from the harness process (AGT-007 R4).
57    #[serde(default)]
58    pub env_remove: Vec<String>,
59}
60
61/// An environment variable's name as a shell writes it.
62fn is_env_name(name: &str) -> bool {
63    (1..=128).contains(&name.len())
64        && !name.starts_with(|c: char| c.is_ascii_digit())
65        && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
66}
67
68const fn prompt_queue() -> usize {
69    32
70}
71const fn content_bytes() -> usize {
72    65_536
73}
74const fn frame_bytes() -> usize {
75    262_144
76}
77const fn permissions() -> usize {
78    1024
79}
80const fn writes() -> usize {
81    2048
82}
83const fn streams() -> usize {
84    128
85}
86const fn read_queue() -> usize {
87    1
88}
89const fn listed_sessions() -> usize {
90    50
91}
92const fn title_chars() -> usize {
93    120
94}
95
96impl Harness {
97    /// Checks catalogue membership, paths, and all positive limits before spawning.
98    ///
99    /// # Errors
100    /// Returns a refusal naming the invalid field, without its value.
101    pub fn validate(&self) -> anyhow::Result<()> {
102        super::catalogue::validate(&self.id)?;
103        anyhow::ensure!(
104            !self.program.as_os_str().is_empty(),
105            "harness.program is empty"
106        );
107        anyhow::ensure!(
108            self.cwd.as_ref().is_none_or(|cwd| cwd.is_absolute()),
109            "harness.cwd must be absolute"
110        );
111        anyhow::ensure!(
112            self.session
113                .as_ref()
114                .is_none_or(|id| !id.trim().is_empty() && id.len() <= self.content_bytes),
115            "harness.session is empty or exceeds content_bytes"
116        );
117        for (name, bound) in [
118            ("prompt_queue", self.prompt_queue),
119            ("content_bytes", self.content_bytes),
120            ("acp_frame_bytes", self.acp_frame_bytes),
121            ("pending_permissions", self.pending_permissions),
122            ("write_queue", self.write_queue),
123            ("read_queue", self.read_queue),
124            ("stream_queue", self.stream_queue),
125            ("issuer_response_bytes", self.issuer_response_bytes),
126            ("listed_sessions", self.listed_sessions),
127            ("title_chars", self.title_chars),
128        ] {
129            anyhow::ensure!(bound > 0, "harness.{name} must be positive");
130        }
131        // AGT-007 R4: names are checked and named; a value never is.
132        for name in self.env.keys().chain(&self.env_remove) {
133            anyhow::ensure!(
134                is_env_name(name),
135                "harness.env names {name:?}: a name is 1 to 128 of A-Z, a-z, 0-9 and '_', not starting with a digit"
136            );
137        }
138        if let Some(both) = self
139            .env_remove
140            .iter()
141            .find(|name| self.env.contains_key(*name))
142        {
143            anyhow::bail!("harness.env and harness.env_remove both name {both}");
144        }
145        Ok(())
146    }
147}
148
149/// Validate the optional host table at the ticket parser's boundary.
150///
151/// # Errors
152/// Returns a named configuration refusal without the table's bytes.
153pub fn validate_optional(
154    table: Option<&toml::Table>,
155    path: &Path,
156) -> Result<(), crate::ConfigError> {
157    let Some(table) = table else {
158        return Ok(());
159    };
160    let harness: Harness =
161        toml::Value::Table(table.clone())
162            .try_into()
163            .map_err(|_| crate::ConfigError::Invalid {
164                path: path.to_path_buf(),
165                message: "[harness] table refused".to_owned(),
166            })?;
167    harness
168        .validate()
169        .map_err(|error| crate::ConfigError::Invalid {
170            path: path.to_path_buf(),
171            message: error.to_string(),
172        })
173}
174
175pub(super) struct Seat {
176    pub path: PathBuf,
177    pub ticket: Config,
178    pub harness: Harness,
179    pub name: String,
180    pub cwd: PathBuf,
181}
182
183#[derive(Deserialize)]
184struct Written {
185    server: String,
186    channel: String,
187    lys: Option<crate::lys::LysTable>,
188    open: Option<crate::OpenTable>,
189    limits: Limits,
190    agent: Option<String>,
191    harness: Harness,
192}
193
194impl Seat {
195    pub fn load(path: &Path) -> anyhow::Result<Self> {
196        let path = std::fs::canonicalize(path)
197            .map_err(|error| anyhow::anyhow!("seat file cannot be opened: {}", error.kind()))?;
198        let text = zeroize::Zeroizing::new(
199            std::fs::read_to_string(&path)
200                .map_err(|error| anyhow::anyhow!("seat file cannot be read: {}", error.kind()))?,
201        );
202        let table: toml::Table =
203            toml::from_str(&text).map_err(|_| anyhow::anyhow!("seat file is not valid TOML"))?;
204        for field in table.keys() {
205            anyhow::ensure!(
206                [
207                    "server", "channel", "lys", "open", "limits", "agent", "listen", "harness"
208                ]
209                .contains(&field.as_str()),
210                "seat file has an unknown field"
211            );
212        }
213        let file: Written = toml::from_str(&text).map_err(|_| {
214            anyhow::anyhow!(
215                "seat requires server, channel, [lys] or [open], [limits] and [harness]"
216            )
217        })?;
218        file.harness.validate()?;
219        let (name, credential) = match (file.lys, file.open) {
220            (Some(lys), None) => (lys_name(&lys)?, Credential::Lys(lys)),
221            (None, Some(open)) => open_name(&file.server, open)?,
222            _ => anyhow::bail!("seat requires exactly one of [lys] and [open]"),
223        };
224        let table_agent = match &credential {
225            Credential::Lys(lys) => lys.agent.clone(),
226            _ => String::new(),
227        };
228        anyhow::ensure!(
229            !name.is_empty()
230                && name
231                    .bytes()
232                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
233                && name.len() <= file.harness.content_bytes,
234            "the seat's agent name (LYS_AGENT, lys.agent or open.name) must be one segment of a-z, 0-9, '-' or '_' within content_bytes"
235        );
236        anyhow::ensure!(
237            file.agent.as_ref().is_none_or(|agent| agent == &name)
238                && (table_agent.is_empty() || table_agent == name),
239            "seat agent differs from LYS_AGENT"
240        );
241        validate_listen(table.get("listen"), &name, file.harness.content_bytes)?;
242        anyhow::ensure!(
243            !file.server.is_empty() && !file.channel.is_empty(),
244            "seat server and channel must be nonempty"
245        );
246        let limits = file.limits;
247        anyhow::ensure!(
248            limits.frame_bytes > 0
249                && limits.record_bytes > 0
250                && limits.reply_timeout_ms > 0
251                && limits.flow_steps > 0
252                && limits.request_bytes > 0,
253            "every ticket limit must be positive"
254        );
255        let cwd = file.harness.cwd.clone().map_or_else(
256            || {
257                std::env::current_dir()
258                    .map_err(|_| anyhow::anyhow!("Lys launch directory cannot be read"))
259            },
260            Ok,
261        )?;
262        Ok(Self {
263            path,
264            name: name.clone(),
265            cwd,
266            harness: file.harness,
267            ticket: Config {
268                server: file.server,
269                channel: file.channel,
270                credential,
271                limits,
272                agent: Some(name),
273                listen: crate::mcp::listen_config::ListenConfig::default(),
274            },
275        })
276    }
277}
278
279/// A Lys-launched seat names itself by `LYS_AGENT` and proves itself with its
280/// run pass; a seat holding its own certified key signs as `lys.agent`.
281fn lys_name(lys: &crate::lys::LysTable) -> anyhow::Result<String> {
282    super::exit::validate_issuer(&lys.issuer)?;
283    if lys.key_file.as_os_str().is_empty() {
284        std::env::var("LYS_AGENT")
285            .map_err(|_| anyhow::anyhow!("LYS_AGENT is missing or not Unicode"))
286    } else {
287        Ok(lys.agent.clone())
288    }
289}
290
291/// An open seat (Tom, 25 Oct: assistants on an open exchange, no Lys) is the
292/// station its `[open] name` declares, presented as `open:<name>`; off
293/// loopback it reaches the network only with `reach = "network"`.
294fn open_name(server: &str, open: crate::OpenTable) -> anyhow::Result<(String, Credential)> {
295    let name = open
296        .name
297        .filter(|name| crate::config::is_open_name(name))
298        .ok_or_else(|| {
299            anyhow::anyhow!("an open seat needs [open] name, 1 to 64 of a-z, 0-9, '.', '_' or '-'")
300        })?;
301    anyhow::ensure!(
302        open.reach == crate::Reach::Network || crate::seat::names_loopback(server),
303        "an open seat reaches a loopback exchange only unless [open] reach = \"network\": server {server}"
304    );
305    Ok((name.clone(), Credential::Open { name: Some(name) }))
306}
307
308#[derive(Deserialize)]
309#[serde(deny_unknown_fields)]
310struct Listen {
311    #[serde(default)]
312    subjects: Vec<String>,
313    #[serde(rename = "listen_bound")]
314    bound: Option<usize>,
315    content_bytes: Option<usize>,
316}
317
318pub(super) fn validate_listen(
319    value: Option<&toml::Value>,
320    name: &str,
321    content_bytes: usize,
322) -> anyhow::Result<()> {
323    let Some(value) = value else {
324        return Ok(());
325    };
326    let listen: Listen = value
327        .clone()
328        .try_into()
329        .map_err(|_| anyhow::anyhow!("[listen] table refused"))?;
330    anyhow::ensure!(
331        listen.bound.is_none_or(|bound| bound > 0),
332        "listen_bound must be positive"
333    );
334    anyhow::ensure!(
335        listen
336            .content_bytes
337            .is_none_or(|bound| bound == content_bytes),
338        "[listen] content_bytes must equal [harness] content_bytes"
339    );
340    // The host owns the inbox; extra listeners require the ticket owner's channel adapter.
341    anyhow::ensure!(
342        listen.subjects.len() <= 1
343            && listen
344                .subjects
345                .iter()
346                .all(|subject| subject == &format!("agents.{name}.inbox")),
347        "agent host accepts only its own inbox in [listen]; additional subjects are not wired"
348    );
349    Ok(())
350}