1use crate::{Config, Credential, Limits};
4use serde::Deserialize;
5use std::path::{Path, PathBuf};
6
7#[derive(Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct Harness {
11 pub id: String,
13 pub program: PathBuf,
15 pub args: Vec<String>,
17 pub cwd: Option<PathBuf>,
19 pub session: Option<String>,
21 #[serde(default = "prompt_queue")]
23 pub prompt_queue: usize,
24 #[serde(default = "content_bytes")]
26 pub content_bytes: usize,
27 #[serde(default = "frame_bytes")]
29 pub acp_frame_bytes: usize,
30 #[serde(default = "permissions")]
32 pub pending_permissions: usize,
33 #[serde(default = "writes")]
35 pub write_queue: usize,
36 #[serde(default = "read_queue")]
38 pub read_queue: usize,
39 #[serde(default = "streams")]
41 pub stream_queue: usize,
42 #[serde(default = "content_bytes")]
44 pub issuer_response_bytes: usize,
45 #[serde(default = "listed_sessions")]
48 pub listed_sessions: usize,
49 #[serde(default = "title_chars")]
51 pub title_chars: usize,
52 #[serde(default)]
55 pub env: std::collections::BTreeMap<String, String>,
56 #[serde(default)]
58 pub env_remove: Vec<String>,
59}
60
61fn 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 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 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
149pub 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
279fn 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
291fn 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 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}