liminal-cli 0.3.4

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! The host reads the same seat fields without claiming ownership of the ticket parser.

use crate::{Config, Credential, Limits};
use serde::Deserialize;
use std::path::{Path, PathBuf};

/// A launch declaration and its positive resource bounds. No field grants permission.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Harness {
    /// One catalogue id; cdx uses channel wake instead of this host.
    pub id: String,
    /// The executable, resolved at launch without searching the current directory.
    pub program: PathBuf,
    /// Arguments passed without a shell. They never enter diagnostics.
    pub args: Vec<String>,
    /// Session root; absent inherits the directory selected by the Lys launch profile.
    pub cwd: Option<PathBuf>,
    /// A prior session explicitly requested for resume, never silently replaced.
    pub session: Option<String>,
    /// Unread prompts; 32 is one seat's unread mail, not a durable mailbox.
    #[serde(default = "prompt_queue")]
    pub prompt_queue: usize,
    /// Text bytes per stream item and prompt; 64 KiB covers one model turn, not a file transfer.
    #[serde(default = "content_bytes")]
    pub content_bytes: usize,
    /// ACP frame bytes; 256 KiB admits tool metadata without permitting an unbounded pipe allocation.
    #[serde(default = "frame_bytes")]
    pub acp_frame_bytes: usize,
    /// Held permissions; 1024 admits the 729-request pipe contract while bounding approval memory.
    #[serde(default = "permissions")]
    pub pending_permissions: usize,
    /// Pipe responses awaiting write; 2048 covers a permission burst without blocking stdout reads.
    #[serde(default = "writes")]
    pub write_queue: usize,
    /// Frames awaiting dispatch; one prevents raw tool frames from becoming a second transcript.
    #[serde(default = "read_queue")]
    pub read_queue: usize,
    /// Stream publications awaiting the network; 128 limits memory when a watcher is slow.
    #[serde(default = "streams")]
    pub stream_queue: usize,
    /// Issuer response bytes; 64 KiB admits structured refusals without retaining arbitrary bodies.
    #[serde(default = "content_bytes")]
    pub issuer_response_bytes: usize,
    /// Conversations one history listing names, newest first; 50 fills the panel's list
    /// while one stream item stays within `content_bytes`.
    #[serde(default = "listed_sessions")]
    pub listed_sessions: usize,
    /// Characters kept of each listed title; 120 is one line of the panel's list.
    #[serde(default = "title_chars")]
    pub title_chars: usize,
}

const fn prompt_queue() -> usize {
    32
}
const fn content_bytes() -> usize {
    65_536
}
const fn frame_bytes() -> usize {
    262_144
}
const fn permissions() -> usize {
    1024
}
const fn writes() -> usize {
    2048
}
const fn streams() -> usize {
    128
}
const fn read_queue() -> usize {
    1
}
const fn listed_sessions() -> usize {
    50
}
const fn title_chars() -> usize {
    120
}

impl Harness {
    /// Checks catalogue membership, paths, and all positive limits before spawning.
    ///
    /// # Errors
    /// Returns a refusal naming the invalid field, without its value.
    pub fn validate(&self) -> anyhow::Result<()> {
        super::catalogue::validate(&self.id)?;
        anyhow::ensure!(
            !self.program.as_os_str().is_empty(),
            "harness.program is empty"
        );
        anyhow::ensure!(
            self.cwd.as_ref().is_none_or(|cwd| cwd.is_absolute()),
            "harness.cwd must be absolute"
        );
        anyhow::ensure!(
            self.session
                .as_ref()
                .is_none_or(|id| !id.trim().is_empty() && id.len() <= self.content_bytes),
            "harness.session is empty or exceeds content_bytes"
        );
        for (name, bound) in [
            ("prompt_queue", self.prompt_queue),
            ("content_bytes", self.content_bytes),
            ("acp_frame_bytes", self.acp_frame_bytes),
            ("pending_permissions", self.pending_permissions),
            ("write_queue", self.write_queue),
            ("read_queue", self.read_queue),
            ("stream_queue", self.stream_queue),
            ("issuer_response_bytes", self.issuer_response_bytes),
            ("listed_sessions", self.listed_sessions),
            ("title_chars", self.title_chars),
        ] {
            anyhow::ensure!(bound > 0, "harness.{name} must be positive");
        }
        Ok(())
    }
}

/// Validate the optional host table at the ticket parser's boundary.
///
/// # Errors
/// Returns a named configuration refusal without the table's bytes.
pub fn validate_optional(
    table: Option<&toml::Table>,
    path: &Path,
) -> Result<(), crate::ConfigError> {
    let Some(table) = table else {
        return Ok(());
    };
    let harness: Harness =
        toml::Value::Table(table.clone())
            .try_into()
            .map_err(|_| crate::ConfigError::Invalid {
                path: path.to_path_buf(),
                message: "[harness] table refused".to_owned(),
            })?;
    harness
        .validate()
        .map_err(|error| crate::ConfigError::Invalid {
            path: path.to_path_buf(),
            message: error.to_string(),
        })
}

