liminal-cli 0.3.0

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! ACCESS-010 R5: an exchange with no `[auth]` and a seat with no credential,
//! no Lys process, no key and no token anywhere in the directory: the seat
//! lists the network, is told by name that the Lys-templated lifecycle services
//! do not exist here, and calls a served subject through
//! the liminal binary, the server's bound line says `mode=open`, and the same
//! exchange written off loopback refuses to start by name.

use std::error::Error;

use liminal_sdk::{PushClient, PushReceiveOutcome};
use serde_json::{Value, json};

use super::network::OpenServer;
use super::{CHANNEL, Host, SUBJECT, Ticket, registration};

/// A worker on the open exchange, dialling with no credential, answering one
/// push with `{"echoed": <input>}`.
fn open_worker(address: &str) -> Result<PushClient, Box<dyn Error>> {
    let worker = PushClient::connect_with_registration_and_auth(address, registration(), b"")?;
    let receiver = worker.receiver_handle()?;
    let writer = worker.writer_handle();
    std::thread::spawn(move || {
        let Ok(PushReceiveOutcome::Frame(push)) = receiver.recv() else {
            return;
        };
        let input: Value = serde_json::from_slice(push.payload()).unwrap_or(Value::Null);
        let reply = serde_json::to_vec(&json!({ "echoed": input })).unwrap_or_default();
        drop(writer.reply(push.correlation_id(), reply));
    });
    Ok(worker)
}

/// The names in `dir`, at every depth.
fn names_under(dir: &std::path::Path) -> Result<Vec<String>, Box<dyn Error>> {
    let mut names = Vec::new();
    for entry in std::fs::read_dir(dir)? {
        let path = entry?.path();
        names.push(
            path.file_name()
                .map_or_else(String::new, |name| name.to_string_lossy().into_owned()),
        );
        if path.is_dir() {
            names.extend(names_under(&path)?);
        }
    }
    Ok(names)
}

#[test]
fn access010_an_open_exchange_and_an_open_seat_work_with_no_lys_anywhere()
-> Result<(), Box<dyn Error>> {
    let dir = tempfile::tempdir()?;
    let server = OpenServer::start(dir.path())?;
    let _worker = open_worker(&server.address.to_string())?;

    let seat = dir.path().join("seat.toml");
    std::fs::write(
        &seat,
        format!(
            "server = \"{}\"\nchannel = \"{CHANNEL}\"\n\n[limits]\nframe_bytes = 65536\nrecord_bytes = 1048576\nreply_timeout_ms = 5000\nflow_steps = 8\nrequest_bytes = 65536\n",
            server.address
        ),
    )?;
    let mut host = Host::open(Ticket::spawn(&seat)?, true)?;

    let listed = host.tool("list", &json!({}))?;
    assert_eq!(listed["isError"], false, "{listed}");
    let stations = listed["structuredContent"]["stations"].to_string();
    assert!(stations.contains(SUBJECT), "{listed}");

    // The lifecycle services are Lys templates; an open exchange has none and
    // says so by name (measured 17 Oct 11:11), rather than answering an empty list.
    let services = host.tool("services", &json!({}))?;
    assert_eq!(services["isError"], true, "{services}");
    assert!(
        services["structuredContent"]["error"]
            .as_str()
            .is_some_and(|error| error
                .contains("lifecycle_unavailable: no [[auth.lys.templates]] are configured")),
        "{services}"
    );

    let answer = host.tool(
        "call",
        &json!({ "subject": SUBJECT, "input": { "text": "alone" } }),
    )?;
    assert_eq!(answer["isError"], false, "{answer}");
    assert_eq!(
        answer["structuredContent"]["echoed"]["text"], "alone",
        "{answer}"
    );

    let (stderr, code) = host.ticket.close()?;
    assert_eq!(code, 0, "{stderr}");

    let log = server.log()?;
    assert!(log.contains("liminal server listener bound"), "{log}");
    assert!(log.contains("mode=open"), "{log}");
    assert!(!log.contains("mode=gated"), "{log}");

    let names = names_under(dir.path())?;
    for word in ["key", "pass", "token"] {
        assert!(
            !names
                .iter()
                .any(|name| name.to_ascii_lowercase().contains(word)),
            "a file named with {word} under the open directory: {names:?}"
        );
    }
    Ok(())
}

#[test]
fn access010_an_open_exchange_off_loopback_refuses_to_start_by_name() -> Result<(), Box<dyn Error>>
{
    let dir = tempfile::tempdir()?;
    // A started server is the failure, told by its ready endpoint: the test
    // never waits on a child that may run forever.
    let refusal = match OpenServer::start_on(dir.path(), "0.0.0.0") {
        Ok(server) => {
            return Err(format!("an open exchange off loopback started: {}", server.log()?).into());
        }
        Err(refusal) => refusal.to_string(),
    };
    assert!(
        refusal.contains("the open exchange did not start (exit status: 1)"),
        "{refusal}"
    );
    assert!(
        refusal.contains("open mode (no [auth]): listen_address 0.0.0.0:")
            && refusal.contains("is not loopback; an exchange with no [auth] listens on loopback only, give [auth] to listen elsewhere"),
        "{refusal}"
    );
    Ok(())
}