polyc-a2a 2026.9.0

polychrome A2A edge: serves a domain-signed Agent Card and drives message/send tasks onto a turn.
//! Layered configuration for the A2A edge: defaults → optional TOML →
//! `POLYCHROME_` env → CLI flags. Mirrors the other edges.
//!
//! The signing key is provisioned from a seed for this foundation slice (the
//! same convention `polyc_crypto`'s signers use for dev/test); wiring the
//! deployment principal to a secret store is a follow-up — see the crate docs.

use std::{net::SocketAddr, path::PathBuf};

use clap::Parser;
use figment::{
    Figment,
    providers::{Env, Format, Serialized, Toml},
};
use polyc_rpc_client::Sensitive;
use serde::{Deserialize, Serialize};

/// Command-line surface for the A2A edge.
#[derive(Debug, Parser)]
#[command(name = "polychrome-a2a", version, about = "polychrome A2A edge")]
pub struct Cli {
    /// Optional TOML config file, layered beneath env vars and flags.
    #[arg(long)]
    pub config: Option<PathBuf>,

    /// Address for the A2A HTTP server (overrides config/env).
    #[arg(long)]
    pub bind: Option<SocketAddr>,

    /// Address for the health/metrics side-server (overrides config/env).
    #[arg(long)]
    pub side_addr: Option<SocketAddr>,

    /// `AgentService` address (e.g. `http://polychrome-control-plane:8080`).
    #[arg(long)]
    pub agent_addr: Option<String>,
}

/// Resolved runtime configuration for the A2A edge.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
    /// Bind address for the A2A HTTP server. Dual-stack by default.
    pub bind: SocketAddr,
    /// Bind address for the health/metrics side-server. Dual-stack by default.
    pub side_addr: SocketAddr,
    /// Address of the polychrome control plane's `AgentService`.
    pub agent_addr: String,
    /// The agent name advertised on the Agent Card.
    pub agent_name: String,
    /// The agent description advertised on the Agent Card.
    pub agent_description: String,
    /// The absolute public URL of this agent's A2A endpoint, advertised as the
    /// card's `url`. Peers POST JSON-RPC here.
    pub public_url: String,
    /// Seed for the deployment principal's ed25519 signing key.
    ///
    /// Foundation-only: production provisions this key from a secret store
    /// rather than a seed (the follow-up noted in the crate docs).
    ///
    /// Wrapped in [`Sensitive`] (#1611) — this seed IS the private key
    /// material `Signer::from_seed` derives an ed25519 key from, so neither a
    /// derived `Debug` nor a serialized dump of this `Config`'s own fields
    /// can ever print it; read it back only via [`Sensitive::expose`] at the
    /// `Signer::from_seed` call site. `Sensitive` has no `Serialize` impl, so
    /// this required field carries its own `#[serde(default = ...)]` (see
    /// [`Self::peer_credentials`]) to stand in for the value the figment defaults
    /// layer can no longer serialize.
    #[serde(default = "default_signing_seed", skip_serializing)]
    pub signing_seed: Sensitive<u64>,
    /// Bounds how many turns this edge dials concurrently (`#795`). A burst
    /// beyond this bound sheds a `failed` task with
    /// [`polyc_proto::admission_shed_text`] instead of dialing an
    /// already-loaded agent. The same default every edge's
    /// `max_concurrent_turns` takes.
    pub max_concurrent_turns: usize,
    /// Whitespace-separated `peer-id=token` credentials accepted by the A2A
    /// JSON-RPC endpoint. Empty means unconfigured and fails every request
    /// closed. A token identifies exactly one stable peer.
    ///
    /// Wrapped in [`Sensitive`] (#1611) so neither a derived
    /// `Debug` nor a serialized dump of this `Config` can ever print the raw
    /// bearer; read it back only via [`Sensitive::expose`] at the
    /// authentication site. `Sensitive` has no `Serialize` impl, so this
    /// required field carries its own `#[serde(default = ...)]` (an empty
    /// bearer) to stand in for the value the figment defaults layer can no
    /// longer serialize.
    #[serde(default = "empty_peer_credentials", skip_serializing)]
    pub peer_credentials: Sensitive<String>,
    /// This edge's identity string, asserted on every signed envelope. Shared
    /// env name (unprefixed by `A2A_`) so it cascades identically across
    /// edges: `POLYCHROME_EDGE_ID`. Unrelated to `signing_seed` (Agent-Card
    /// signing) and `peer_credentials` (inbound JSON-RPC gate) above — this is
    /// the OUTBOUND control-plane edge credential.
    #[serde(default)]
    pub edge_id: Option<String>,
    /// Transport bearer (`pc_<edge_id>_<secret>`) for the control plane's
    /// internal listener. Env: `POLYCHROME_EDGE_BEARER_KEY`.
    ///
    /// Wrapped in [`Sensitive`] (peer review on #1514) so neither a derived
    /// `Debug` nor a serialized dump of this `Config` can ever print the raw
    /// bearer; read it back only via [`Sensitive::expose`] at the dial site.
    #[serde(default, skip_serializing)]
    pub edge_bearer_key: Option<Sensitive<String>>,
    /// Hex-encoded 32-byte ed25519 private key used to sign this edge's
    /// attribution envelopes. Env: `POLYCHROME_EDGE_SIGNING_KEY_HEX`.
    ///
    /// Wrapped in [`Sensitive`] (peer review on #1514) — see
    /// [`Self::edge_bearer_key`].
    #[serde(default, skip_serializing)]
    pub edge_signing_key_hex: Option<Sensitive<String>>,
}

