aion-integration-acp 0.31.0

The first-party Agent Client Protocol (ACP) adapter for Aion: implements the aion-integrations AgentHarness trait against any ACP agent subprocess over newline-delimited JSON-RPC 2.0 stdio.
Documentation
//! The harness catalogue: which ACP agents this build knows how to launch.
//!
//! # Why this is DATA and not configuration
//!
//! There is no standard ACP launch flag — the table in [`crate::harness`] is the
//! whole reason a launch command was configuration in the first place — but the
//! line for each PARTICULAR agent is not a guess: it is that agent's own
//! documented ACP invocation, and it is the same line on every machine. Making
//! an operator type it was therefore never a way of learning something the
//! product did not know; it was a way of making a fresh install unusable until
//! somebody read the adapter's documentation.
//!
//! So the lines ship here, as data, and an operator picks a harness by NAME. A
//! `[assistant]` section is optional and carries no command, no arguments and no
//! paths.
//!
//! # What is a venue fact, and what is not
//!
//! The launch line is a fact about the AGENT: it does not vary by machine, so it
//! is a constant. Whether the launch line's PROGRAM can be run is a fact about
//! THIS machine at THIS moment — `npx` is there or it is not, `opencode` is on
//! the operator's `PATH` or it is not — so it is measured, never remembered:
//! [`CatalogueHarness::available_in`] takes the `PATH` it is asked about, and
//! [`CatalogueHarness::available`] reads the process's own. A cached
//! availability would be a claim about a machine as it was at boot.
//!
//! # The install hint is the catalogue's own sentence
//!
//! An entry whose program does not resolve is not a failure to hide: it is one
//! sentence saying what to install. The sentence lives beside the launch line it
//! is about, so the surface that offers a harness and the surface that explains
//! why it cannot be offered read the same source.

use std::path::{Path, PathBuf};

/// One agent this build knows how to launch over ACP.
///
/// Every field is a constant: the entries are compiled in, so a catalogue read
/// allocates nothing and cannot be edited into an arbitrary command by anything
/// a caller sends.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CatalogueHarness {
    /// The stable id an operator picks and a session record stores.
    ///
    /// Short, lowercase and never renamed: it is written into durable session
    /// records, so changing one would orphan every conversation that named it.
    pub id: &'static str,
    /// What a surface shows a human.
    pub display_name: &'static str,
    /// The program the server execs — resolved on `PATH` unless it is a path.
    pub program: &'static str,
    /// The arguments passed to it, verbatim, in order.
    pub args: &'static [&'static str],
    /// One sentence naming what to install when [`Self::program`] does not
    /// resolve.
    pub install_hint: &'static str,
}

impl CatalogueHarness {
    /// The exact line the server runs, for display.
    ///
    /// Built from the same two fields the spawn uses, so what an operator is
    /// shown cannot drift from what is executed. Arguments are joined with
    /// single spaces: none of the catalogue's arguments contains one, and this
    /// is a rendering rather than a shell command anybody should paste blind.
    #[must_use]
    pub fn launch(&self) -> String {
        if self.args.is_empty() {
            return self.program.to_owned();
        }
        format!("{} {}", self.program, self.args.join(" "))
    }

    /// Whether [`Self::program`] resolves to something executable under `path`
    /// — the `PATH` value a spawn would use.
    ///
    /// `None` means the launching context carries no `PATH` at all, under which
    /// a bare program name resolves to nothing. A program written WITH a
    /// separator is not looked up: it is checked where it says it is, which is
    /// the same rule `exec` follows.
    #[must_use]
    pub fn available_in(&self, path: Option<&str>) -> bool {
        program_resolves(Path::new(self.program), path)
    }

    /// Whether [`Self::program`] resolves on THIS process's `PATH`, measured now.
    ///
    /// Never cached: an operator who installs Node.js and reloads the console
    /// must see the harness become available without restarting the server.
    #[must_use]
    pub fn available(&self) -> bool {
        self.available_in(
            std::env::var_os(PATH_VARIABLE)
                .as_ref()
                .and_then(|value| value.to_str()),
        )
    }
}

/// The variable a program named without a separator is looked up on.
const PATH_VARIABLE: &str = "PATH";

