Skip to main content

liminal_cli/
config.rs

1//! The ticket's configuration.
2//!
3//! The ticket's configuration (SDK-017 R1), read from the TOML file named on
4//! its command line: the server's address, the channel, the Lys pass from a
5//! file of its own (never printed or logged), and the limits. A missing or
6//! zero field is refused by its name before any socket or stdin is touched.
7
8use std::fmt;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12pub use crate::mcp::listen_config::ListenConfig;
13use serde::Deserialize;
14
15/// The Lys pass, read from its file. Its Debug prints its length only.
16pub struct Token(String);
17
18impl Token {
19    /// Borrows the pass only for the authenticated connection boundary.
20    #[must_use]
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24}
25
26impl fmt::Debug for Token {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        write!(f, "Token({} bytes)", self.0.len())
29    }
30}
31
32/// The ticket's limits; every one is positive.
33#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
34#[serde(deny_unknown_fields)]
35pub struct Limits {
36    /// The longest JSON-RPC line read from stdin, in bytes.
37    pub frame_bytes: usize,
38    /// The largest topology record accepted from `console.topology`, in bytes.
39    pub record_bytes: usize,
40    /// How long one routed request may wait for its reply, in milliseconds.
41    pub reply_timeout_ms: u64,
42    /// The most steps one flow may carry.
43    pub flow_steps: usize,
44    /// The largest request body sent on a subject, in bytes.
45    pub request_bytes: usize,
46    /// How long a loopback server or issuer may take to accept a connection
47    /// before the seat is refused (AGT-006 R3), in milliseconds; 2000 when
48    /// unnamed: a local exchange answers in milliseconds.
49    #[serde(default = "default_connect_timeout_ms")]
50    pub connect_timeout_ms: u64,
51    /// The largest answer `liminal mcp` writes inline on stdout, in bytes; a
52    /// larger one is handed over whole as a file (SRV-052 R2). 16 MiB when
53    /// unnamed: an MCP host holds a tool result in memory, and 16 MiB is far
54    /// past a page while bounded. Never the stdin line bound.
55    #[serde(default = "default_answer_bytes")]
56    pub answer_bytes: usize,
57}
58
59const fn default_connect_timeout_ms() -> u64 {
60    2_000
61}
62
63const fn default_answer_bytes() -> usize {
64    16_777_216
65}
66
67fn central_server() -> String {
68    crate::seat::CENTRAL_SERVER.to_owned()
69}
70
71impl Limits {
72    /// The reply timeout as a duration.
73    #[must_use]
74    pub const fn reply_timeout(&self) -> Duration {
75        Duration::from_millis(self.reply_timeout_ms)
76    }
77
78    /// The loopback connect bound as a duration.
79    #[must_use]
80    pub const fn connect_timeout(&self) -> Duration {
81        Duration::from_millis(self.connect_timeout_ms)
82    }
83
84    const fn validate(self) -> Result<Self, &'static str> {
85        if self.frame_bytes == 0 {
86            return Err("frame_bytes");
87        }
88        if self.record_bytes == 0 {
89            return Err("record_bytes");
90        }
91        if self.reply_timeout_ms == 0 {
92            return Err("reply_timeout_ms");
93        }
94        if self.flow_steps == 0 {
95            return Err("flow_steps");
96        }
97        if self.request_bytes == 0 {
98            return Err("request_bytes");
99        }
100        if self.connect_timeout_ms == 0 {
101            return Err("connect_timeout_ms");
102        }
103        if self.answer_bytes == 0 {
104            return Err("answer_bytes");
105        }
106        Ok(self)
107    }
108}
109
110/// Validated inputs for the ticket.
111#[derive(Debug)]
112pub struct Config {
113    /// The server address every connection dials.
114    pub server: String,
115    /// The channel routed requests travel on.
116    pub channel: String,
117    /// Where the pass comes from: a fixed pass, or Lys asked with the agent's key.
118    pub credential: Credential,
119    /// The limits validated before anything is opened.
120    pub limits: Limits,
121    /// This seat's validated agent identity, used only for its own inbox.
122    pub agent: Option<String>,
123    /// Exact subjects and positive bounds for channel input.
124    pub listen: ListenConfig,
125}
126
127/// Where the ticket's pass comes from.
128#[derive(Debug)]
129pub enum Credential {
130    /// A pass read once from `token_file`, whose Debug reveals only its length.
131    Token(Token),
132    /// `[lys]`: the agent's own pass, asked of Lys with its key and renewed.
133    Lys(crate::lys::LysTable),
134    /// Neither (ACCESS-010 R3): an open seat on an exchange that gates
135    /// nothing; it presents no credential, only the name it declares
136    /// (ACCESS-012 R2), when it has one.
137    Open {
138        /// `[open] name`, recorded by the exchange as `open:<name>`.
139        name: Option<String>,
140    },
141}
142
143impl Credential {
144    /// Who this seat is to an exchange, in one phrase: `open:<name>` or
145    /// `open:anonymous` for an open seat, the kind alone for the others.
146    #[must_use]
147    pub fn holder(&self) -> String {
148        match self {
149            Self::Open { name } => format!("open:{}", name.as_deref().unwrap_or("anonymous")),
150            Self::Token(_) => "token file".to_owned(),
151            Self::Lys(lys) => format!("lys issuer {}", lys.issuer),
152        }
153    }
154
155    /// The record an open seat presents at `Connect` (ACCESS-012 R2):
156    /// `open:<name>`, or nothing when it declares no name; empty for every
157    /// other credential, which present their pass instead.
158    #[must_use]
159    pub fn open_record(&self) -> Vec<u8> {
160        match self {
161            Self::Open { name: Some(name) } => format!("open:{name}").into_bytes(),
162            _ => Vec::new(),
163        }
164    }
165}
166
167/// Whether `name` is a declared name: 1 to 64 of `a-z`, `0-9`, `.`, `_` or
168/// `-`, the same word as the exchange's.
169#[must_use]
170pub fn is_open_name(name: &str) -> bool {
171    (1..=64).contains(&name.len())
172        && name.bytes().all(|b| {
173            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-')
174        })
175}
176
177/// Why a configuration is refused.
178#[derive(Debug, thiserror::Error)]
179pub enum ConfigError {
180    /// Reading the named configuration failed.
181    #[error("the configuration {path} cannot be read: {message}")]
182    Unreadable {
183        /// The configuration that could not be read.
184        path: PathBuf,
185        /// The operating-system failure, without file contents.
186        message: String,
187    },
188    /// The named configuration did not satisfy its schema.
189    #[error("the configuration {path} is refused: {message}")]
190    Invalid {
191        /// The refused configuration.
192        path: PathBuf,
193        /// The schema failure, without file contents.
194        message: String,
195    },
196    /// A limit is zero.
197    #[error("the configuration {path} is refused: {field} must be positive")]
198    Zero {
199        /// The refused configuration.
200        path: PathBuf,
201        /// The field that is zero.
202        field: &'static str,
203    },
204    /// Reading or validating the pass failed, without revealing its bytes.
205    #[error("the token file {path} cannot be read: {message}")]
206    Token {
207        /// The refused token file.
208        path: PathBuf,
209        /// The failure, without token contents.
210        message: String,
211    },
212}
213
214/// The file as written; every field but the server (the central exchange
215/// when unnamed) is required, and an unknown one is refused.
216#[derive(Deserialize)]
217#[serde(deny_unknown_fields)]
218struct Written {
219    harness: Option<toml::Table>,
220    #[serde(default = "central_server")]
221    server: String,
222    channel: String,
223    token_file: Option<PathBuf>,
224    lys: Option<crate::lys::LysTable>,
225    limits: Limits,
226    agent: Option<String>,
227    #[serde(default)]
228    listen: ListenConfig,
229    open: Option<OpenTable>,
230}
231
232/// `[open]` (ACCESS-011 R1): how far a seat with no credential reaches. The
233/// table is refused beside a credential, so it never reads as a way to
234/// loosen a gated seat.
235#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
236#[serde(deny_unknown_fields)]
237pub struct OpenTable {
238    /// Loopback without the table; the network when the operator writes it.
239    #[serde(default)]
240    pub reach: Reach,
241    /// The name this seat declares to an open exchange (ACCESS-012 R2):
242    /// recorded as `open:<name>` on what it creates; anonymous without one.
243    #[serde(default)]
244    pub name: Option<String>,
245}
246
247/// How far an open seat reaches.
248#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum Reach {
251    /// This machine only.
252    #[default]
253    Loopback,
254    /// Any exchange address the seat names; the exchange asks it nothing.
255    Network,
256}
257
258/// What `liminal mcp`'s flags say about the seat.
259///
260/// Tom, 25 Oct: "can we have a generic one and set id at runtime?" Each flag
261/// given wins over the seat file's value; with no file, `liminal seat
262/// init`'s channel and limits stand under them.
263#[derive(Debug, Clone, Default, PartialEq, Eq)]
264pub struct Overrides {
265    /// `--server`: the exchange to dial.
266    pub server: Option<String>,
267    /// `--channel`: the channel routed requests travel on.
268    pub channel: Option<String>,
269    /// `--as`: the name an open seat declares (`[open] name`).
270    pub name: Option<String>,
271    /// `--agent`: the Lys agent id (`[lys] agent`), given with `--key-file`.
272    pub agent: Option<String>,
273    /// `--key-file`: the agent's key (`[lys] key_file`), given with `--agent`.
274    pub key_file: Option<PathBuf>,
275}
276
277impl Overrides {
278    /// Whether no flag was given.
279    #[must_use]
280    pub fn is_empty(&self) -> bool {
281        self == &Self::default()
282    }
283
284    /// Writes the flags into `table`, the seat file's or the seat-init
285    /// defaults, so the one validation below reads them as written fields.
286    /// The seat's kind is never overridden: `--as` beside `[lys]` or a
287    /// `token_file`, and `--agent` beside `[open]` or a `token_file`, are
288    /// refused, as is `--agent` or `--key-file` alone.
289    fn apply(&self, table: &mut toml::Table, written: bool) -> Result<(), String> {
290        let lys = match (&self.agent, &self.key_file) {
291            (Some(agent), Some(key_file)) => Some((agent, key_file)),
292            (Some(_), None) => return Err("--agent needs --key-file, the agent's key".to_owned()),
293            (None, Some(_)) => {
294                return Err("--key-file needs --agent, the agent it is the key of".to_owned());
295            }
296            (None, None) => None,
297        };
298        if self.name.is_some() && lys.is_some() {
299            return Err(
300                "--as names an open seat and --agent a Lys one; give one of them".to_owned(),
301            );
302        }
303        let credential = ["lys", "token_file"]
304            .into_iter()
305            .find(|key| table.contains_key(*key))
306            .map(|key| if key == "lys" { "[lys]" } else { "token_file" });
307        if let Some(server) = &self.server {
308            table.insert("server".into(), server.clone().into());
309        }
310        if let Some(channel) = &self.channel {
311            table.insert("channel".into(), channel.clone().into());
312        }
313        if let Some(name) = &self.name {
314            if let Some(credential) = credential {
315                return Err(format!(
316                    "--as names an open seat; this seat has {credential}, whose key is its identity"
317                ));
318            }
319            if !is_open_name(name) {
320                return Err("--as must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned());
321            }
322            let open = table_of(table, "open")?;
323            open.insert("name".into(), name.clone().into());
324        }
325        if let Some((agent, key_file)) = lys {
326            if table.contains_key("open") || table.contains_key("token_file") {
327                let kind = if table.contains_key("open") {
328                    "[open]"
329                } else {
330                    "token_file"
331                };
332                return Err(format!(
333                    "--agent names a Lys seat; this seat has {kind}, remove it or give --as"
334                ));
335            }
336            let key_file = std::path::absolute(key_file)
337                .map_err(|error| format!("--key-file cannot be made absolute: {error}"))?;
338            let key_file = key_file
339                .to_str()
340                .ok_or("--key-file is not a UTF-8 path")?
341                .to_owned();
342            if table.contains_key("agent") {
343                table.insert("agent".into(), agent.clone().into());
344            }
345            let lys = table_of(table, "lys")?;
346            lys.insert("agent".into(), agent.clone().into());
347            lys.insert("key_file".into(), key_file.into());
348        }
349        // With no file an open seat off loopback reaches the network, as
350        // `liminal seat init --open` writes it; a file's own reach stands.
351        if !written
352            && credential.is_none()
353            && !table.contains_key("lys")
354            && table
355                .get("server")
356                .and_then(toml::Value::as_str)
357                .is_some_and(|server| !crate::seat::names_loopback(server))
358        {
359            let open = table_of(table, "open")?;
360            open.entry("reach").or_insert_with(|| "network".into());
361        }
362        Ok(())
363    }
364}
365
366/// `table[key]` as a table, made empty when absent.
367fn table_of<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table, String> {
368    table
369        .entry(key)
370        .or_insert_with(|| toml::Value::Table(toml::Table::new()))
371        .as_table_mut()
372        .ok_or_else(|| format!("{key} must be a table"))
373}
374
375/// Where the errors of a seat given only by flags point.
376const COMMAND_LINE: &str = "the command line";
377
378impl Config {
379    /// Reads and checks the configuration at `path`. A missing field is named
380    /// in the error (serde's own words, "missing field `frame_bytes`"), a zero
381    /// limit by its name; the pass's bytes never appear in any error.
382    ///
383    /// # Errors
384    /// Returns a named read, schema, zero-limit or token refusal.
385    pub fn load(path: &Path) -> Result<Self, ConfigError> {
386        Self::load_with(Some(path), &Overrides::default())
387    }
388
389    /// Reads the seat at `path`, or `liminal seat init`'s defaults when there
390    /// is none, with `overrides` written over it, then checks it exactly as
391    /// [`Self::load`] does.
392    ///
393    /// # Errors
394    /// As [`Self::load`], and a refused flag by its name.
395    pub fn load_with(path: Option<&Path>, overrides: &Overrides) -> Result<Self, ConfigError> {
396        let mut table = match path {
397            Some(path) => {
398                let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Unreadable {
399                    path: path.to_path_buf(),
400                    message: e.to_string(),
401                })?;
402                toml::from_str::<toml::Table>(&text).map_err(|e| ConfigError::Invalid {
403                    path: path.to_path_buf(),
404                    message: e.message().to_owned(),
405                })?
406            }
407            None => crate::seat::defaults(),
408        };
409        let path = path.unwrap_or_else(|| Path::new(COMMAND_LINE));
410        overrides
411            .apply(&mut table, path != Path::new(COMMAND_LINE))
412            .map_err(|message| ConfigError::Invalid {
413                path: path.to_path_buf(),
414                message,
415            })?;
416        let file: Written =
417            table
418                .try_into()
419                .map_err(|e: toml::de::Error| ConfigError::Invalid {
420                    path: path.to_path_buf(),
421                    message: e.message().to_owned(),
422                })?;
423        crate::agent::config::validate_optional(file.harness.as_ref(), path)?;
424        let limits = file.limits.validate().map_err(|field| ConfigError::Zero {
425            path: path.to_path_buf(),
426            field,
427        })?;
428        file.listen
429            .validate_bounds()
430            .map_err(|message| ConfigError::Invalid {
431                path: path.to_path_buf(),
432                message,
433            })?;
434        let agent = agent_name(&file).map_err(|message| ConfigError::Invalid {
435            path: path.to_path_buf(),
436            message,
437        })?;
438        file.listen
439            .validate(agent.as_deref())
440            .map_err(|message| ConfigError::Invalid {
441                path: path.to_path_buf(),
442                message,
443            })?;
444        if file.server.is_empty() {
445            return Err(ConfigError::Zero {
446                path: path.to_path_buf(),
447                field: "server",
448            });
449        }
450        if file.channel.is_empty() {
451            return Err(ConfigError::Zero {
452                path: path.to_path_buf(),
453                field: "channel",
454            });
455        }
456        let credential = credential(path, &file.server, file.token_file, file.lys, file.open)?;
457        Ok(Self {
458            server: file.server,
459            channel: file.channel,
460            credential,
461            limits,
462            agent,
463            listen: file.listen,
464        })
465    }
466}
467
468/// The seat's credential from its `token_file` and `[lys]` fields: exactly one
469/// of them, or neither for an open seat (ACCESS-010 R3), which reaches a
470/// loopback exchange only unless `[open] reach = "network"` says otherwise
471/// (ACCESS-011 R1); the loopback refusal happens here, before any dial.
472fn credential(
473    path: &Path,
474    server: &str,
475    token_file: Option<PathBuf>,
476    lys: Option<crate::lys::LysTable>,
477    open: Option<OpenTable>,
478) -> Result<Credential, ConfigError> {
479    let invalid = |message: String| ConfigError::Invalid {
480        path: path.to_path_buf(),
481        message,
482    };
483    if open.is_some() && (token_file.is_some() || lys.is_some()) {
484        return Err(invalid(
485            "[open] names a seat with no credential; this seat has token_file or [lys], remove one"
486                .to_owned(),
487        ));
488    }
489    let reach = open.as_ref().map_or(Reach::Loopback, |open| open.reach);
490    let name = open.and_then(|open| open.name);
491    if let Some(name) = name.as_deref()
492        && !is_open_name(name)
493    {
494        return Err(invalid(
495            "[open] name must be 1 to 64 of a-z, 0-9, '.', '_' or '-'".to_owned(),
496        ));
497    }
498    let token_file = match (token_file, lys) {
499        (None, Some(lys)) => return Ok(Credential::Lys(lys)),
500        (None, None) if reach == Reach::Network || crate::seat::names_loopback(server) => {
501            return Ok(Credential::Open { name });
502        }
503        (None, None) => {
504            return Err(invalid(format!(
505                "an open seat (no token_file, no [lys]) reaches a loopback exchange only: \
506                 server {server}"
507            )));
508        }
509        (Some(token_file), None) if token_file.is_absolute() => token_file,
510        (Some(token_file), None) => path
511            .parent()
512            .map_or_else(|| token_file.clone(), |base| base.join(&token_file)),
513        _ => {
514            return Err(invalid(
515                "give exactly one of token_file and [lys]".to_owned(),
516            ));
517        }
518    };
519    let token = std::fs::read_to_string(&token_file).map_err(|e| ConfigError::Token {
520        path: token_file.clone(),
521        message: e.to_string(),
522    })?;
523    let token = token.trim_end_matches(['\r', '\n']);
524    if token.is_empty() {
525        return Err(ConfigError::Token {
526            path: token_file,
527            message: "token file is empty".to_owned(),
528        });
529    }
530    Ok(Credential::Token(Token(token.to_owned())))
531}
532
533fn agent_name(file: &Written) -> Result<Option<String>, String> {
534    let named = file.agent.as_deref().or_else(|| {
535        file.lys
536            .as_ref()
537            .map(|lys| lys.agent.as_str())
538            .filter(|name| !name.is_empty())
539    });
540    let environment = match if named.is_some() || file.lys.is_some() {
541        std::env::var("LYS_AGENT")
542    } else {
543        Err(std::env::VarError::NotPresent)
544    } {
545        Ok(value) => Some(value),
546        Err(std::env::VarError::NotPresent) => None,
547        Err(std::env::VarError::NotUnicode(_)) => return Err("LYS_AGENT is not Unicode".to_owned()),
548    };
549    for candidate in [
550        file.agent.as_deref(),
551        file.lys
552            .as_ref()
553            .map(|lys| lys.agent.as_str())
554            .filter(|name| !name.is_empty()),
555        environment.as_deref(),
556    ]
557    .into_iter()
558    .flatten()
559    {
560        if candidate.is_empty()
561            || candidate.len() > file.listen.agent_bytes
562            || candidate.split('.').any(str::is_empty)
563            || !candidate
564                .bytes()
565                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
566        {
567            return Err(format!(
568                "agent must be a plain identity of at most {} bytes",
569                file.listen.agent_bytes
570            ));
571        }
572    }
573    if let (Some(named), Some(environment)) = (named, environment.as_deref())
574        && named != environment
575    {
576        return Err(format!(
577            "seat agent {named} differs from LYS_AGENT {environment}"
578        ));
579    }
580    if let (Some(root), Some(lys)) = (file.agent.as_deref(), file.lys.as_ref())
581        && !lys.agent.is_empty()
582        && root != lys.agent
583    {
584        return Err(format!(
585            "seat agent {root} differs from lys.agent {}",
586            lys.agent
587        ));
588    }
589    let name = named.map(str::to_owned).or_else(|| {
590        if file.lys.is_some() {
591            environment
592        } else {
593            None
594        }
595    });
596    // ACCESS-013 R2: an open seat that declares a name has that station.
597    // A name the open check refuses is left to it, so its words are the answer.
598    Ok(name.or_else(|| {
599        file.open
600            .as_ref()
601            .and_then(|open| open.name.clone())
602            .filter(|name| is_open_name(name))
603    }))
604}
605
606#[cfg(test)]
607#[path = "config_tests.rs"]
608mod tests;