/// `serde(default = ...)` fallback for [`Config::peer_credentials`]: an empty,
/// unconfigured bearer. `Sensitive<T>` deliberately has no `Default` impl (a
/// blanket one would invite a silent "empty secret" fallback anywhere a
/// `Sensitive` is required), so this field spells its default out explicitly
/// instead.
const fn empty_peer_credentials() -> Sensitive<String> {
    Sensitive::new(String::new())
}

/// `serde(default = ...)` fallback for [`Config::signing_seed`]: the same `1`
/// [`Config::default`] always used. `Sensitive<T>` deliberately has no
/// `Default` impl (see [`empty_peer_credentials`]), so this field spells its
/// default out explicitly instead.
const fn default_signing_seed() -> Sensitive<u64> {
    Sensitive::new(1)
}

impl Default for Config {
    fn default() -> Self {
        Self {
            bind: SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 8080)),
            side_addr: SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 9090)),
            agent_addr: String::new(),
            agent_name: "Polychrome".to_owned(),
            agent_description: "A polychrome agent reachable over A2A.".to_owned(),
            public_url: "http://localhost:8080/".to_owned(),
            signing_seed: default_signing_seed(),
            max_concurrent_turns: 64,
            peer_credentials: empty_peer_credentials(),
            edge_id: None,
            edge_bearer_key: None,
            edge_signing_key_hex: None,
        }
    }
}

/// Build the effective [`Config`] from the layered sources:
/// defaults → optional TOML → `POLYCHROME_A2A_` env → shared `POLYCHROME_` env
/// → CLI flags.
///
/// # Errors
///
/// Returns a boxed [`figment::Error`] if a source is malformed.
pub fn load(cli: &Cli) -> Result<Config, Box<figment::Error>> {
    let mut fig = Figment::from(Serialized::defaults(Config::default()));
    if let Some(path) = &cli.config {
        fig = fig.merge(Toml::file(path));
    }
    fig = fig.merge(Env::prefixed("POLYCHROME_A2A_"));
    // `agent_addr` and the edge-credential fields use the shared
    // `POLYCHROME_`-prefixed names (no `A2A_`) so they cascade identically
    // across edges.
    fig = fig.merge(Env::prefixed("POLYCHROME_").only(&[
        "AGENT_ADDR",
        "EDGE_ID",
        "EDGE_BEARER_KEY",
        "EDGE_SIGNING_KEY_HEX",
    ]));
    if let Some(addr) = cli.bind {
        fig = fig.merge(Serialized::default("bind", addr));
    }
    if let Some(addr) = cli.side_addr {
        fig = fig.merge(Serialized::default("side_addr", addr));
    }
    if let Some(addr) = &cli.agent_addr {
        fig = fig.merge(Serialized::default("agent_addr", addr));
    }
    fig.extract().map_err(Box::new)
}

#[cfg(test)]
#[allow(
    clippy::pedantic,
    clippy::nursery,
    clippy::result_large_err,
    missing_docs
)]
mod tests {
    use super::*;

