use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::domain::errors::{AgentError, AgentResult, ErrorCode};
pub(crate) const CURRENT_SCHEMA_VERSION: u32 = 3;
pub(crate) const DEFAULT_FRONTEND_ORIGIN: &str = "https://regy.one";
const RETIRED_TUNNEL_COMMAND: &str = concat!("PC_AGENT_CLOU", "DFLARED_COMMAND");
const RETIRED_BROWSER_TRANSPORT_ENV_KEYS: &[&str] = &[
"PC_AGENT_NETWORK_MODE",
"PC_AGENT_LOCAL_PORT",
"PC_AGENT_UI_BIND_ADDR",
];
pub(crate) enum IrohRelayConfig {
N0Preset,
Explicit(Vec<iroh::RelayUrl>),
}
impl IrohRelayConfig {
pub(crate) fn from_urls(urls: &[String]) -> AgentResult<Self> {
if urls.is_empty() {
return Ok(Self::N0Preset);
}
let relays = urls
.iter()
.map(|value| {
let url = Url::parse(value).map_err(|_| invalid_relay_url())?;
if url.scheme() != "https"
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(invalid_relay_url());
}
value
.parse::<iroh::RelayUrl>()
.map_err(|_| invalid_relay_url())
})
.collect::<AgentResult<Vec<_>>>()?;
Ok(Self::Explicit(relays))
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ProviderId {
OpenaiCodex,
ClaudeCode,
Antigravity,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ToolConfig {
pub node_command: Option<Vec<String>>,
pub pi_command: Option<Vec<String>>,
pub claude_command: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ServerConfig {
pub default_command: Option<Vec<String>>,
pub allowed_commands: Vec<String>,
pub allowed_workdirs: Vec<String>,
pub iroh_relay_urls: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct StoredConfig {
pub schema_version: u32,
pub frontend_origin: Option<String>,
pub selected_providers: Vec<ProviderId>,
pub tools: ToolConfig,
pub server: ServerConfig,
}
impl Default for StoredConfig {
fn default() -> Self {
Self {
schema_version: CURRENT_SCHEMA_VERSION,
frontend_origin: Some(DEFAULT_FRONTEND_ORIGIN.to_owned()),
selected_providers: Vec::new(),
tools: ToolConfig::default(),
server: ServerConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ConfigLayer {
pub schema_version: Option<u32>,
pub frontend_origin: Option<String>,
pub selected_providers: Option<Vec<ProviderId>>,
pub tools: Option<ToolConfigLayer>,
pub server: Option<ServerConfigLayer>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ToolConfigLayer {
pub node_command: Option<Vec<String>>,
pub pi_command: Option<Vec<String>>,
pub claude_command: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct ServerConfigLayer {
pub default_command: Option<Vec<String>>,
pub allowed_commands: Option<Vec<String>>,
pub allowed_workdirs: Option<Vec<String>>,
pub iroh_relay_urls: Option<Vec<String>>,
}
impl ConfigLayer {
pub(crate) fn from_env() -> AgentResult<Self> {
const KEYS: &[&str] = &[
"PC_AGENT_NETWORK_MODE",
"PC_AGENT_FRONTEND_ORIGIN",
"PC_AGENT_SELECTED_PROVIDERS",
"PC_AGENT_NODE_COMMAND",
"PC_AGENT_PI_COMMAND",
"PC_AGENT_CLAUDE_COMMAND",
RETIRED_TUNNEL_COMMAND,
"PC_AGENT_IROH_RELAY_URLS",
"PC_AGENT_LOCAL_PORT",
"PC_AGENT_UI_BIND_ADDR",
"PC_AGENT_DEFAULT_COMMAND",
"PC_AGENT_ALLOWED_COMMANDS",
"PC_AGENT_ALLOWED_WORKDIRS",
];
let mut pairs = Vec::new();
for key in KEYS {
match std::env::var(key) {
Ok(value) => pairs.push((*key, value)),
Err(std::env::VarError::NotPresent) => {}
Err(std::env::VarError::NotUnicode(_)) => {
return Err(invalid(format!("{key} must contain valid UTF-8")));
}
}
}
Self::from_pairs(pairs)
}
pub(crate) fn from_pairs<I, K, V>(pairs: I) -> AgentResult<Self>
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let values = pairs
.into_iter()
.map(|(key, value)| (key.into(), value.into()))
.collect::<HashMap<_, _>>();
if values.contains_key(RETIRED_TUNNEL_COMMAND) {
return Err(invalid(format!(
"{RETIRED_TUNNEL_COMMAND} is no longer supported; public mode uses Iroh."
)));
}
for key in RETIRED_BROWSER_TRANSPORT_ENV_KEYS {
if values.contains_key(*key) {
return Err(retired_browser_transport_environment_setting(key));
}
}
let frontend_origin = optional_trimmed(&values, "PC_AGENT_FRONTEND_ORIGIN")?;
let selected_providers = values
.get("PC_AGENT_SELECTED_PROVIDERS")
.map(|value| parse_providers(value))
.transpose()?;
let tools = ToolConfigLayer {
node_command: optional_command(&values, "PC_AGENT_NODE_COMMAND")?,
pi_command: optional_command(&values, "PC_AGENT_PI_COMMAND")?,
claude_command: optional_command(&values, "PC_AGENT_CLAUDE_COMMAND")?,
};
let server = ServerConfigLayer {
default_command: optional_command(&values, "PC_AGENT_DEFAULT_COMMAND")?,
allowed_commands: optional_allowlist(&values, "PC_AGENT_ALLOWED_COMMANDS")?,
allowed_workdirs: optional_allowlist(&values, "PC_AGENT_ALLOWED_WORKDIRS")?,
iroh_relay_urls: optional_relay_urls(&values, "PC_AGENT_IROH_RELAY_URLS")?,
};
Ok(Self {
frontend_origin,
selected_providers,
tools: (tools != ToolConfigLayer::default()).then_some(tools),
server: (server != ServerConfigLayer::default()).then_some(server),
..Self::default()
})
}
}
pub(crate) fn resolve_layers(
mut stored: StoredConfig,
environment: ConfigLayer,
cli: ConfigLayer,
) -> AgentResult<StoredConfig> {
apply_layer(&mut stored, environment);
apply_layer(&mut stored, cli);
canonicalize_config(&mut stored)?;
validate_config(&stored)?;
Ok(stored)
}
pub(super) fn validate_config(config: &StoredConfig) -> AgentResult<()> {
if config.schema_version != CURRENT_SCHEMA_VERSION {
return Err(invalid(format!(
"unsupported configuration schema version {} (expected {CURRENT_SCHEMA_VERSION})",
config.schema_version,
)));
}
for (name, command) in [
("tools.node_command", &config.tools.node_command),
("tools.pi_command", &config.tools.pi_command),
("tools.claude_command", &config.tools.claude_command),
("server.default_command", &config.server.default_command),
] {
validate_command(name, command)?;
}
if let Some(origin) = &config.frontend_origin {
validate_frontend_origin(origin)?;
}
for command in &config.server.allowed_commands {
if command.trim() != command
|| command.is_empty()
|| command.chars().any(char::is_whitespace)
{
return Err(invalid(
"server.allowed_commands entries must be non-empty executable names or paths",
));
}
}
for workdir in &config.server.allowed_workdirs {
if workdir.trim() != workdir || !Path::new(workdir).is_absolute() {
return Err(invalid(
"server.allowed_workdirs entries must be non-empty absolute paths",
));
}
}
IrohRelayConfig::from_urls(&config.server.iroh_relay_urls)?;
Ok(())
}
pub(super) fn canonicalize_config(config: &mut StoredConfig) -> AgentResult<()> {
config.server.iroh_relay_urls = canonicalize_relay_urls(&config.server.iroh_relay_urls)?;
Ok(())
}
fn canonicalize_relay_urls(urls: &[String]) -> AgentResult<Vec<String>> {
match IrohRelayConfig::from_urls(urls)? {
IrohRelayConfig::N0Preset => Ok(Vec::new()),
IrohRelayConfig::Explicit(relays) => {
Ok(relays.into_iter().map(|relay| relay.to_string()).collect())
}
}
}
fn apply_layer(config: &mut StoredConfig, layer: ConfigLayer) {
if let Some(value) = layer.schema_version {
config.schema_version = value;
}
if let Some(value) = layer.frontend_origin {
config.frontend_origin = Some(value);
}
if let Some(value) = layer.selected_providers {
config.selected_providers = value;
}
if let Some(layer) = layer.tools {
if let Some(value) = layer.node_command {
config.tools.node_command = Some(value);
}
if let Some(value) = layer.pi_command {
config.tools.pi_command = Some(value);
}
if let Some(value) = layer.claude_command {
config.tools.claude_command = Some(value);
}
}
if let Some(layer) = layer.server {
if let Some(value) = layer.default_command {
config.server.default_command = Some(value);
}
if let Some(value) = layer.allowed_commands {
config.server.allowed_commands = value;
}
if let Some(value) = layer.allowed_workdirs {
config.server.allowed_workdirs = value;
}
if let Some(value) = layer.iroh_relay_urls {
config.server.iroh_relay_urls = value;
}
}
}
fn validate_command(name: &str, command: &Option<Vec<String>>) -> AgentResult<()> {
let Some(command) = command else {
return Ok(());
};
if command.is_empty() || command.iter().any(|argument| argument.is_empty()) {
return Err(invalid(format!(
"{name} must contain a non-empty command vector"
)));
}
if !Path::new(&command[0]).is_absolute() {
return Err(invalid(format!(
"{name} must start with an absolute executable path"
)));
}
Ok(())
}
pub(crate) fn validate_frontend_origin(origin: &str) -> AgentResult<()> {
let url = Url::parse(origin).map_err(|_| invalid("frontend_origin must be a valid origin"))?;
let is_exact_origin = url.origin().ascii_serialization() == origin
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none();
if url.scheme() != "https" || url.host_str().is_none() || !is_exact_origin {
return Err(invalid("frontend_origin must be a bare HTTPS origin"));
}
Ok(())
}
fn optional_command(
values: &HashMap<String, String>,
key: &str,
) -> AgentResult<Option<Vec<String>>> {
values
.get(key)
.map(|value| {
let command = value
.split_ascii_whitespace()
.map(str::to_string)
.collect::<Vec<_>>();
if command.is_empty() {
Err(invalid(format!("{key} must not be empty")))
} else {
Ok(command)
}
})
.transpose()
}
fn optional_allowlist(
values: &HashMap<String, String>,
key: &str,
) -> AgentResult<Option<Vec<String>>> {
Ok(values.get(key).map(|value| {
value
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect()
}))
}
fn optional_relay_urls(
values: &HashMap<String, String>,
key: &str,
) -> AgentResult<Option<Vec<String>>> {
values
.get(key)
.map(|value| {
let urls = value
.split(',')
.map(str::trim)
.map(str::to_owned)
.collect::<Vec<_>>();
if urls.iter().any(String::is_empty) {
return Err(invalid(format!(
"{key} must not contain empty relay URL entries"
)));
}
canonicalize_relay_urls(&urls)
})
.transpose()
}
fn optional_trimmed(values: &HashMap<String, String>, key: &str) -> AgentResult<Option<String>> {
values
.get(key)
.map(|value| {
let value = value.trim();
if value.is_empty() {
Err(invalid(format!("{key} must not be empty")))
} else {
Ok(value.to_string())
}
})
.transpose()
}
fn parse_providers(value: &str) -> AgentResult<Vec<ProviderId>> {
value
.split(',')
.map(str::trim)
.map(|provider| match provider {
"openai-codex" => Ok(ProviderId::OpenaiCodex),
"claude-code" => Ok(ProviderId::ClaudeCode),
"antigravity" => Ok(ProviderId::Antigravity),
_ => Err(invalid(format!(
"PC_AGENT_SELECTED_PROVIDERS contains unknown provider {provider:?}"
))),
})
.collect()
}
fn invalid(message: impl Into<String>) -> AgentError {
AgentError::new(ErrorCode::InvalidMessage, message)
}
fn retired_browser_transport_environment_setting(key: &str) -> AgentError {
invalid(format!(
"{key} is no longer supported; the browser transport is Iroh-only."
))
}
fn invalid_relay_url() -> AgentError {
invalid("iroh relay URLs must be credential-free HTTPS origins")
}