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: crate::lys::LysTable,
160    limits: Limits,
161    agent: Option<String>,
162    harness: Harness,
163}
164
165impl Seat {
166    pub fn load(path: &Path) -> anyhow::Result<Self> {
167        let path = std::fs::canonicalize(path)
168            .map_err(|error| anyhow::anyhow!("seat file cannot be opened: {}", error.kind()))?;
169        let text = zeroize::Zeroizing::new(
170            std::fs::read_to_string(&path)
171                .map_err(|error| anyhow::anyhow!("seat file cannot be read: {}", error.kind()))?,
172        );
173        let table: toml::Table =
174            toml::from_str(&text).map_err(|_| anyhow::anyhow!("seat file is not valid TOML"))?;
175        for field in table.keys() {
176            anyhow::ensure!(
177                [
178                    "server", "channel", "lys", "limits", "agent", "listen", "harness"
179                ]
180                .contains(&field.as_str()),
181                "seat file has an unknown field"
182            );
183        }
184        let file: Written = toml::from_str(&text).map_err(|_| {
185            anyhow::anyhow!("seat requires server, channel, [lys], [limits] and [harness]")
186        })?;
187        file.harness.validate()?;
188        super::exit::validate_issuer(&file.lys.issuer)?;
189        // A Lys-launched seat names itself by LYS_AGENT and proves itself with
190        // its run pass; a seat holding its own certified key signs as lys.agent.
191        let name = if file.lys.key_file.as_os_str().is_empty() {
192            std::env::var("LYS_AGENT")
193                .map_err(|_| anyhow::anyhow!("LYS_AGENT is missing or not Unicode"))?
194        } else {
195            file.lys.agent.clone()
196        };
197        anyhow::ensure!(
198            !name.is_empty()
199                && name
200                    .bytes()
201                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
202                && name.len() <= file.harness.content_bytes,
203            "LYS_AGENT must name one agent segment within content_bytes"
204        );
205        anyhow::ensure!(
206            file.agent.as_ref().is_none_or(|agent| agent == &name)
207                && (file.lys.agent.is_empty() || file.lys.agent == name),
208            "seat agent differs from LYS_AGENT"
209        );
210        validate_listen(table.get("listen"), &name, file.harness.content_bytes)?;
211        anyhow::ensure!(
212            !file.server.is_empty() && !file.channel.is_empty(),
213            "seat server and channel must be nonempty"
214        );
215        let limits = file.limits;
216        anyhow::ensure!(
217            limits.frame_bytes > 0
218                && limits.record_bytes > 0
219                && limits.reply_timeout_ms > 0
220                && limits.flow_steps > 0
221                && limits.request_bytes > 0,
222            "every ticket limit must be positive"
223        );
224        let cwd = file.harness.cwd.clone().map_or_else(
225            || {
226                std::env::current_dir()
227                    .map_err(|_| anyhow::anyhow!("Lys launch directory cannot be read"))
228            },
229            Ok,
230        )?;
231        Ok(Self {
232            path,
233            name: name.clone(),
234            cwd,
235            harness: file.harness,
236            ticket: Config {
237                server: file.server,
238                channel: file.channel,
239                credential: Credential::Lys(file.lys),
240                limits,
241                agent: Some(name),
242                listen: crate::mcp::listen_config::ListenConfig::default(),
243            },
244        })
245    }
246}
247
248#[derive(Deserialize)]
249#[serde(deny_unknown_fields)]
250struct Listen {
251    #[serde(default)]
252    subjects: Vec<String>,
253    #[serde(rename = "listen_bound")]
254    bound: Option<usize>,
255    content_bytes: Option<usize>,
256}
257
258pub(super) fn validate_listen(
259    value: Option<&toml::Value>,
260    name: &str,
261    content_bytes: usize,
262) -> anyhow::Result<()> {
263    let Some(value) = value else {
264        return Ok(());
265    };
266    let listen: Listen = value
267        .clone()
268        .try_into()
269        .map_err(|_| anyhow::anyhow!("[listen] table refused"))?;
270    anyhow::ensure!(
271        listen.bound.is_none_or(|bound| bound > 0),
272        "listen_bound must be positive"
273    );
274    anyhow::ensure!(
275        listen
276            .content_bytes
277            .is_none_or(|bound| bound == content_bytes),
278        "[listen] content_bytes must equal [harness] content_bytes"
279    );
280    // The host owns the inbox; extra listeners require the ticket owner's channel adapter.
281    anyhow::ensure!(
282        listen.subjects.len() <= 1
283            && listen
284                .subjects
285                .iter()
286                .all(|subject| subject == &format!("agents.{name}.inbox")),
287        "agent host accepts only its own inbox in [listen]; additional subjects are not wired"
288    );
289    Ok(())
290}