    fn cli() -> Cli {
        Cli {
            config: None,
            bind: None,
            side_addr: None,
            agent_addr: None,
        }
    }

    #[test]
    fn defaults_apply_when_env_empty() {
        figment::Jail::expect_with(|_jail| {
            let cfg = load(&cli()).map_err(|e| *e)?;
            assert_eq!(cfg.bind, SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 8080)));
            assert!(cfg.agent_addr.is_empty());
            assert_eq!(cfg.agent_name, "Polychrome");
            assert_eq!(*cfg.signing_seed.expose(), 1);
            assert!(cfg.peer_credentials.expose().is_empty());
            Ok(())
        });
    }

    #[test]
    fn env_overrides_win() {
        figment::Jail::expect_with(|jail| {
            jail.set_env("POLYCHROME_A2A_BIND", "127.0.0.1:7100");
            jail.set_env("POLYCHROME_A2A_PUBLIC_URL", "https://agent.example/");
            jail.set_env("POLYCHROME_A2A_AGENT_NAME", "Edge One");
            jail.set_env("POLYCHROME_A2A_SIGNING_SEED", "42");
            jail.set_env("POLYCHROME_A2A_PEER_CREDENTIALS", "weather=s3cr3t");
            jail.set_env("POLYCHROME_AGENT_ADDR", "http://agent:8080");

            let cfg = load(&cli()).map_err(|e| *e)?;
            assert_eq!(cfg.bind, SocketAddr::from(([127, 0, 0, 1], 7100)));
            assert_eq!(cfg.public_url, "https://agent.example/");
            assert_eq!(cfg.agent_name, "Edge One");
            assert_eq!(*cfg.signing_seed.expose(), 42);
            assert_eq!(cfg.agent_addr, "http://agent:8080");
            assert_eq!(cfg.peer_credentials.expose(), &"weather=s3cr3t".to_owned());
            Ok(())
        });
    }

    #[test]
    fn shared_edge_credential_env_names_load_unprefixed() {
        figment::Jail::expect_with(|jail| {
            jail.set_env("POLYCHROME_EDGE_ID", "a2a");
            jail.set_env("POLYCHROME_EDGE_BEARER_KEY", "pc_a2a_secret");
            jail.set_env("POLYCHROME_EDGE_SIGNING_KEY_HEX", "ab12");

            let cfg = load(&cli()).map_err(|e| *e)?;
            assert_eq!(cfg.edge_id.as_deref(), Some("a2a"));
            assert_eq!(
                cfg.edge_bearer_key.as_ref().map(Sensitive::expose),
                Some(&"pc_a2a_secret".to_owned())
            );
            assert_eq!(
                cfg.edge_signing_key_hex.as_ref().map(Sensitive::expose),
                Some(&"ab12".to_owned())
            );
            Ok(())
        });
    }

    #[test]
    fn cli_flag_beats_env_for_bind() {
        figment::Jail::expect_with(|jail| {
            jail.set_env("POLYCHROME_A2A_BIND", "127.0.0.1:7100");
            let cli = Cli {
                config: None,
                bind: Some(SocketAddr::from(([10, 0, 0, 1], 9999))),
                side_addr: None,
                agent_addr: None,
            };
            let cfg = load(&cli).map_err(|e| *e)?;
            assert_eq!(cfg.bind, SocketAddr::from(([10, 0, 0, 1], 9999)));
            Ok(())
        });
    }

    /// Invariant (#1611): a `Config` holding the inbound JSON-RPC bearer and
    /// the ed25519 signing seed never prints either raw value via `Debug` —
    /// proven against the actual `Config` field types, not just the
    /// `Sensitive` wrapper in isolation. `env_overrides_win` above already
    /// covers the bearer token's env round trip, so this test only proves
    /// redaction.
    #[test]
    fn config_debug_never_prints_peer_credentials() {
        let raw_token = "weather=s3cr3t-bearer-value";
        let raw_seed = 424_242_424_242_u64;
        let cfg = Config {
            peer_credentials: Sensitive::new(raw_token.to_owned()),
            signing_seed: Sensitive::new(raw_seed),
            ..Config::default()
        };

        polyc_rpc_client::assert_redacted(&cfg, &[raw_token, &raw_seed.to_string()]);
    }
}