pub(crate) mod app;
pub(crate) mod paths;
pub(crate) mod store;
use std::collections::HashMap;
use std::env;
use std::path::Path;
use uuid::Uuid;
use crate::domain::errors::{AgentError, AgentResult, ErrorCode};
const RETIRED_BROWSER_TRANSPORT_ENV_KEYS: &[&str] = &[
"PC_AGENT_NETWORK_MODE",
"PC_AGENT_LOCAL_PORT",
"PC_AGENT_UI_BIND_ADDR",
];
#[cfg(test)]
pub(crate) fn environment_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[derive(Clone, PartialEq, Eq)]
pub struct Config {
pub backend_url: Option<String>,
pub agent_token: Option<String>,
pub host_id: String,
pub default_command: Vec<String>,
pub allowed_commands: Vec<String>,
pub allowed_workdirs: Vec<String>,
pub heartbeat_seconds: u64,
pub reconnect_max_seconds: u64,
pub output_buffer_bytes: usize,
pub pi_command: Vec<String>,
pub pi_session_dir: String,
pub pi_global_skills_dir: String,
pub pi_rpc_timeout_seconds: u64,
pub skills_provider: String,
pub skills_api_base_url: String,
pub skills_api_token: Option<String>,
pub home_dir: Option<String>,
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field(
"backend_url",
&self.backend_url.as_ref().map(|_| "<configured>"),
)
.field(
"agent_token",
&self.agent_token.as_ref().map(|_| "<redacted>"),
)
.field("host_id", &self.host_id)
.field("default_command", &self.default_command)
.field("allowed_commands", &self.allowed_commands)
.field("allowed_workdirs", &self.allowed_workdirs)
.field("heartbeat_seconds", &self.heartbeat_seconds)
.field("reconnect_max_seconds", &self.reconnect_max_seconds)
.field("output_buffer_bytes", &self.output_buffer_bytes)
.field("pi_command", &self.pi_command)
.field("pi_session_dir", &self.pi_session_dir)
.field("pi_global_skills_dir", &self.pi_global_skills_dir)
.field("pi_rpc_timeout_seconds", &self.pi_rpc_timeout_seconds)
.field("skills_provider", &self.skills_provider)
.field("skills_api_base_url", &self.skills_api_base_url)
.field(
"skills_api_token",
&self.skills_api_token.as_ref().map(|_| "<redacted>"),
)
.field("home_dir", &self.home_dir)
.finish()
}
}
impl Config {
pub fn from_env() -> AgentResult<Self> {
let mut pairs = Vec::new();
push_env(&mut pairs, "PC_AGENT_NETWORK_MODE", false)?;
push_env(&mut pairs, "PC_AGENT_LOCAL_PORT", false)?;
push_env(&mut pairs, "PC_AGENT_UI_BIND_ADDR", false)?;
push_env(&mut pairs, "PC_AGENT_BACKEND_URL", false)?;
push_env(&mut pairs, "PC_AGENT_TOKEN", false)?;
push_env(&mut pairs, "PC_AGENT_HOST_ID", false)?;
push_env(&mut pairs, "PC_AGENT_DEFAULT_COMMAND", false)?;
push_env(&mut pairs, "PC_AGENT_ALLOWED_COMMANDS", false)?;
push_env(&mut pairs, "PC_AGENT_ALLOWED_WORKDIRS", false)?;
push_env(&mut pairs, "PC_AGENT_HEARTBEAT_SECONDS", false)?;
push_env(&mut pairs, "PC_AGENT_RECONNECT_MAX_SECONDS", false)?;
push_env(&mut pairs, "PC_AGENT_OUTPUT_BUFFER_BYTES", false)?;
push_env(&mut pairs, "PC_AGENT_PI_COMMAND", false)?;
push_env(&mut pairs, "PC_AGENT_PI_SESSION_DIR", false)?;
push_env(&mut pairs, "PC_AGENT_PI_GLOBAL_SKILLS_DIR", false)?;
push_env(&mut pairs, "PC_AGENT_PI_RPC_TIMEOUT_SECONDS", false)?;
push_env(&mut pairs, "PC_AGENT_SKILLS_PROVIDER", false)?;
push_env(&mut pairs, "PC_AGENT_SKILLS_API_BASE_URL", false)?;
push_env(&mut pairs, "PC_AGENT_SKILLS_API_TOKEN", false)?;
push_env(&mut pairs, "PC_AGENT_HOME_DIR", false)?;
Self::from_pairs(pairs)
}
pub fn from_pairs<I, K, V>(pairs: I) -> AgentResult<Self>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let home = env::var("HOME").ok();
Self::from_pairs_with_home(pairs, home.as_deref())
}
pub(crate) fn from_pairs_with_home<I, K, V>(
pairs: I,
environment_home: Option<&str>,
) -> AgentResult<Self>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let values: HashMap<String, String> = pairs
.into_iter()
.map(|(key, value)| (key.into(), value.into()))
.collect();
for key in RETIRED_BROWSER_TRANSPORT_ENV_KEYS {
if values.contains_key(*key) {
return Err(AgentError::new(
ErrorCode::InvalidMessage,
format!("{key} is no longer supported; the browser transport is Iroh-only."),
));
}
}
let terminal_command = values
.get("PC_AGENT_DEFAULT_COMMAND")
.map(|value| value.trim().to_string())
.unwrap_or_else(default_terminal_command);
let pi_global_skills_dir = resolve_pi_global_skills_path(
optional_non_empty(&values, "PC_AGENT_PI_GLOBAL_SKILLS_DIR")?,
environment_home,
)?;
let default_pi_global_skills_dir = usable_home(environment_home)
.map(|home| resolve_pi_global_skills_path(None, Some(home)))
.transpose()?;
let pi_command = effective_pi_command(
parse_command(
values
.get("PC_AGENT_PI_COMMAND")
.map(|value| value.trim())
.unwrap_or("pi"),
"PC_AGENT_PI_COMMAND",
)?,
Path::new(&pi_global_skills_dir),
default_pi_global_skills_dir.as_deref().map(Path::new),
);
let backend_url = optional_non_empty(&values, "PC_AGENT_BACKEND_URL")?;
let agent_token = optional_non_empty(&values, "PC_AGENT_TOKEN")?;
if backend_url.is_some() != agent_token.is_some() {
return Err(AgentError::new(
ErrorCode::InvalidMessage,
"PC_AGENT_BACKEND_URL and PC_AGENT_TOKEN must be configured together",
));
}
Ok(Self {
backend_url,
agent_token,
host_id: match trimmed_value(&values, "PC_AGENT_HOST_ID") {
Some(value) if !value.is_empty() => value,
Some(_) => {
return Err(AgentError::new(
ErrorCode::InvalidMessage,
"PC_AGENT_HOST_ID must not be empty",
));
}
None => format!("host_{}", Uuid::new_v4()),
},
default_command: parse_command(&terminal_command, "PC_AGENT_DEFAULT_COMMAND")?,
allowed_commands: parse_allowlist(values.get("PC_AGENT_ALLOWED_COMMANDS")),
allowed_workdirs: parse_allowlist(values.get("PC_AGENT_ALLOWED_WORKDIRS")),
heartbeat_seconds: parse_number(
values.get("PC_AGENT_HEARTBEAT_SECONDS"),
"15",
"PC_AGENT_HEARTBEAT_SECONDS",
true,
)?,
reconnect_max_seconds: parse_number(
values.get("PC_AGENT_RECONNECT_MAX_SECONDS"),
"30",
"PC_AGENT_RECONNECT_MAX_SECONDS",
false,
)?,
output_buffer_bytes: parse_number(
values.get("PC_AGENT_OUTPUT_BUFFER_BYTES"),
"1048576",
"PC_AGENT_OUTPUT_BUFFER_BYTES",
false,
)?,
pi_command,
pi_session_dir: resolve_pi_path(
optional_non_empty(&values, "PC_AGENT_PI_SESSION_DIR")?,
environment_home,
"PC_AGENT_PI_SESSION_DIR",
".pi/agent/sessions",
)?,
pi_global_skills_dir,
pi_rpc_timeout_seconds: parse_number(
values.get("PC_AGENT_PI_RPC_TIMEOUT_SECONDS"),
"15",
"PC_AGENT_PI_RPC_TIMEOUT_SECONDS",
true,
)?,
skills_provider: optional_non_empty(&values, "PC_AGENT_SKILLS_PROVIDER")?
.unwrap_or_else(|| "skillspool".into()),
skills_api_base_url: optional_non_empty(&values, "PC_AGENT_SKILLS_API_BASE_URL")?
.unwrap_or_else(|| "https://skillspool.org".into()),
skills_api_token: optional_non_empty(&values, "PC_AGENT_SKILLS_API_TOKEN")?,
home_dir: optional_non_empty(&values, "PC_AGENT_HOME_DIR")?
.map(|p| expand_tilde(&p))
.or_else(|| usable_home(environment_home).map(str::to_string)),
})
}
pub(crate) fn legacy_credentials(&self) -> AgentResult<Option<(&str, &str)>> {
match (self.backend_url.as_deref(), self.agent_token.as_deref()) {
(Some(backend_url), Some(agent_token)) => Ok(Some((backend_url, agent_token))),
(None, None) => Ok(None),
_ => Err(AgentError::new(
ErrorCode::InvalidMessage,
"PC_AGENT_BACKEND_URL and PC_AGENT_TOKEN must be configured together",
)),
}
}
}
pub(crate) fn effective_pi_command(
mut command: Vec<String>,
configured_global_skill_root: &Path,
default_global_skill_root: Option<&Path>,
) -> Vec<String> {
if default_global_skill_root == Some(configured_global_skill_root) {
return command;
}
let configured_root = configured_global_skill_root.to_string_lossy();
let already_configured = command
.windows(2)
.any(|arguments| arguments[0] == "--skill" && arguments[1] == configured_root.as_ref());
if !already_configured {
command.push("--skill".to_string());
command.push(configured_root.into_owned());
}
command
}
fn expand_tilde(path: &str) -> String {
if path.starts_with("~/") {
if let Ok(home) = env::var("HOME") {
return path.replacen('~', &home, 1);
}
} else if path == "~"
&& let Ok(home) = env::var("HOME")
{
return home;
}
path.to_string()
}
fn default_terminal_command() -> String {
env::var("SHELL")
.ok()
.filter(|shell| !shell.trim().is_empty())
.unwrap_or_else(|| "/bin/sh".to_string())
}
fn resolve_pi_path(
configured: Option<String>,
environment_home: Option<&str>,
key: &str,
default_relative_path: &str,
) -> AgentResult<String> {
match configured {
Some(path) if path.starts_with("~/") || path == "~" => {
let home = require_home(environment_home, key)?;
Ok(path.replacen('~', home, 1))
}
Some(path) => Ok(path),
None => {
let home = require_home(environment_home, key)?;
Ok(Path::new(home)
.join(default_relative_path)
.to_string_lossy()
.into_owned())
}
}
}
fn resolve_pi_global_skills_path(
configured: Option<String>,
environment_home: Option<&str>,
) -> AgentResult<String> {
let resolved = resolve_pi_path(
configured,
environment_home,
"PC_AGENT_PI_GLOBAL_SKILLS_DIR",
".pi/agent/skills",
)?;
if Path::new(&resolved).is_absolute() {
return Ok(resolved);
}
let cwd = env::current_dir().map_err(|_| {
AgentError::new(
ErrorCode::InvalidMessage,
"failed to resolve PC_AGENT_PI_GLOBAL_SKILLS_DIR relative to the agent cwd",
)
})?;
Ok(cwd.join(resolved).to_string_lossy().into_owned())
}
fn require_home<'a>(environment_home: Option<&'a str>, key: &str) -> AgentResult<&'a str> {
usable_home(environment_home).ok_or_else(|| {
AgentError::new(
ErrorCode::InvalidMessage,
format!("HOME must be set and non-empty when {key} is omitted or starts with ~/"),
)
})
}
fn usable_home(environment_home: Option<&str>) -> Option<&str> {
environment_home
.map(str::trim)
.filter(|home| !home.is_empty())
}
fn push_env(pairs: &mut Vec<(String, String)>, key: &str, required: bool) -> AgentResult<()> {
match env::var(key) {
Ok(value) => {
pairs.push((key.to_string(), value));
Ok(())
}
Err(env::VarError::NotPresent) if required => Err(AgentError::new(
ErrorCode::InvalidMessage,
format!("{key} is required and must not be empty"),
)),
Err(env::VarError::NotPresent) => Ok(()),
Err(env::VarError::NotUnicode(_)) => Err(AgentError::new(
ErrorCode::InvalidMessage,
format!("{key} contains non-UTF-8 data"),
)),
}
}
fn trimmed_value(values: &HashMap<String, String>, key: &str) -> Option<String> {
values.get(key).map(|value| value.trim().to_string())
}
fn parse_command(value: &str, key: &str) -> AgentResult<Vec<String>> {
let parts: Vec<String> = value
.split_ascii_whitespace()
.map(|part| part.to_string())
.collect();
if parts.is_empty() {
return Err(AgentError::new(
ErrorCode::InvalidMessage,
format!("{key} must contain at least one command"),
));
}
Ok(parts)
}
fn parse_allowlist(value: Option<&String>) -> Vec<String> {
value
.into_iter()
.flat_map(|value| value.split(','))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn optional_non_empty(values: &HashMap<String, String>, key: &str) -> AgentResult<Option<String>> {
match trimmed_value(values, key) {
Some(value) if !value.is_empty() => Ok(Some(value)),
Some(_) => Err(AgentError::new(
ErrorCode::InvalidMessage,
format!("{key} must not be empty"),
)),
None => Ok(None),
}
}
fn parse_number<T>(
value: Option<&String>,
default: &str,
key: &str,
reject_zero: bool,
) -> AgentResult<T>
where
T: std::str::FromStr + PartialEq + From<u8>,
T::Err: std::fmt::Display,
{
let value = match value {
Some(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AgentError::new(
ErrorCode::InvalidMessage,
format!("{key} must not be empty"),
));
}
trimmed
}
None => default,
};
let parsed = value.parse::<T>().map_err(|err| {
AgentError::new(
ErrorCode::InvalidMessage,
format!("{key} must be a valid number: {err}"),
)
})?;
if reject_zero && parsed == T::from(0u8) {
return Err(AgentError::new(
ErrorCode::InvalidMessage,
format!("{key} must be greater than zero"),
));
}
Ok(parsed)
}