pub(super) struct Seat {
    pub path: PathBuf,
    pub ticket: Config,
    pub harness: Harness,
    pub name: String,
    pub cwd: PathBuf,
}

#[derive(Deserialize)]
struct Written {
    server: String,
    channel: String,
    lys: crate::lys::LysTable,
    limits: Limits,
    agent: Option<String>,
    harness: Harness,
}

impl Seat {
    pub fn load(path: &Path) -> anyhow::Result<Self> {
        let path = std::fs::canonicalize(path)
            .map_err(|error| anyhow::anyhow!("seat file cannot be opened: {}", error.kind()))?;
        let text = zeroize::Zeroizing::new(
            std::fs::read_to_string(&path)
                .map_err(|error| anyhow::anyhow!("seat file cannot be read: {}", error.kind()))?,
        );
        let table: toml::Table =
            toml::from_str(&text).map_err(|_| anyhow::anyhow!("seat file is not valid TOML"))?;
        for field in table.keys() {
            anyhow::ensure!(
                [
                    "server", "channel", "lys", "limits", "agent", "listen", "harness"
                ]
                .contains(&field.as_str()),
                "seat file has an unknown field"
            );
        }
        let file: Written = toml::from_str(&text).map_err(|_| {
            anyhow::anyhow!("seat requires server, channel, [lys], [limits] and [harness]")
        })?;
        file.harness.validate()?;
        super::exit::validate_issuer(&file.lys.issuer)?;
        // A Lys-launched seat names itself by LYS_AGENT and proves itself with
        // its run pass; a seat holding its own certified key signs as lys.agent.
        let name = if file.lys.key_file.as_os_str().is_empty() {
            std::env::var("LYS_AGENT")
                .map_err(|_| anyhow::anyhow!("LYS_AGENT is missing or not Unicode"))?
        } else {
            file.lys.agent.clone()
        };
        anyhow::ensure!(
            !name.is_empty()
                && name
                    .bytes()
                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
                && name.len() <= file.harness.content_bytes,
            "LYS_AGENT must name one agent segment within content_bytes"
        );
        anyhow::ensure!(
            file.agent.as_ref().is_none_or(|agent| agent == &name)
                && (file.lys.agent.is_empty() || file.lys.agent == name),
            "seat agent differs from LYS_AGENT"
        );
        validate_listen(table.get("listen"), &name, file.harness.content_bytes)?;
        anyhow::ensure!(
            !file.server.is_empty() && !file.channel.is_empty(),
            "seat server and channel must be nonempty"
        );
        let limits = file.limits;
        anyhow::ensure!(
            limits.frame_bytes > 0
                && limits.record_bytes > 0
                && limits.reply_timeout_ms > 0
                && limits.flow_steps > 0
                && limits.request_bytes > 0,
            "every ticket limit must be positive"
        );
        let cwd = file.harness.cwd.clone().map_or_else(
            || {
                std::env::current_dir()
                    .map_err(|_| anyhow::anyhow!("Lys launch directory cannot be read"))
            },
            Ok,
        )?;
        Ok(Self {
            path,
            name: name.clone(),
            cwd,
            harness: file.harness,
            ticket: Config {
                server: file.server,
                channel: file.channel,
                credential: Credential::Lys(file.lys),
                limits,
                agent: Some(name),
                listen: crate::mcp::listen_config::ListenConfig::default(),
            },
        })
    }
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Listen {
    #[serde(default)]
    subjects: Vec<String>,
    #[serde(rename = "listen_bound")]
    bound: Option<usize>,
    content_bytes: Option<usize>,
}

pub(super) fn validate_listen(
    value: Option<&toml::Value>,
    name: &str,
    content_bytes: usize,
) -> anyhow::Result<()> {
    let Some(value) = value else {
        return Ok(());
    };
    let listen: Listen = value
        .clone()
        .try_into()
        .map_err(|_| anyhow::anyhow!("[listen] table refused"))?;
    anyhow::ensure!(
        listen.bound.is_none_or(|bound| bound > 0),
        "listen_bound must be positive"
    );
    anyhow::ensure!(
        listen
            .content_bytes
            .is_none_or(|bound| bound == content_bytes),
        "[listen] content_bytes must equal [harness] content_bytes"
    );
    // The host owns the inbox; extra listeners require the ticket owner's channel adapter.
    anyhow::ensure!(
        listen.subjects.len() <= 1
            && listen
                .subjects
                .iter()
                .all(|subject| subject == &format!("agents.{name}.inbox")),
        "agent host accepts only its own inbox in [listen]; additional subjects are not wired"
    );
    Ok(())
}