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}
53
54const fn prompt_queue() -> usize {
55    32
56}
57const fn content_bytes() -> usize {
58    65_536
59}
60const fn frame_bytes() -> usize {
61    262_144
62}
63const fn permissions() -> usize {
64    1024
65}
66const fn writes() -> usize {
67    2048
68}
69const fn streams() -> usize {
70    128
71}
72const fn read_queue() -> usize {
73    1
74}
75const fn listed_sessions() -> usize {
76    50
77}
78const fn title_chars() -> usize {
79    120
80}
81
82impl Harness {
83    /// Checks catalogue membership, paths, and all positive limits before spawning.
84    ///
85    /// # Errors
86    /// Returns a refusal naming the invalid field, without its value.
87    pub fn validate(&self) -> anyhow::Result<()> {
88        super::catalogue::validate(&self.id)?;
89        anyhow::ensure!(
90            !self.program.as_os_str().is_empty(),
91            "harness.program is empty"
92        );
93        anyhow::ensure!(
94            self.cwd.as_ref().is_none_or(|cwd| cwd.is_absolute()),
95            "harness.cwd must be absolute"
96        );
97        anyhow::ensure!(
98            self.session
99                .as_ref()
100                .is_none_or(|id| !id.trim().is_empty() && id.len() <= self.content_bytes),
101            "harness.session is empty or exceeds content_bytes"
102        );
103        for (name, bound) in [
104            ("prompt_queue", self.prompt_queue),
105            ("content_bytes", self.content_bytes),
106            ("acp_frame_bytes", self.acp_frame_bytes),
107            ("pending_permissions", self.pending_permissions),
108            ("write_queue", self.write_queue),
109            ("read_queue", self.read_queue),
110            ("stream_queue", self.stream_queue),
111            ("issuer_response_bytes", self.issuer_response_bytes),
112            ("listed_sessions", self.listed_sessions),
113            ("title_chars", self.title_chars),
114        ] {
115            anyhow::ensure!(bound > 0, "harness.{name} must be positive");
116        }
117        Ok(())
118    }
119}
120
121/// Validate the optional host table at the ticket parser's boundary.
122///
123/// # Errors
124/// Returns a named configuration refusal without the table's bytes.
125pub fn validate_optional(
126    table: Option<&toml::Table>,
127    path: &Path,
128) -> Result<(), crate::ConfigError> {
129    let Some(table) = table else {
130        return Ok(());
131    };
132    let harness: Harness =
133        toml::Value::Table(table.clone())
134            .try_into()
135            .map_err(|_| crate::ConfigError::Invalid {
136                path: path.to_path_buf(),
137                message: "[harness] table refused".to_owned(),
138            })?;
139    harness
140        .validate()
141        .map_err(|error| crate::ConfigError::Invalid {
142            path: path.to_path_buf(),
143            message: error.to_string(),
144        })
145}
146
147pub(super) struct Seat {
148    pub path: PathBuf,
149    pub ticket: Config,
150    pub harness: Harness,
151    pub name: String,
152    pub cwd: PathBuf,
153}
154
155#[derive(Deserialize)]
156struct Written {
157    server: String,
158    channel: String,
159    lys: Option<crate::lys::LysTable>,
160    open: Option<crate::OpenTable>,
161    limits: Limits,
162    agent: Option<String>,
163    harness: Harness,
164}
165
166impl Seat {
167    pub fn load(path: &Path) -> anyhow::Result<Self> {
168        let path = std::fs::canonicalize(path)
169            .map_err(|error| anyhow::anyhow!("seat file cannot be opened: {}", error.kind()))?;
170        let text = zeroize::Zeroizing::new(
171            std::fs::read_to_string(&path)
172                .map_err(|error| anyhow::anyhow!("seat file cannot be read: {}", error.kind()))?,
173        );
174        let table: toml::Table =
175            toml::from_str(&text).map_err(|_| anyhow::anyhow!("seat file is not valid TOML"))?;
176        for field in table.keys() {
177            anyhow::ensure!(
178                [
179                    "server", "channel", "lys", "open", "limits", "agent", "listen", "harness"
180                ]
181                .contains(&field.as_str()),
182                "seat file has an unknown field"
183            );
184        }
185        let file: Written = toml::from_str(&text).map_err(|_| {
186            anyhow::anyhow!(
187                "seat requires server, channel, [lys] or [open], [limits] and [harness]"
188            )
189        })?;
190        file.harness.validate()?;
191        let (name, credential) = match (file.lys, file.open) {
192            (Some(lys), None) => (lys_name(&lys)?, Credential::Lys(lys)),
193            (None, Some(open)) => open_name(&file.server, open)?,
194            _ => anyhow::bail!("seat requires exactly one of [lys] and [open]"),
195        };
196        let table_agent = match &credential {
197            Credential::Lys(lys) => lys.agent.clone(),
198            _ => String::new(),
199        };
200        anyhow::ensure!(
201            !name.is_empty()
202                && name
203                    .bytes()
204                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
205                && name.len() <= file.harness.content_bytes,
206            "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"
207        );
208        anyhow::ensure!(
209            file.agent.as_ref().is_none_or(|agent| agent == &name)
210                && (table_agent.is_empty() || table_agent == name),
211            "seat agent differs from LYS_AGENT"
212        );
213        validate_listen(table.get("listen"), &name, file.harness.content_bytes)?;
214        anyhow::ensure!(
215            !file.server.is_empty() && !file.channel.is_empty(),
216            "seat server and channel must be nonempty"
217        );
218        let limits = file.limits;
219        anyhow::ensure!(
220            limits.frame_bytes > 0
221                && limits.record_bytes > 0
222                && limits.reply_timeout_ms > 0
223                && limits.flow_steps > 0
224                && limits.request_bytes > 0,
225            "every ticket limit must be positive"
226        );
227        let cwd = file.harness.cwd.clone().map_or_else(
228            || {
229                std::env::current_dir()
230                    .map_err(|_| anyhow::anyhow!("Lys launch directory cannot be read"))
231            },
232            Ok,
233        )?;
234        Ok(Self {
235            path,
236            name: name.clone(),
237            cwd,
238            harness: file.harness,
239            ticket: Config {
240                server: file.server,
241                channel: file.channel,
242                credential,
243                limits,
244                agent: Some(name),
245                listen: crate::mcp::listen_config::ListenConfig::default(),
246            },
247        })
248    }
249}
250
251/// A Lys-launched seat names itself by `LYS_AGENT` and proves itself with its
252/// run pass; a seat holding its own certified key signs as `lys.agent`.
253fn lys_name(lys: &crate::lys::LysTable) -> anyhow::Result<String> {
254    super::exit::validate_issuer(&lys.issuer)?;
255    if lys.key_file.as_os_str().is_empty() {
256        std::env::var("LYS_AGENT")
257            .map_err(|_| anyhow::anyhow!("LYS_AGENT is missing or not Unicode"))
258    } else {
259        Ok(lys.agent.clone())
260    }
261}
262
263/// An open seat (Tom, 25 Oct: assistants on an open exchange, no Lys) is the
264/// station its `[open] name` declares, presented as `open:<name>`; off
265/// loopback it reaches the network only with `reach = "network"`.
266fn open_name(server: &str, open: crate::OpenTable) -> anyhow::Result<(String, Credential)> {
267    let name = open
268        .name
269        .filter(|name| crate::config::is_open_name(name))
270        .ok_or_else(|| {
271            anyhow::anyhow!("an open seat needs [open] name, 1 to 64 of a-z, 0-9, '.', '_' or '-'")
272        })?;
273    anyhow::ensure!(
274        open.reach == crate::Reach::Network || crate::seat::names_loopback(server),
275        "an open seat reaches a loopback exchange only unless [open] reach = \"network\": server {server}"
276    );
277    Ok((name.clone(), Credential::Open { name: Some(name) }))
278}
279
280#[derive(Deserialize)]
281#[serde(deny_unknown_fields)]
282struct Listen {
283    #[serde(default)]
284    subjects: Vec<String>,
285    #[serde(rename = "listen_bound")]
286    bound: Option<usize>,
287    content_bytes: Option<usize>,
288}
289
290pub(super) fn validate_listen(
291    value: Option<&toml::Value>,
292    name: &str,
293    content_bytes: usize,
294) -> anyhow::Result<()> {
295    let Some(value) = value else {
296        return Ok(());
297    };
298    let listen: Listen = value
299        .clone()
300        .try_into()
301        .map_err(|_| anyhow::anyhow!("[listen] table refused"))?;
302    anyhow::ensure!(
303        listen.bound.is_none_or(|bound| bound > 0),
304        "listen_bound must be positive"
305    );
306    anyhow::ensure!(
307        listen
308            .content_bytes
309            .is_none_or(|bound| bound == content_bytes),
310        "[listen] content_bytes must equal [harness] content_bytes"
311    );
312    // The host owns the inbox; extra listeners require the ticket owner's channel adapter.
313    anyhow::ensure!(
314        listen.subjects.len() <= 1
315            && listen
316                .subjects
317                .iter()
318                .all(|subject| subject == &format!("agents.{name}.inbox")),
319        "agent host accepts only its own inbox in [listen]; additional subjects are not wired"
320    );
321    Ok(())
322}