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}
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 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
121pub 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 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 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}