/// Every ACP agent this build can launch, in the order a surface offers them.
///
/// Claude Code first because it is the harness Aion's own assistant is written
/// against; the rest in the order the adapter's own launch table lists them.
/// Each line is that agent's documented ACP invocation, verbatim — see the table
/// in [`crate::harness`], which is the one place these strings are explained.
pub const CATALOGUE: &[CatalogueHarness] = &[
    CatalogueHarness {
        id: "claude-code",
        display_name: "Claude Code",
        program: "npx",
        args: &["@agentclientprotocol/claude-agent-acp"],
        install_hint: "Install Node.js so `npx` is on the server's PATH; the ACP adapter itself is \
                       fetched by npx on first use. Log Claude Code in on the server host (`claude \
                       login`) — nothing here can enter a credential.",
    },
    CatalogueHarness {
        id: "codex",
        display_name: "Codex",
        program: "npx",
        args: &["@agentclientprotocol/codex-acp"],
        install_hint: "Install Node.js so `npx` is on the server's PATH; the ACP adapter itself is \
                       fetched by npx on first use. Log Codex in on the server host — nothing here \
                       can enter a credential.",
    },
    CatalogueHarness {
        id: "gemini-cli",
        display_name: "Gemini CLI",
        program: "npx",
        args: &["@google/gemini-cli", "--acp"],
        install_hint: "Install Node.js so `npx` is on the server's PATH; the Gemini CLI itself is \
                       fetched by npx on first use. Log it in on the server host — nothing here \
                       can enter a credential.",
    },
    CatalogueHarness {
        id: "opencode",
        display_name: "OpenCode",
        program: "opencode",
        args: &["acp"],
        install_hint: "Install OpenCode so `opencode` is on the server's PATH \
                       (https://opencode.ai), then log it in on the server host — nothing here can \
                       enter a credential.",
    },
    CatalogueHarness {
        id: "pi",
        display_name: "Pi",
        program: "npx",
        args: &["-y", "pi-acp"],
        install_hint: "Install Node.js so `npx` is on the server's PATH; the `pi-acp` adapter is \
                       fetched by npx on first use and drives the `pi` coding agent, which must be \
                       installed on the server host at 0.80.4 or newer and logged in there — nothing \
                       here can enter a credential. The adapter accepts MCP servers without wiring \
                       them into pi, so the Aion tools do not reach this harness.",
    },
];

/// The catalogue entry with this id, or [`None`].
///
/// The ONE lookup: a name that is not here names no harness, and every surface
/// that has to refuse an unknown name refuses it through the same miss.
#[must_use]
pub fn harness(id: &str) -> Option<&'static CatalogueHarness> {
    harness_in(CATALOGUE, id)
}

/// The same lookup over a stated catalogue, for a registry that was built over
/// one — the shipped [`CATALOGUE`] in the server, a fixture's in a test — so the
/// miss reads the same whichever catalogue answers.
#[must_use]
pub fn harness_in(
    catalogue: &'static [CatalogueHarness],
    id: &str,
) -> Option<&'static CatalogueHarness> {
    catalogue.iter().find(|entry| entry.id == id)
}

/// Every catalogue id, comma-separated, for a refusal that has to say what the
/// server does offer.
#[must_use]
pub fn ids() -> String {
    ids_of(CATALOGUE)
}

/// Every id of a stated catalogue, comma-separated, for the same refusal from a
/// registry built over that catalogue.
#[must_use]
pub fn ids_of(catalogue: &'static [CatalogueHarness]) -> String {
    catalogue
        .iter()
        .map(|entry| entry.id)
        .collect::<Vec<_>>()
        .join(", ")
}

/// Whether `program` names something this machine could exec under `path`.
fn program_resolves(program: &Path, path: Option<&str>) -> bool {
    if program.components().count() > 1 || program.is_absolute() {
        return is_executable_file(program);
    }
    let Some(path) = path else {
        return false;
    };
    path.split(PATH_SEPARATOR)
        // An empty entry in PATH means the current directory, which is not a
        // place a server may resolve an agent binary from: whatever happens to
        // be in the process's working directory is not something an operator
        // chose. Skipped rather than searched.
        .filter(|directory| !directory.is_empty())
        .any(|directory| is_executable_file(&PathBuf::from(directory).join(program)))
}

/// The character `PATH` entries are separated by on this platform.
#[cfg(windows)]
const PATH_SEPARATOR: char = ';';
/// The character `PATH` entries are separated by on this platform.
#[cfg(not(windows))]
const PATH_SEPARATOR: char = ':';

