use anyhow::{Context, Result};
use elisym_core::{AgentNodeBuilder, SolanaNetwork, SolanaPaymentConfig, SolanaPaymentProvider};
use nostr_sdk::ToBech32;
use serde::{Deserialize, Serialize};
use solana_sdk::signature::Signer as _;
use zeroize::Zeroize;
use crate::crypto;
#[derive(Deserialize)]
pub(crate) struct AgentConfig {
pub(crate) name: String,
pub(crate) description: String,
#[serde(default)]
pub(crate) capabilities: Vec<String>,
#[serde(default)]
pub(crate) relays: Vec<String>,
#[serde(default)]
pub(crate) secret_key: String,
#[serde(default)]
pub(crate) payment: Option<PaymentSection>,
#[serde(default)]
pub(crate) encryption: Option<crypto::EncryptionSection>,
}
impl std::fmt::Debug for AgentConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentConfig")
.field("name", &self.name)
.field("secret_key", &"[REDACTED]")
.field("encryption", &self.encryption.is_some())
.finish()
}
}
impl Drop for AgentConfig {
fn drop(&mut self) {
self.secret_key.zeroize();
if let Some(ref mut p) = self.payment {
p.solana_secret_key.zeroize();
}
}
}
#[derive(Deserialize)]
pub(crate) struct PaymentSection {
#[serde(default = "default_chain")]
pub(crate) chain: String,
#[serde(default = "default_network")]
pub(crate) network: String,
#[serde(default)]
pub(crate) rpc_url: Option<String>,
#[serde(default)]
pub(crate) solana_secret_key: String,
#[serde(default = "default_job_price")]
#[allow(dead_code)]
pub(crate) job_price: u64,
#[serde(default = "default_payment_timeout")]
#[allow(dead_code)]
pub(crate) payment_timeout_secs: u32,
}
impl std::fmt::Debug for PaymentSection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PaymentSection")
.field("chain", &self.chain)
.field("network", &self.network)
.field("solana_secret_key", &"[REDACTED]")
.finish()
}
}
pub(crate) fn default_chain() -> String {
"solana".into()
}
pub(crate) fn default_network() -> String {
"devnet".into()
}
pub(crate) fn default_job_price() -> u64 {
10_000_000
}
pub(crate) fn default_payment_timeout() -> u32 {
120
}
pub(crate) fn validate_agent_name(name: &str) -> Result<()> {
anyhow::ensure!(
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
"Invalid agent name: '{name}'. Only [a-zA-Z0-9_-] allowed, max 64 chars."
);
Ok(())
}
pub(crate) fn load_agent_config(name: &str) -> Result<AgentConfig> {
validate_agent_name(name)?;
let home = dirs::home_dir().context("Cannot find home directory")?;
let path = home
.join(".elisym")
.join("agents")
.join(name)
.join("config.toml");
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if let Ok(meta) = std::fs::metadata(&path) {
let mode = meta.mode();
if mode & 0o077 != 0 {
tracing::warn!(
path = %path.display(),
mode = format!("{mode:04o}"),
"Agent config file has insecure permissions (contains secret keys). \
Consider: chmod 600 {}",
path.display()
);
}
}
}
let mut contents = std::fs::read_to_string(&path)
.with_context(|| format!("Agent '{}' not found at {}", name, path.display()))?;
let config_result: Result<AgentConfig, _> = toml::from_str(&contents);
contents.zeroize();
let mut config: AgentConfig =
config_result.with_context(|| format!("Invalid config for agent '{}'", name))?;
if let Some(ref enc) = config.encryption {
let mut password = std::env::var("ELISYM_AGENT_PASSWORD").with_context(|| {
format!(
"Agent '{}' has encrypted secrets. Set ELISYM_AGENT_PASSWORD env var to decrypt.",
name
)
})?;
let result = crypto::decrypt_secrets(enc, &password);
password.zeroize();
let bundle = result
.with_context(|| format!("Failed to decrypt secrets for agent '{}'", name))?;
config.secret_key = bundle.nostr_secret_key.clone();
if let Some(ref mut payment) = config.payment {
payment.solana_secret_key = bundle.solana_secret_key.clone();
}
tracing::info!("Decrypted agent secrets");
}
Ok(config)
}
pub(crate) fn builder_from_config(config: &AgentConfig) -> AgentNodeBuilder {
let mut b = AgentNodeBuilder::new(&config.name, &config.description)
.capabilities(config.capabilities.clone())
.secret_key(&config.secret_key);
if !config.relays.is_empty() {
b = b.relays(config.relays.clone());
}
if let Some(ref payment) = config.payment {
if let Some(provider) = build_solana_provider(payment) {
b = b.solana_payment_provider(provider);
}
}
b
}
pub(crate) fn build_solana_provider(payment: &PaymentSection) -> Option<SolanaPaymentProvider> {
if payment.chain != "solana" || payment.solana_secret_key.is_empty() {
return None;
}
let network = match payment.network.as_str() {
"mainnet" => SolanaNetwork::Mainnet,
"testnet" => SolanaNetwork::Testnet,
"devnet" => SolanaNetwork::Devnet,
custom => SolanaNetwork::Custom(custom.to_string()),
};
let config = SolanaPaymentConfig {
network,
rpc_url: payment.rpc_url.clone(),
};
match SolanaPaymentProvider::from_secret_key(config, &payment.solana_secret_key) {
Ok(provider) => {
tracing::info!(address = %provider.address(), "Solana wallet configured");
Some(provider)
}
Err(e) => {
tracing::warn!("Failed to initialize Solana wallet: {e}");
None
}
}
}
pub(crate) fn run_init(
name: &str,
description: Option<&str>,
capabilities: Option<&str>,
password: Option<&str>,
network: &str,
quiet: bool,
) -> Result<()> {
validate_agent_name(name)?;
let home = dirs::home_dir().context("Cannot find home directory")?;
let agent_dir = home.join(".elisym").join("agents").join(name);
let config_path = agent_dir.join("config.toml");
let keys = nostr_sdk::Keys::generate();
let secret_hex = keys.secret_key().to_secret_hex();
let npub = keys.public_key().to_bech32().unwrap_or_default();
let sol_keypair = solana_sdk::signature::Keypair::new();
let sol_secret_b58 = bs58::encode(sol_keypair.to_bytes()).into_string();
let sol_address = sol_keypair.pubkey().to_string();
let desc = description.unwrap_or("Elisym MCP agent");
let caps: Vec<&str> = capabilities
.unwrap_or("mcp-gateway")
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
#[derive(Serialize)]
struct InitConfig {
name: String,
description: String,
capabilities: Vec<String>,
relays: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
secret_key: Option<String>,
payment: InitPayment,
#[serde(skip_serializing_if = "Option::is_none")]
encryption: Option<InitEncryption>,
}
#[derive(Serialize)]
struct InitPayment {
chain: String,
network: String,
#[serde(skip_serializing_if = "Option::is_none")]
solana_secret_key: Option<String>,
}
#[derive(Serialize)]
struct InitEncryption {
ciphertext: String,
salt: String,
nonce: String,
}
let caps_vec: Vec<String> = caps.iter().map(|c| c.to_string()).collect();
let relays = vec![
"wss://relay.damus.io".into(),
"wss://nos.lol".into(),
"wss://relay.nostr.band".into(),
];
let (secret_key_field, sol_key_field, encryption_field, encrypted) = if let Some(pw) = password {
let bundle = crypto::SecretsBundle {
nostr_secret_key: secret_hex,
solana_secret_key: sol_secret_b58,
llm_api_key: String::new(),
customer_llm_api_key: None,
};
let enc = crypto::encrypt_secrets(&bundle, pw)
.context("Failed to encrypt secrets")?;
(None, None, Some(InitEncryption {
ciphertext: enc.ciphertext,
salt: enc.salt,
nonce: enc.nonce,
}), true)
} else {
(Some(secret_hex), Some(sol_secret_b58), None, false)
};
let init_config = InitConfig {
name: name.to_string(),
description: desc.to_string(),
capabilities: caps_vec,
relays,
secret_key: secret_key_field,
payment: InitPayment {
chain: "solana".into(),
network: network.to_string(),
solana_secret_key: sol_key_field,
},
encryption: encryption_field,
};
let mut config_content = toml::to_string_pretty(&init_config)
.context("Failed to serialize config")?;
if let Some(mut sk) = init_config.secret_key {
sk.zeroize();
}
if let Some(mut sk) = init_config.payment.solana_secret_key {
sk.zeroize();
}
std::fs::create_dir_all(&agent_dir)
.with_context(|| format!("Cannot create {}", agent_dir.display()))?;
{
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_path)
.with_context(|| format!("Agent '{}' already exists at {}", name, config_path.display()))?;
file.write_all(config_content.as_bytes())
.with_context(|| format!("Cannot write {}", config_path.display()))?;
}
config_content.zeroize();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("Cannot set permissions on {}", config_path.display()))?;
}
if quiet {
tracing::info!(
agent = name,
npub = %npub,
solana = %sol_address,
config = %config_path.display(),
encrypted,
"Agent created"
);
} else {
println!("Agent '{}' created.", name);
println!(" npub: {}", npub);
println!(" solana: {} ({network})", sol_address);
println!(" config: {}", config_path.display());
if encrypted {
println!(" encrypted: yes (AES-256-GCM + Argon2id)");
}
println!();
println!("To use with MCP:");
if encrypted {
println!(" elisym-mcp install --agent {name} --password <password>");
} else {
println!(" elisym-mcp install --agent {name}");
}
println!(" # or: ELISYM_AGENT={name} elisym-mcp");
}
Ok(())
}