/// Whether `candidate` is a file this process could execute.
///
/// On Unix that is the executable bit, for anybody: the server may be running as
/// a user whose class cannot be determined without a `stat` of its own uid and
/// gid, and a file with no execute bit at all is the case worth catching — it is
/// the one that produces `Permission denied` at spawn with no explanation.
#[cfg(unix)]
fn is_executable_file(candidate: &Path) -> bool {
    use std::os::unix::fs::PermissionsExt;

    std::fs::metadata(candidate)
        .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}

/// Whether `candidate` is a file this process could execute.
#[cfg(not(unix))]
fn is_executable_file(candidate: &Path) -> bool {
    std::fs::metadata(candidate).is_ok_and(|metadata| metadata.is_file())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_entry_renders_the_line_the_adapters_own_table_documents() {
        let rendered: Vec<String> = CATALOGUE.iter().map(CatalogueHarness::launch).collect();
        assert_eq!(
            rendered,
            vec![
                "npx @agentclientprotocol/claude-agent-acp".to_owned(),
                "npx @agentclientprotocol/codex-acp".to_owned(),
                "npx @google/gemini-cli --acp".to_owned(),
                "opencode acp".to_owned(),
                "npx -y pi-acp".to_owned(),
            ],
            "the catalogue's launch lines are the harness table's own invocations, verbatim"
        );
    }

    #[test]
    fn every_entry_carries_an_id_a_name_and_a_hint_and_no_two_share_an_id() {
        let mut seen: Vec<&str> = Vec::new();
        for entry in CATALOGUE {
            assert!(!entry.id.is_empty(), "a catalogue entry has no id");
            assert!(
                !entry.display_name.is_empty(),
                "catalogue entry `{}` has no display name",
                entry.id
            );
            assert!(
                !entry.install_hint.is_empty(),
                "catalogue entry `{}` has no install hint, so a surface could offer it, refuse \
                 it, and say nothing about what to install",
                entry.id
            );
            assert!(
                !seen.contains(&entry.id),
                "catalogue id `{}` is declared twice; a session record names a harness by id",
                entry.id
            );
            seen.push(entry.id);
        }
        assert!(
            CATALOGUE.len() >= 5,
            "the catalogue ships Claude Code, Codex, Gemini CLI, OpenCode and Pi"
        );
    }

    #[test]
    fn a_lookup_answers_by_id_and_a_name_nobody_ships_is_a_miss() {
        assert_eq!(
            harness("claude-code").map(|entry| entry.program),
            Some("npx")
        );
        assert!(
            harness("claude").is_none(),
            "a near-miss id must not resolve: a session would record a harness this build cannot \
             launch"
        );
        assert!(
            ids().contains("claude-code"),
            "the refusal names what is offered"
        );
    }

    #[test]
    fn availability_is_measured_against_the_path_it_is_asked_about() -> Result<(), std::io::Error> {
        let directory = tempfile::tempdir()?;
        let program = directory.path().join("opencode");
        std::fs::write(&program, b"#!/bin/sh\nexit 0\n")?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755))?;
        }
        let entry = harness("opencode").ok_or_else(|| {
            std::io::Error::other("the catalogue must carry the opencode entry this pin is about")
        })?;
        let planted = directory.path().to_string_lossy().into_owned();
        assert!(
            entry.available_in(Some(&planted)),
            "the positive control: the program is on the PATH that was asked about"
        );
        // The same entry, the same machine, an EMPTY PATH: availability is a
        // reading of the launching context, not a property remembered about the
        // harness.
        assert!(
            !entry.available_in(Some("")),
            "an empty PATH resolves nothing"
        );
        assert!(
            !entry.available_in(None),
            "a launching context with no PATH at all resolves no bare program name"
        );
        Ok(())
    }

    #[test]
    fn a_file_without_the_execute_bit_does_not_count_as_available() -> Result<(), std::io::Error> {
        let directory = tempfile::tempdir()?;
        std::fs::write(directory.path().join("opencode"), b"not executable\n")?;
        let entry = harness("opencode").ok_or_else(|| {
            std::io::Error::other("the catalogue must carry the opencode entry this pin is about")
        })?;
        let planted = directory.path().to_string_lossy().into_owned();
        #[cfg(unix)]
        assert!(
            !entry.available_in(Some(&planted)),
            "a file that cannot be executed is not a harness that can be launched; reporting it \
             available would move the failure to the spawn, where the operator gets `Permission \
             denied` and no hint"
        );
        #[cfg(not(unix))]
        let _ = planted;
        Ok(())
    }
}