mod logging;
mod types;
pub(crate) use types::*;
use std::collections::HashSet;
use std::env;
use std::fs::{self, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Component, Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant};
use axum::http::{HeaderMap, HeaderValue};
use nemo_relay::logging::LoggingConfig;
use nemo_relay::plugin::dynamic::{
DYNAMIC_PLUGIN_MANIFEST_FILENAME, DynamicPluginManifest, DynamicPluginManifestLoad,
};
use nemo_relay::plugin::{
PluginError, deduplicate_plugin_config_paths, merge_plugin_config_documents,
};
use ring::rand::{SecureRandom, SystemRandom};
use ring::{digest, hmac};
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::error::CliError;
use crate::filesystem::{LockAttempt, try_lock_exclusive, try_lock_shared};
#[cfg(test)]
use crate::plugins::lifecycle::active_dynamic_plugin_components;
use crate::plugins::lifecycle::{
ActiveDynamicPluginComponent, active_dynamic_plugin_components_for_identity,
dynamic_plugin_runtime_closure_digest, enforce_required_dynamic_plugin_startup,
};
use crate::plugins::policy::DynamicPluginHostPolicy;
use crate::process::RunOverrides;
use crate::server::GatewayOverrides;
pub(crate) const BOOTSTRAP_FINGERPRINT_ENV: &str = "NEMO_RELAY_BOOTSTRAP_FINGERPRINT";
pub(crate) const PLUGIN_IDLE_TIMEOUT_ENV: &str = "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS";
pub(crate) const RELAY_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay-local";
pub(crate) const RELAY_SOURCE_PLUGIN_ID: &str = "nemo-relay-plugin@nemo-relay";
pub(crate) const DEFAULT_MAX_HOOK_PAYLOAD_BYTES: usize = 20 * 1024 * 1024;
pub(crate) const DEFAULT_MAX_PASSTHROUGH_BODY_BYTES: usize = 100 * 1024 * 1024;
pub(crate) const GATEWAY_URL_ENV: &str = "NEMO_RELAY_GATEWAY_URL";
pub(crate) const TRANSPARENT_RUN_ENV: &str = "NEMO_RELAY_TRANSPARENT_RUN";
#[derive(Debug, Clone, Default, Deserialize)]
struct FileConfig {
gateway: Option<FileGatewayConfig>,
upstream: Option<FileUpstreamConfig>,
agents: Option<FileAgentsConfig>,
logging: Option<logging::FileLoggingConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileGatewayConfig {
max_hook_payload_bytes: Option<usize>,
max_passthrough_body_bytes: Option<usize>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileUpstreamConfig {
openai_base_url: Option<String>,
openai_auth_header: Option<String>,
anthropic_base_url: Option<String>,
anthropic_auth_header: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileAgentsConfig {
claude: Option<FileAgentCommandConfig>,
codex: Option<FileAgentCommandConfig>,
hermes: Option<FileAgentCommandConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileAgentCommandConfig {
command: Option<String>,
hooks_path: Option<PathBuf>,
}
pub(crate) fn resolve_server_config(args: &GatewayOverrides) -> Result<ResolvedConfig, CliError> {
let mut resolved = load_shared_config(args.config.as_ref(), args.plugin_config_path.as_ref())?;
apply_server_overrides(&mut resolved.gateway, args)?;
let explicit_plugin_config =
explicit_plugin_config_path(args.config.as_ref(), args.plugin_config_path.as_ref());
enforce_required_dynamic_plugin_startup(explicit_plugin_config.as_ref(), &resolved)?;
log::info!(
target: "nemo_relay.configuration",
event = "configuration_resolved",
mode = "server",
dynamic_plugin_count = resolved.dynamic_plugins.len();
"Runtime configuration resolved"
);
Ok(resolved)
}
pub(crate) fn resolve_logging_config(
explicit: Option<&Path>,
user_only: bool,
) -> Result<LoggingConfig, CliError> {
let explicit = explicit.map(Path::to_path_buf);
let user_only = user_only || user_config_scope();
let mut merged = toml::Value::Table(toml::map::Map::new());
for path in config_paths_scoped(explicit.as_ref(), user_only) {
let required = explicit.as_ref() == Some(&path);
let Some(raw) = read_config_file(&path, required, "configuration")? else {
continue;
};
let parsed = raw
.parse::<toml::Table>()
.map(toml::Value::Table)
.map_err(|error| {
CliError::Config(format!("invalid TOML in {}: {error}", path.display()))
})?;
merge_gateway_config_toml(&mut merged, parsed);
}
if merged.get("logging").is_none() {
return Ok(LoggingConfig::default());
}
let document = toml::to_string(&merged).map_err(|error| {
CliError::Config(format!("failed to resolve logging configuration: {error}"))
})?;
LoggingConfig::from_toml_document(&document).map_err(|error| match error {
nemo_relay::error::FlowError::InvalidArgument(message) => CliError::Config(message),
other => CliError::Flow(other),
})
}
pub(crate) fn resolve_persistent_server_config(
args: &GatewayOverrides,
) -> Result<ResolvedConfig, CliError> {
if args.config.is_some() || args.plugin_config_path.is_some() || args.ready_file.is_some() {
return Err(CliError::Config(
"nemo-relay mcp uses system and user configuration only; use `nemo-relay run` for explicit or project configuration"
.into(),
));
}
let mut resolved = load_shared_config_scoped(None, None, true)?;
apply_server_overrides(&mut resolved.gateway, args)?;
let active_dynamic_plugins = active_dynamic_plugin_components_for_identity(None, &resolved)?;
resolved.bootstrap_fingerprint = Some(persistent_bootstrap_fingerprint(
&resolved,
&active_dynamic_plugins,
)?);
Ok(resolved)
}
#[derive(Debug, Clone)]
pub(crate) struct ManagedBootstrapIdentity {
expected: String,
persistent_args: GatewayOverrides,
resolved: ResolvedConfig,
active_dynamic_plugins: Vec<ActiveDynamicPluginComponent>,
}
impl ManagedBootstrapIdentity {
pub(crate) fn fingerprint(&self) -> &str {
&self.expected
}
pub(crate) fn verify_current(&self) -> Result<(), CliError> {
let snapshot_actual =
persistent_bootstrap_fingerprint(&self.resolved, &self.active_dynamic_plugins)?;
verify_managed_bootstrap_fingerprint(&self.expected, &snapshot_actual)?;
let resolved = resolve_persistent_server_config(&self.persistent_args)?;
let actual = resolved
.bootstrap_fingerprint
.expect("persistent gateway resolution sets a bootstrap fingerprint");
verify_managed_bootstrap_fingerprint(&self.expected, &actual)
}
}
pub(crate) fn managed_bootstrap_identity(
args: &GatewayOverrides,
resolved: &ResolvedConfig,
active_dynamic_plugins: &[ActiveDynamicPluginComponent],
) -> Result<Option<ManagedBootstrapIdentity>, CliError> {
if args.ready_file.is_none() {
return Ok(None);
}
let Some(expected) = env::var(BOOTSTRAP_FINGERPRINT_ENV)
.ok()
.filter(|fingerprint| !fingerprint.is_empty())
else {
return Err(CliError::Config(format!(
"{BOOTSTRAP_FINGERPRINT_ENV} must be set and non-empty when a managed readiness file is requested"
)));
};
let actual = persistent_bootstrap_fingerprint(resolved, active_dynamic_plugins)?;
verify_managed_bootstrap_fingerprint(&expected, &actual)?;
let mut persistent_args = args.clone();
persistent_args.ready_file = None;
Ok(Some(ManagedBootstrapIdentity {
expected,
persistent_args,
resolved: resolved.clone(),
active_dynamic_plugins: active_dynamic_plugins.to_vec(),
}))
}
fn verify_managed_bootstrap_fingerprint(expected: &str, actual: &str) -> Result<(), CliError> {
if actual == expected {
return Ok(());
}
Err(CliError::Config(
"persistent gateway identity changed during managed bootstrap; retry so the parent can resolve the current configuration"
.into(),
))
}
fn persistent_bootstrap_fingerprint(
resolved: &ResolvedConfig,
active_dynamic_plugins: &[ActiveDynamicPluginComponent],
) -> Result<String, CliError> {
let dynamic_plugins = active_dynamic_plugins
.iter()
.map(dynamic_plugin_bootstrap_identity)
.collect::<Result<Vec<_>, _>>()?;
let gateway = &resolved.gateway;
let idle_timeout_secs = crate::bootstrap::plugin_idle_timeout()
.map_err(CliError::Config)?
.as_secs();
let document = serde_json::json!({
"bootstrap_protocol": crate::bootstrap::BOOTSTRAP_PROTOCOL_VERSION,
"relay_version": env!("CARGO_PKG_VERSION"),
"openai_base_url": gateway.openai_base_url,
"openai_auth_header": gateway.openai_auth_header,
"anthropic_base_url": gateway.anthropic_base_url,
"anthropic_auth_header": gateway.anthropic_auth_header,
"metadata": gateway.metadata,
"plugin_config": gateway.plugin_config,
"max_hook_payload_bytes": gateway.max_hook_payload_bytes,
"max_passthrough_body_bytes": gateway.max_passthrough_body_bytes,
"plugin_idle_timeout_secs": idle_timeout_secs,
"dynamic_plugins": dynamic_plugins,
"dynamic_plugin_policy": format!("{:?}", resolved.dynamic_plugin_policy),
});
let key = load_or_create_bootstrap_hmac_key()?;
let key = hmac::Key::new(hmac::HMAC_SHA256, &key);
let mut digest = hmac::Context::with_key(&key);
digest.update(
&serde_json::to_vec(&document).expect("persistent gateway fingerprint serializes to JSON"),
);
let environment = env::vars_os().filter_map(|(name, _)| name.into_string().ok());
for name in crate::mcp_environment::forwarded_names(environment, gateway.plugin_config.as_ref())
{
if name == PLUGIN_IDLE_TIMEOUT_ENV {
continue;
}
digest.update(&[0]);
digest.update(name.as_bytes());
digest.update(&[0]);
if let Some(value) = env::var_os(&name) {
digest.update(value.to_string_lossy().as_bytes());
}
}
let tag = digest.sign();
Ok(format!(
"hmac-sha256:{}",
tag.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
))
}
fn dynamic_plugin_bootstrap_identity(
plugin: &ActiveDynamicPluginComponent,
) -> Result<Value, CliError> {
let manifest_identity = match (&plugin.activation_snapshot, plugin.manifest_ref.as_deref()) {
(Some(snapshot), _) => Some(dynamic_plugin_snapshot_identity(snapshot)?),
(None, Some(manifest_ref)) => Some(dynamic_plugin_manifest_identity(
manifest_ref,
plugin.environment_ref.as_deref(),
)?),
(None, None) => None,
};
Ok(serde_json::json!({
"plugin_id": plugin.plugin_id,
"kind": format!("{:?}", plugin.kind),
"lifecycle_generation": plugin.lifecycle_generation,
"manifest": manifest_identity,
"environment_ref": plugin.environment_ref,
"config": plugin.config,
}))
}
fn dynamic_plugin_snapshot_identity(
snapshot: &crate::plugins::lifecycle::DynamicPluginActivationSnapshot,
) -> Result<Value, CliError> {
let (manifest, _) = load_bounded_dynamic_plugin_manifest(snapshot.identity_manifest())?;
let manifest_path = PathBuf::from(snapshot.original_manifest_ref());
let manifest_digest = bootstrap_file_digest(
snapshot.identity_manifest(),
"dynamic plugin manifest snapshot",
)?;
let artifact_ref = manifest
.source
.as_ref()
.and_then(|source| source.artifact.as_deref())
.or(match &manifest.load {
DynamicPluginManifestLoad::RustDynamic(load) => load.library.as_deref(),
DynamicPluginManifestLoad::Worker(_) => None,
});
let artifact = artifact_ref
.map(|artifact_ref| {
let logical_path = resolve_dynamic_plugin_relative_path(&manifest_path, artifact_ref);
let snapshot_path = snapshot.identity_file(&logical_path).ok_or_else(|| {
CliError::Config(format!(
"dynamic plugin activation snapshot is missing artifact {}",
logical_path.display()
))
})?;
bootstrap_file_digest(snapshot_path, "dynamic plugin artifact snapshot")
.map(|digest| serde_json::json!({ "path": logical_path, "sha256": digest }))
})
.transpose()?;
let signature = manifest
.integrity
.as_ref()
.and_then(|integrity| integrity.signature.as_deref())
.map(|signature_ref| {
let logical_path = resolve_dynamic_plugin_relative_path(&manifest_path, signature_ref);
let snapshot_path = snapshot.identity_file(&logical_path).ok_or_else(|| {
CliError::Config(format!(
"dynamic plugin activation snapshot is missing signature {}",
logical_path.display()
))
})?;
bootstrap_file_digest(snapshot_path, "dynamic plugin signature snapshot")
.map(|digest| serde_json::json!({ "path": logical_path, "sha256": digest }))
})
.transpose()?;
Ok(serde_json::json!({
"path": snapshot.original_manifest_ref(),
"sha256": manifest_digest,
"artifact": artifact,
"signature": signature,
"runtime_closure_sha256": snapshot.closure_digest(),
}))
}
fn dynamic_plugin_manifest_identity(
manifest_ref: &str,
environment_ref: Option<&str>,
) -> Result<Value, CliError> {
let (manifest, normalized_ref) = load_bounded_dynamic_plugin_manifest(manifest_ref)?;
let manifest_path = PathBuf::from(&normalized_ref);
let manifest_digest = bootstrap_file_digest(&manifest_path, "dynamic plugin manifest")?;
let artifact_ref = manifest
.source
.as_ref()
.and_then(|source| source.artifact.as_deref())
.or(match &manifest.load {
DynamicPluginManifestLoad::RustDynamic(load) => load.library.as_deref(),
DynamicPluginManifestLoad::Worker(_) => None,
});
let artifact = artifact_ref
.map(|artifact_ref| {
let path = resolve_dynamic_plugin_relative_path(&manifest_path, artifact_ref);
bootstrap_file_digest(&path, "dynamic plugin artifact")
.map(|digest| serde_json::json!({ "path": path, "sha256": digest }))
})
.transpose()?;
let signature = manifest
.integrity
.as_ref()
.and_then(|integrity| integrity.signature.as_deref())
.map(|signature_ref| {
let path = resolve_dynamic_plugin_relative_path(&manifest_path, signature_ref);
bootstrap_file_digest(&path, "dynamic plugin signature")
.map(|digest| serde_json::json!({ "path": path, "sha256": digest }))
})
.transpose()?;
let closure_digest = dynamic_plugin_runtime_closure_digest(&normalized_ref, environment_ref)?;
Ok(serde_json::json!({
"path": normalized_ref,
"sha256": manifest_digest,
"artifact": artifact,
"signature": signature,
"runtime_closure_sha256": closure_digest,
}))
}
fn resolve_dynamic_plugin_relative_path(manifest_path: &Path, reference: &str) -> PathBuf {
let path = PathBuf::from(reference);
if path.is_absolute() {
path
} else {
manifest_path
.parent()
.map(|parent| parent.join(&path))
.unwrap_or(path)
}
}
fn bootstrap_file_digest(path: &Path, description: &str) -> Result<String, CliError> {
let mut context = digest::Context::new(&digest::SHA256);
crate::filesystem::bounded::stream_bounded_regular_file(path, description, |bytes| {
context.update(bytes)
})
.map_err(CliError::Config)?;
Ok(context
.finish()
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect())
}
pub(crate) fn load_bounded_dynamic_plugin_manifest(
path: impl AsRef<Path>,
) -> Result<(DynamicPluginManifest, String), CliError> {
let (manifest, normalized, _) = load_bounded_dynamic_plugin_manifest_bytes(path)?;
Ok((manifest, normalized))
}
pub(crate) fn load_bounded_dynamic_plugin_manifest_bytes(
path: impl AsRef<Path>,
) -> Result<(DynamicPluginManifest, String, Vec<u8>), CliError> {
let path = path.as_ref();
let manifest_path = if path.is_dir() {
path.join(DYNAMIC_PLUGIN_MANIFEST_FILENAME)
} else {
path.to_path_buf()
};
let normalized = fs::canonicalize(&manifest_path).map_err(|error| {
CliError::Config(format!(
"failed to normalize dynamic plugin manifest {}: {error}",
manifest_path.display()
))
})?;
let bytes = crate::filesystem::bounded::read_bounded_regular_file(
&normalized,
"dynamic plugin manifest",
)
.map_err(CliError::Config)?;
let contents = std::str::from_utf8(&bytes).map_err(|error| {
CliError::Config(format!(
"dynamic plugin manifest {} is not UTF-8: {error}",
normalized.display()
))
})?;
let manifest = DynamicPluginManifest::parse_toml(contents)
.map_err(|error| CliError::Config(error.to_string()))?;
Ok((manifest, normalized.to_string_lossy().into_owned(), bytes))
}
const BOOTSTRAP_HMAC_KEY_BYTES: usize = 32;
const BOOTSTRAP_HMAC_LOCK_TIMEOUT: Duration = Duration::from_secs(5);
const BOOTSTRAP_CHALLENGE_DOMAIN: &[u8] = b"nemo-relay/bootstrap-health/v1\0";
const BOOTSTRAP_CLIENT_TOKEN_DOMAIN: &[u8] = b"nemo-relay/bootstrap-client/v1\0";
const TRANSPARENT_GATEWAY_DOMAIN: &[u8] = b"nemo-relay/transparent-gateway/v1\0";
const PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN: &[u8] =
b"nemo-relay/python-environment-attestation/v1\0";
pub(crate) const BOOTSTRAP_CLIENT_TOKEN_HEADER: &str = "x-nemo-relay-client-token";
pub(crate) fn transparent_gateway_fingerprint(gateway_url: &str) -> String {
let mut context = digest::Context::new(&digest::SHA256);
context.update(TRANSPARENT_GATEWAY_DOMAIN);
context.update(gateway_url.as_bytes());
let encoded = context
.finish()
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("transparent-sha256:{encoded}")
}
#[derive(Clone)]
pub(crate) struct BootstrapChallengeKey(hmac::Key);
impl BootstrapChallengeKey {
pub(crate) fn load() -> Result<Self, CliError> {
Ok(Self(hmac::Key::new(
hmac::HMAC_SHA256,
&load_or_create_bootstrap_hmac_key()?,
)))
}
pub(crate) fn load_existing() -> Result<Option<Self>, CliError> {
load_existing_bootstrap_hmac_key()
.map(|key| key.map(|key| Self(hmac::Key::new(hmac::HMAC_SHA256, &key))))
}
pub(crate) fn proof(&self, fingerprint: &str, nonce: &str) -> String {
let mut context = hmac::Context::with_key(&self.0);
context.update(BOOTSTRAP_CHALLENGE_DOMAIN);
context.update(fingerprint.as_bytes());
context.update(&[0]);
context.update(nonce.as_bytes());
encode_hmac_tag(context.sign())
}
pub(crate) fn verify(&self, fingerprint: &str, nonce: &str, proof: &str) -> bool {
let Some(encoded) = proof.strip_prefix("hmac-sha256:") else {
return false;
};
let Some(tag) = decode_fixed_hex::<32>(encoded) else {
return false;
};
let mut message = Vec::with_capacity(
BOOTSTRAP_CHALLENGE_DOMAIN.len() + fingerprint.len() + nonce.len() + 1,
);
message.extend_from_slice(BOOTSTRAP_CHALLENGE_DOMAIN);
message.extend_from_slice(fingerprint.as_bytes());
message.push(0);
message.extend_from_slice(nonce.as_bytes());
hmac::verify(&self.0, &message, &tag).is_ok()
}
pub(crate) fn client_token(&self) -> String {
encode_hmac_tag(hmac::sign(&self.0, BOOTSTRAP_CLIENT_TOKEN_DOMAIN))
}
pub(crate) fn verify_client_token(&self, token: &str) -> bool {
let Some(encoded) = token.strip_prefix("hmac-sha256:") else {
return false;
};
let Some(tag) = decode_fixed_hex::<32>(encoded) else {
return false;
};
hmac::verify(&self.0, BOOTSTRAP_CLIENT_TOKEN_DOMAIN, &tag).is_ok()
}
#[cfg(test)]
pub(crate) fn from_bytes(bytes: &[u8]) -> Self {
Self(hmac::Key::new(hmac::HMAC_SHA256, bytes))
}
}
fn encode_hmac_tag(tag: hmac::Tag) -> String {
format!(
"hmac-sha256:{}",
tag.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
)
}
fn decode_fixed_hex<const N: usize>(encoded: &str) -> Option<[u8; N]> {
if encoded.len() != N * 2 || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let mut decoded = [0_u8; N];
for (index, byte) in decoded.iter_mut().enumerate() {
*byte = u8::from_str_radix(&encoded[index * 2..index * 2 + 2], 16).ok()?;
}
Some(decoded)
}
pub(crate) fn sign_python_environment_attestation(
source_artifact_sha256: &str,
environment_sha256: &str,
) -> Result<String, CliError> {
let key = hmac::Key::new(hmac::HMAC_SHA256, &load_or_create_bootstrap_hmac_key()?);
let message =
python_environment_attestation_message(source_artifact_sha256, environment_sha256);
let tag = hmac::sign(&key, &message);
Ok(format!(
"hmac-sha256:{}",
tag.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
))
}
pub(crate) fn verify_python_environment_attestation(
source_artifact_sha256: &str,
environment_sha256: &str,
authentication: &str,
) -> Result<bool, CliError> {
let Some(encoded) = authentication.strip_prefix("hmac-sha256:") else {
return Ok(false);
};
let Some(tag) = decode_fixed_hex::<32>(encoded) else {
return Ok(false);
};
let key = hmac::Key::new(hmac::HMAC_SHA256, &load_or_create_bootstrap_hmac_key()?);
Ok(hmac::verify(
&key,
&python_environment_attestation_message(source_artifact_sha256, environment_sha256),
&tag,
)
.is_ok())
}
fn python_environment_attestation_message(
source_artifact_sha256: &str,
environment_sha256: &str,
) -> Vec<u8> {
let mut message = Vec::with_capacity(
PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN.len()
+ source_artifact_sha256.len()
+ environment_sha256.len()
+ 1,
);
message.extend_from_slice(PYTHON_ENVIRONMENT_ATTESTATION_DOMAIN);
message.extend_from_slice(source_artifact_sha256.trim().as_bytes());
message.push(0);
message.extend_from_slice(environment_sha256.as_bytes());
message
}
fn load_or_create_bootstrap_hmac_key() -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> {
load_or_create_bootstrap_hmac_key_at(&bootstrap_hmac_key_path()?)
}
fn bootstrap_hmac_key_path() -> Result<PathBuf, CliError> {
user_config_dir()
.map(|directory| directory.join("bootstrap").join("fingerprint-hmac.key"))
.ok_or_else(|| {
CliError::Config(
"cannot determine the per-user NeMo Relay bootstrap state directory; set HOME or USERPROFILE"
.into(),
)
})
}
fn load_existing_bootstrap_hmac_key() -> Result<Option<[u8; BOOTSTRAP_HMAC_KEY_BYTES]>, CliError> {
let path = bootstrap_hmac_key_path()?;
let mut file = match OpenOptions::new().read(true).open(&path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(CliError::Config(format!(
"failed to open bootstrap HMAC key {}: {error}",
path.display()
)));
}
};
let deadline = Instant::now() + BOOTSTRAP_HMAC_LOCK_TIMEOUT;
loop {
match try_lock_shared(&file) {
Ok(LockAttempt::Acquired) => break,
Ok(LockAttempt::Contended) if Instant::now() < deadline => {
thread::sleep(Duration::from_millis(25));
}
Ok(LockAttempt::Contended) => {
return Err(CliError::Config(format!(
"timed out waiting for bootstrap HMAC key lock {}",
path.display()
)));
}
Err(error) => {
return Err(CliError::Config(format!(
"failed to lock bootstrap HMAC key {}: {error}",
path.display()
)));
}
}
}
let length = file
.metadata()
.map_err(|error| {
CliError::Config(format!(
"failed to inspect bootstrap HMAC key {}: {error}",
path.display()
))
})?
.len();
if length != BOOTSTRAP_HMAC_KEY_BYTES as u64 {
return Err(CliError::Config(format!(
"bootstrap HMAC key {} has invalid length {length}; expected {BOOTSTRAP_HMAC_KEY_BYTES} bytes",
path.display()
)));
}
let mut key = [0_u8; BOOTSTRAP_HMAC_KEY_BYTES];
file.read_exact(&mut key).map_err(|error| {
CliError::Config(format!(
"failed to read bootstrap HMAC key {}: {error}",
path.display()
))
})?;
Ok(Some(key))
}
fn load_or_create_bootstrap_hmac_key_at(
path: &Path,
) -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> {
load_or_create_bootstrap_hmac_key_at_with_timeout(path, BOOTSTRAP_HMAC_LOCK_TIMEOUT)
}
fn load_or_create_bootstrap_hmac_key_at_with_timeout(
path: &Path,
lock_timeout: Duration,
) -> Result<[u8; BOOTSTRAP_HMAC_KEY_BYTES], CliError> {
let parent = path.parent().ok_or_else(|| {
CliError::Config(format!(
"bootstrap HMAC key path {} has no parent directory",
path.display()
))
})?;
fs::create_dir_all(parent).map_err(|error| {
CliError::Config(format!(
"failed to create bootstrap state directory {}: {error}",
parent.display()
))
})?;
#[cfg(windows)]
crate::filesystem::protect_private_windows_path(parent).map_err(|error| {
CliError::Config(format!(
"failed to protect bootstrap state directory {}: {error}",
parent.display()
))
})?;
#[cfg(unix)]
fs::set_permissions(parent, {
use std::os::unix::fs::PermissionsExt;
fs::Permissions::from_mode(0o700)
})
.map_err(|error| {
CliError::Config(format!(
"failed to protect bootstrap state directory {}: {error}",
parent.display()
))
})?;
#[cfg(windows)]
let mut file = crate::filesystem::open_private_windows_file(path).map_err(|error| {
CliError::Config(format!(
"failed to open bootstrap HMAC key {}: {error}",
path.display()
))
})?;
#[cfg(not(windows))]
let mut file = {
let mut options = OpenOptions::new();
options.create(true).truncate(false).read(true).write(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path).map_err(|error| {
CliError::Config(format!(
"failed to open bootstrap HMAC key {}: {error}",
path.display()
))
})?
};
let lock_deadline = Instant::now() + lock_timeout;
loop {
match try_lock_exclusive(&file) {
Ok(LockAttempt::Acquired) => break,
Ok(LockAttempt::Contended) => {
if Instant::now() >= lock_deadline {
return Err(CliError::Config(format!(
"timed out waiting for bootstrap HMAC key lock {}",
path.display()
)));
}
thread::sleep(Duration::from_millis(25));
}
Err(error) => {
return Err(CliError::Config(format!(
"failed to lock bootstrap HMAC key {}: {error}",
path.display()
)));
}
}
}
#[cfg(unix)]
file.set_permissions({
use std::os::unix::fs::PermissionsExt;
fs::Permissions::from_mode(0o600)
})
.map_err(|error| {
CliError::Config(format!(
"failed to protect bootstrap HMAC key {}: {error}",
path.display()
))
})?;
let length = file
.metadata()
.map_err(|error| {
CliError::Config(format!(
"failed to inspect bootstrap HMAC key {}: {error}",
path.display()
))
})?
.len();
if length == 0 {
let mut key = [0_u8; BOOTSTRAP_HMAC_KEY_BYTES];
SystemRandom::new()
.fill(&mut key)
.map_err(|_| CliError::Config("failed to generate bootstrap HMAC key".into()))?;
file.write_all(&key).map_err(|error| {
CliError::Config(format!(
"failed to write bootstrap HMAC key {}: {error}",
path.display()
))
})?;
file.sync_all().map_err(|error| {
CliError::Config(format!(
"failed to persist bootstrap HMAC key {}: {error}",
path.display()
))
})?;
return Ok(key);
}
if length != BOOTSTRAP_HMAC_KEY_BYTES as u64 {
return Err(CliError::Config(format!(
"bootstrap HMAC key {} has invalid length {length}; expected {BOOTSTRAP_HMAC_KEY_BYTES} bytes",
path.display()
)));
}
file.seek(SeekFrom::Start(0)).map_err(|error| {
CliError::Config(format!(
"failed to read bootstrap HMAC key {}: {error}",
path.display()
))
})?;
let mut key = [0_u8; BOOTSTRAP_HMAC_KEY_BYTES];
file.read_exact(&mut key).map_err(|error| {
CliError::Config(format!(
"failed to read bootstrap HMAC key {}: {error}",
path.display()
))
})?;
Ok(key)
}
#[cfg(test)]
pub(crate) fn resolve_plugins_config(
explicit: Option<&PathBuf>,
) -> Result<ResolvedConfig, CliError> {
resolve_plugins_config_with_path(explicit, None)
}
pub(crate) fn resolve_plugins_config_with_path(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
) -> Result<ResolvedConfig, CliError> {
let resolved = load_shared_config(explicit, plugin_config_path)?;
log::info!(
target: "nemo_relay.configuration",
event = "plugin_configuration_resolved",
dynamic_plugin_count = resolved.dynamic_plugins.len();
"Plugin configuration resolved"
);
Ok(resolved)
}
pub(crate) fn resolve_run_config(
command: &RunOverrides,
inherited: Option<&GatewayOverrides>,
) -> Result<ResolvedConfig, CliError> {
let config = command
.config
.as_ref()
.or_else(|| inherited.and_then(|args| args.config.as_ref()));
let plugin_config_path = command
.plugin_config_path
.as_ref()
.or_else(|| inherited.and_then(|args| args.plugin_config_path.as_ref()));
let mut resolved = load_shared_config(config, plugin_config_path)?;
if let Some(args) = inherited {
apply_server_overrides(&mut resolved.gateway, args)?;
}
apply_run_overrides(&mut resolved.gateway, command)?;
resolved.gateway.bind = "127.0.0.1:0"
.parse()
.expect("valid transparent bind address");
if !command.dry_run {
let explicit_plugin_config = explicit_plugin_config_path(config, plugin_config_path);
enforce_required_dynamic_plugin_startup(explicit_plugin_config.as_ref(), &resolved)?;
}
log::info!(
target: "nemo_relay.configuration",
event = "configuration_resolved",
mode = "run",
dynamic_plugin_count = resolved.dynamic_plugins.len();
"Runtime configuration resolved"
);
Ok(resolved)
}
fn apply_run_overrides(config: &mut GatewayConfig, command: &RunOverrides) -> Result<(), CliError> {
apply_run_url_overrides(config, command);
apply_run_json_overrides(config, command)?;
Ok(())
}
fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunOverrides) {
if let Some(value) = &command.openai_base_url {
replace_upstream_base_url(
&mut config.openai_base_url,
&mut config.openai_auth_header,
value.clone(),
);
}
if let Some(value) = &command.anthropic_base_url {
replace_upstream_base_url(
&mut config.anthropic_base_url,
&mut config.anthropic_auth_header,
value.clone(),
);
}
}
fn apply_run_json_overrides(
config: &mut GatewayConfig,
command: &RunOverrides,
) -> Result<(), CliError> {
if let Some(value) = &command.session_metadata {
config.metadata = Some(parse_json_option("session metadata", value)?);
}
Ok(())
}
fn apply_server_overrides(
config: &mut GatewayConfig,
args: &GatewayOverrides,
) -> Result<(), CliError> {
if let Some(value) = args.bind {
config.bind = value;
}
if let Some(value) = &args.openai_base_url {
replace_upstream_base_url(
&mut config.openai_base_url,
&mut config.openai_auth_header,
value.clone(),
);
}
if let Some(value) = &args.anthropic_base_url {
replace_upstream_base_url(
&mut config.anthropic_base_url,
&mut config.anthropic_auth_header,
value.clone(),
);
}
if let Some(value) = args.max_hook_payload_bytes {
config.max_hook_payload_bytes = validate_body_limit("max hook payload bytes", value)?;
}
if let Some(value) = args.max_passthrough_body_bytes {
config.max_passthrough_body_bytes =
validate_body_limit("max passthrough body bytes", value)?;
}
Ok(())
}
pub(crate) const PLUGINS_TOML: &str = "plugins.toml";
fn load_shared_config(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
) -> Result<ResolvedConfig, CliError> {
load_shared_config_scoped(explicit, plugin_config_path, user_config_scope())
}
fn load_shared_config_scoped(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
user_only: bool,
) -> Result<ResolvedConfig, CliError> {
let mut merged = toml::Value::Table(toml::map::Map::new());
for path in config_paths_scoped(explicit, user_only) {
let required = explicit == Some(&path);
let Some(raw) = read_config_file(&path, required, "configuration")? else {
continue;
};
let parsed = raw
.parse::<toml::Table>()
.map(toml::Value::Table)
.map_err(|error| {
CliError::Config(format!("invalid TOML in {}: {error}", path.display()))
})?;
let legacy_observability = legacy_observability_sections(&parsed);
if !legacy_observability.is_empty() {
return Err(CliError::Config(format!(
"legacy observability config in {} is no longer supported: {}; configure \
observability in plugins.toml with `nemo-relay plugins edit`",
path.display(),
legacy_observability.join(", ")
)));
}
if parsed.get("plugins").is_some() {
return Err(CliError::Config(format!(
"plugin configuration in {} is no longer supported; move it to plugins.toml",
path.display()
)));
}
merge_gateway_config_toml(&mut merged, parsed);
}
let plugin_toml = load_plugin_toml_config_scoped(explicit, plugin_config_path, user_only)?;
let mut resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
..ResolvedConfig::default()
};
apply_file_config(&mut resolved, merged)?;
apply_plugin_toml_config(&mut resolved, plugin_toml);
apply_env_config(&mut resolved.gateway)?;
Ok(resolved)
}
fn read_config_file(
path: &Path,
required: bool,
description: &str,
) -> Result<Option<String>, CliError> {
match path.try_exists() {
Ok(false) if !required => Ok(None),
Ok(false) => Err(CliError::Config(format!(
"explicit {description} file {} does not exist",
path.display()
))),
Err(error) => Err(CliError::Config(format!(
"failed to inspect {description} file {}: {error}",
path.display()
))),
Ok(true) => std::fs::read_to_string(path).map(Some).map_err(|error| {
CliError::Config(format!(
"failed to read {description} file {}: {error}",
path.display()
))
}),
}
}
pub(crate) fn any_config_file_exists() -> bool {
config_paths(None).iter().any(|path| path.exists())
}
fn config_paths(explicit: Option<&PathBuf>) -> Vec<PathBuf> {
config_paths_scoped(explicit, user_config_scope())
}
fn config_paths_scoped(explicit: Option<&PathBuf>, user_only: bool) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(path) = explicit {
paths.push(path.clone());
} else if let Some(user) = user_config_path() {
paths.push(user);
}
if !user_only
&& let Ok(cwd) = std::env::current_dir()
&& let Some(project) = find_project_config(&cwd)
{
paths.push(project);
}
paths.push(PathBuf::from("/etc/nemo-relay/config.toml"));
paths
}
fn plugin_config_paths(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
) -> Vec<PathBuf> {
plugin_config_paths_scoped(explicit, plugin_config_path, user_config_scope())
}
fn plugin_config_paths_scoped(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
user_only: bool,
) -> Vec<PathBuf> {
let cwd = if user_only {
None
} else {
std::env::current_dir().ok()
};
if let Some(path) = explicit_plugin_config_path(explicit, plugin_config_path) {
let mut paths = vec![path];
paths.extend(implicit_plugin_config_paths(cwd.as_deref(), None));
return paths;
}
implicit_plugin_config_paths(cwd.as_deref(), user_config_dir())
}
pub(crate) fn explicit_plugin_config_path(
config_path: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
) -> Option<PathBuf> {
plugin_config_path.cloned().or_else(|| {
config_path.and_then(|path| path.parent().map(|parent| parent.join(PLUGINS_TOML)))
})
}
fn user_config_scope() -> bool {
std::env::var("NEMO_RELAY_CONFIG_SCOPE").ok().as_deref() == Some("user")
}
fn implicit_plugin_config_paths(
cwd: Option<&std::path::Path>,
user_config_dir: Option<PathBuf>,
) -> Vec<PathBuf> {
nemo_relay::plugin::default_plugin_config_paths(cwd, user_config_dir)
}
pub(crate) fn find_project_config(start: &std::path::Path) -> Option<PathBuf> {
for ancestor in start.ancestors() {
let path = ancestor.join(".nemo-relay/config.toml");
if path.exists() {
return Some(path);
}
}
None
}
fn find_project_plugin_config(start: &std::path::Path) -> Option<PathBuf> {
nemo_relay::plugin::nearest_project_plugin_config(start)
}
pub(crate) fn user_plugin_config_path() -> Option<PathBuf> {
user_config_dir().map(|dir| dir.join(PLUGINS_TOML))
}
pub(crate) fn user_plugin_runtime_config() -> Result<Option<Value>, CliError> {
Ok(
load_plugin_toml_config_from_paths(implicit_plugin_config_paths(None, user_config_dir()))?
.and_then(|config| config.value),
)
}
pub(crate) fn project_plugin_config_path(start: &std::path::Path) -> PathBuf {
find_project_plugin_config(start)
.or_else(|| {
find_project_config(start)
.and_then(|path| path.parent().map(|parent| parent.join(PLUGINS_TOML)))
})
.unwrap_or_else(|| start.join(".nemo-relay").join(PLUGINS_TOML))
}
pub(crate) fn global_plugin_config_path() -> PathBuf {
PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML)
}
fn user_config_path() -> Option<PathBuf> {
user_config_dir().map(|dir| dir.join("config.toml"))
}
pub(crate) fn user_config_dir() -> Option<PathBuf> {
nemo_relay::plugin::user_config_dir()
}
fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Result<(), CliError> {
let config = parse_file_config(value)?;
apply_file_gateway_config(&mut resolved.gateway, config.gateway)?;
apply_file_upstream_config(&mut resolved.gateway, config.upstream)?;
apply_file_agents_config(&mut resolved.agents, config.agents);
logging::apply_file_logging_config(&mut resolved.logging, config.logging)?;
Ok(())
}
pub(crate) fn validate_shared_config_shape(value: toml::Value) -> Result<(), CliError> {
let _ = parse_file_config(value)?;
Ok(())
}
fn parse_file_config(value: toml::Value) -> Result<FileConfig, CliError> {
value
.try_into()
.map_err(|error| CliError::Config(format!("invalid gateway configuration shape: {error}")))
}
fn apply_file_gateway_config(
gateway: &mut GatewayConfig,
config: Option<FileGatewayConfig>,
) -> Result<(), CliError> {
let Some(config) = config else {
return Ok(());
};
if let Some(value) = config.max_hook_payload_bytes {
gateway.max_hook_payload_bytes =
validate_body_limit("gateway.max_hook_payload_bytes", value)?;
}
if let Some(value) = config.max_passthrough_body_bytes {
gateway.max_passthrough_body_bytes =
validate_body_limit("gateway.max_passthrough_body_bytes", value)?;
}
Ok(())
}
fn apply_file_upstream_config(
gateway: &mut GatewayConfig,
upstream: Option<FileUpstreamConfig>,
) -> Result<(), CliError> {
let Some(upstream) = upstream else {
return Ok(());
};
let FileUpstreamConfig {
openai_base_url,
openai_auth_header,
anthropic_base_url,
anthropic_auth_header,
} = upstream;
if let Some(value) = openai_base_url {
gateway.openai_base_url = value;
if openai_auth_header.is_none() {
gateway.openai_auth_header = None;
}
}
if let Some(value) = openai_auth_header {
gateway.openai_auth_header =
Some(validate_auth_header("upstream.openai_auth_header", value)?);
}
if let Some(value) = anthropic_base_url {
gateway.anthropic_base_url = value;
if anthropic_auth_header.is_none() {
gateway.anthropic_auth_header = None;
}
}
if let Some(value) = anthropic_auth_header {
gateway.anthropic_auth_header = Some(validate_auth_header(
"upstream.anthropic_auth_header",
value,
)?);
}
Ok(())
}
#[derive(Debug, Clone)]
struct PluginTomlConfig {
value: Option<Value>,
dynamic_plugins: Vec<ResolvedDynamicPluginConfig>,
dynamic_plugin_policy: DynamicPluginHostPolicy,
contributing_sources: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct PluginTomlPluginsSection {
#[serde(default)]
dynamic: Vec<FileDynamicPluginConfig>,
#[serde(default)]
policy: Option<crate::plugins::policy::FileDynamicPluginHostPolicy>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileDynamicPluginConfig {
manifest: String,
#[serde(default)]
config: Option<Map<String, Value>>,
}
fn load_plugin_toml_config_scoped(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
user_only: bool,
) -> Result<Option<PluginTomlConfig>, CliError> {
load_plugin_toml_config_from_paths(plugin_config_paths_scoped(
explicit,
plugin_config_path,
user_only,
))
}
pub(crate) fn diagnostic_plugin_config_paths(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
) -> Vec<PathBuf> {
plugin_config_paths(explicit, plugin_config_path)
}
pub(crate) fn effective_plugin_toml_sources(
explicit: Option<&PathBuf>,
plugin_config_path: Option<&PathBuf>,
) -> Result<Vec<PathBuf>, CliError> {
effective_plugin_toml_sources_from_paths(plugin_config_paths(explicit, plugin_config_path))
}
fn effective_plugin_toml_sources_from_paths<I>(paths: I) -> Result<Vec<PathBuf>, CliError>
where
I: IntoIterator<Item = PathBuf>,
{
let Some(config) = load_plugin_toml_config_from_paths(paths)? else {
return Ok(Vec::new());
};
let mut sources = config.contributing_sources;
sources.sort();
sources.dedup();
Ok(sources)
}
fn load_plugin_toml_config_from_paths<I>(paths: I) -> Result<Option<PluginTomlConfig>, CliError>
where
I: IntoIterator<Item = PathBuf>,
{
let paths = deduplicate_plugin_config_paths(paths);
let mut dynamic_plugins = Vec::new();
let mut dynamic_plugin_policy = DynamicPluginHostPolicy::default();
let mut seen_plugin_ids = HashSet::new();
let mut contributing_sources = Vec::new();
let mut runtime_documents = Vec::new();
for path in &paths {
let Some(raw) = read_config_file(path, false, "plugin configuration")? else {
continue;
};
let mut parsed = raw
.parse::<toml::Table>()
.map(toml::Value::Table)
.map_err(|error| {
CliError::Config(format!(
"invalid plugin TOML in {}: {error}",
path.display()
))
})?;
let resolved_plugins =
resolve_dynamic_plugin_refs(path, &mut parsed, &mut seen_plugin_ids)?;
if !resolved_plugins.dynamic_plugins.is_empty()
|| resolved_plugins.dynamic_plugin_policy != DynamicPluginHostPolicy::default()
{
contributing_sources.push(path.clone());
}
dynamic_plugins.extend(resolved_plugins.dynamic_plugins);
dynamic_plugin_policy.merge_from(resolved_plugins.dynamic_plugin_policy);
runtime_documents.push((
path.clone(),
serde_json::to_value(remove_dynamic_plugin_sections(parsed))
.expect("toml value serializes to JSON"),
));
}
let resolved = merge_plugin_config_documents(runtime_documents).map_err(|err| match err {
PluginError::InvalidConfig(message) => CliError::Config(message),
other => CliError::Config(other.to_string()),
})?;
match resolved {
Some((value, sources)) => {
contributing_sources.extend(sources.iter().cloned());
contributing_sources.sort();
contributing_sources.dedup();
Ok(Some(PluginTomlConfig {
value: plugin_toml_runtime_value(value),
dynamic_plugins,
dynamic_plugin_policy,
contributing_sources,
}))
}
None => Ok((!dynamic_plugins.is_empty()
|| dynamic_plugin_policy != DynamicPluginHostPolicy::default())
.then_some(PluginTomlConfig {
value: None,
dynamic_plugins,
dynamic_plugin_policy,
contributing_sources,
})),
}
}
fn apply_plugin_toml_config(resolved: &mut ResolvedConfig, plugin_toml: Option<PluginTomlConfig>) {
let Some(plugin_toml) = plugin_toml else {
return;
};
if let Some(value) = plugin_toml.value {
resolved.gateway.plugin_config = Some(value);
}
resolved.dynamic_plugins = plugin_toml.dynamic_plugins;
resolved.dynamic_plugin_policy = plugin_toml.dynamic_plugin_policy;
}
struct ResolvedDynamicPluginRefs {
dynamic_plugins: Vec<ResolvedDynamicPluginConfig>,
dynamic_plugin_policy: DynamicPluginHostPolicy,
}
fn resolve_dynamic_plugin_refs(
source: &Path,
value: &mut toml::Value,
seen_plugin_ids: &mut HashSet<String>,
) -> Result<ResolvedDynamicPluginRefs, CliError> {
let Some(root) = value.as_table_mut() else {
return Ok(ResolvedDynamicPluginRefs {
dynamic_plugins: Vec::new(),
dynamic_plugin_policy: DynamicPluginHostPolicy::default(),
});
};
let plugins_value = root.get("plugins").cloned();
let Some(plugins_value) = plugins_value else {
return Ok(ResolvedDynamicPluginRefs {
dynamic_plugins: Vec::new(),
dynamic_plugin_policy: DynamicPluginHostPolicy::default(),
});
};
let plugins: PluginTomlPluginsSection = plugins_value.try_into().map_err(|error| {
CliError::Config(format!(
"invalid dynamic plugin config in {}: {error}",
source.display()
))
})?;
if let Some(toml::Value::Table(plugins_table)) = root.get_mut("plugins") {
plugins_table.remove("dynamic");
plugins_table.remove("policy");
if plugins_table.is_empty() {
root.remove("plugins");
}
}
let mut resolved = Vec::with_capacity(plugins.dynamic.len());
for dynamic in plugins.dynamic {
let manifest_path = resolve_dynamic_manifest_path(source, &dynamic.manifest);
let (manifest, manifest_ref) = load_bounded_dynamic_plugin_manifest(&manifest_path)
.map_err(|error| {
CliError::Config(format!(
"invalid dynamic plugin manifest referenced by {}: {error}",
source.display()
))
})?;
let plugin_id = manifest.plugin.id.trim().to_owned();
if !seen_plugin_ids.insert(plugin_id.clone()) {
return Err(CliError::Config(format!(
"duplicate dynamic plugin id '{}' in {} across plugins.toml sources",
plugin_id,
source.display()
)));
}
resolved.push(ResolvedDynamicPluginConfig {
plugin_id,
manifest_ref,
has_explicit_config: dynamic.config.is_some(),
config: dynamic.config.unwrap_or_default(),
source: source.to_path_buf(),
});
}
Ok(ResolvedDynamicPluginRefs {
dynamic_plugins: resolved,
dynamic_plugin_policy: plugins.policy.map(Into::into).unwrap_or_default(),
})
}
fn resolve_dynamic_manifest_path(source: &Path, manifest: &str) -> PathBuf {
let manifest = PathBuf::from(manifest);
if manifest.is_absolute() {
manifest
} else {
source
.parent()
.map(|parent| parent.join(&manifest))
.unwrap_or(manifest)
}
}
fn plugin_toml_runtime_value(value: Value) -> Option<Value> {
match value {
Value::Object(ref object) if object.is_empty() => None,
other => Some(other),
}
}
fn remove_dynamic_plugin_sections(mut value: toml::Value) -> toml::Value {
if let Some(root) = value.as_table_mut()
&& let Some(toml::Value::Table(plugins)) = root.get_mut("plugins")
{
plugins.remove("dynamic");
plugins.remove("policy");
if plugins.is_empty() {
root.remove("plugins");
}
}
value
}
fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option<FileAgentsConfig>) {
let Some(file_agents) = file_agents else {
return;
};
if let Some(value) = file_agents.claude {
agents.claude.command = value.command;
}
if let Some(value) = file_agents.codex {
agents.codex.command = value.command;
}
if let Some(value) = file_agents.hermes {
agents.hermes.command = value.command;
agents.hermes.hooks_path = value.hooks_path;
}
}
fn apply_env_config(config: &mut GatewayConfig) -> Result<(), CliError> {
if let Ok(value) = std::env::var("NEMO_RELAY_GATEWAY_BIND")
&& let Ok(value) = value.parse()
{
config.bind = value;
}
let openai_auth_header = std::env::var("NEMO_RELAY_OPENAI_AUTH_HEADER").ok();
if let Ok(value) = std::env::var("NEMO_RELAY_OPENAI_BASE_URL") {
replace_upstream_base_url(
&mut config.openai_base_url,
&mut config.openai_auth_header,
value,
);
}
if let Some(value) = openai_auth_header {
config.openai_auth_header = Some(validate_auth_header(
"NEMO_RELAY_OPENAI_AUTH_HEADER",
value,
)?);
}
let anthropic_auth_header = std::env::var("NEMO_RELAY_ANTHROPIC_AUTH_HEADER").ok();
if let Ok(value) = std::env::var("NEMO_RELAY_ANTHROPIC_BASE_URL") {
replace_upstream_base_url(
&mut config.anthropic_base_url,
&mut config.anthropic_auth_header,
value,
);
}
if let Some(value) = anthropic_auth_header {
config.anthropic_auth_header = Some(validate_auth_header(
"NEMO_RELAY_ANTHROPIC_AUTH_HEADER",
value,
)?);
}
if let Ok(value) = std::env::var("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES") {
config.max_hook_payload_bytes =
parse_env_body_limit("NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES", &value)?;
}
if let Ok(value) = std::env::var("NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES") {
config.max_passthrough_body_bytes =
parse_env_body_limit("NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES", &value)?;
}
Ok(())
}
fn replace_upstream_base_url(
base_url: &mut String,
auth_header: &mut Option<String>,
replacement: String,
) {
if *base_url != replacement {
*auth_header = None;
}
*base_url = replacement;
}
fn validate_auth_header(name: &str, value: String) -> Result<String, CliError> {
let value = value.trim().to_string();
if value.is_empty() {
return Err(CliError::Config(format!("{name} must not be empty")));
}
HeaderValue::from_str(&value)
.map_err(|_| CliError::Config(format!("{name} must be a valid HTTP header value")))?;
Ok(value)
}
fn parse_env_body_limit(name: &str, raw: &str) -> Result<usize, CliError> {
let value = raw.parse::<usize>().map_err(|error| {
CliError::Config(format!("{name} must be a positive byte count: {error}"))
})?;
validate_body_limit(name, value)
}
fn validate_body_limit(name: &str, value: usize) -> Result<usize, CliError> {
if value == 0 {
return Err(CliError::Config(format!("{name} must be greater than 0")));
}
Ok(value)
}
fn merge_toml(left: &mut toml::Value, right: toml::Value) {
match (left, right) {
(toml::Value::Table(left), toml::Value::Table(right)) => {
for (key, value) in right {
match left.get_mut(&key) {
Some(existing) => merge_toml(existing, value),
None => {
left.insert(key, value);
}
}
}
}
(left, right) => *left = right,
}
}
fn merge_gateway_config_toml(left: &mut toml::Value, mut right: toml::Value) {
clear_credentials_for_replaced_upstreams(left, &right);
merge_logging_sinks_by_path(left, &mut right);
merge_toml(left, right);
}
fn clear_credentials_for_replaced_upstreams(left: &mut toml::Value, right: &toml::Value) {
if let (Some(existing), Some(override_upstream)) = (
left.get_mut("upstream").and_then(toml::Value::as_table_mut),
right.get("upstream").and_then(toml::Value::as_table),
) {
for (base_url, auth_header) in [
("openai_base_url", "openai_auth_header"),
("anthropic_base_url", "anthropic_auth_header"),
] {
let endpoint_changed = override_upstream
.get(base_url)
.is_some_and(|value| existing.get(base_url) != Some(value));
if endpoint_changed && !override_upstream.contains_key(auth_header) {
existing.remove(auth_header);
}
}
}
}
fn merge_logging_sinks_by_path(left: &toml::Value, right: &mut toml::Value) {
let lower = left
.get("logging")
.and_then(|logging| logging.get("sinks"))
.and_then(toml::Value::as_array)
.cloned()
.unwrap_or_default();
let Some(higher_value) = right
.get_mut("logging")
.and_then(toml::Value::as_table_mut)
.and_then(|logging| logging.get_mut("sinks"))
else {
return;
};
let Some(higher) = higher_value.as_array().cloned() else {
return;
};
*higher_value = toml::Value::Array(merge_logging_sink_lists(lower, higher));
}
fn merge_logging_sink_lists(lower: Vec<toml::Value>, higher: Vec<toml::Value>) -> Vec<toml::Value> {
let lower = coalesce_logging_sinks(lower);
let higher = coalesce_logging_sinks(higher);
let mut lower_used = vec![false; lower.len()];
let mut merged = Vec::with_capacity(lower.len() + higher.len());
for higher_sink in higher {
let identity = logging_sink_identity(&higher_sink);
let lower_match = identity.as_ref().and_then(|path| {
lower
.iter()
.position(|sink| logging_sink_identity(sink).as_ref() == Some(path))
});
if let Some(index) = lower_match {
let mut sink = lower[index].clone();
merge_toml(&mut sink, higher_sink);
lower_used[index] = true;
merged.push(sink);
} else {
merged.push(higher_sink);
}
}
merged.extend(
lower
.into_iter()
.enumerate()
.filter_map(|(index, sink)| (!lower_used[index]).then_some(sink)),
);
merged
}
fn coalesce_logging_sinks(sinks: Vec<toml::Value>) -> Vec<toml::Value> {
let mut coalesced: Vec<toml::Value> = Vec::with_capacity(sinks.len());
for sink in sinks {
let identity = logging_sink_identity(&sink);
let existing = identity.as_ref().and_then(|path| {
coalesced
.iter()
.position(|candidate| logging_sink_identity(candidate).as_ref() == Some(path))
});
if let Some(index) = existing {
merge_toml(&mut coalesced[index], sink);
} else {
coalesced.push(sink);
}
}
coalesced
}
fn logging_sink_path(sink: &toml::Value) -> Option<&str> {
sink.as_table()?.get("path")?.as_str()
}
fn logging_sink_identity(sink: &toml::Value) -> Option<PathBuf> {
let path = Path::new(logging_sink_path(sink)?);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().ok()?.join(path)
};
Some(logging_path_identity(&absolute))
}
fn logging_path_identity(path: &Path) -> PathBuf {
if let Ok(canonical) = std::fs::canonicalize(path) {
return canonical;
}
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
let file_name = path.file_name().unwrap_or_default();
if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
return canonical_parent.join(file_name);
}
normalize_path_components(parent).join(file_name)
}
_ => normalize_path_components(path),
}
}
fn normalize_path_components(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> {
let mut sections = Vec::new();
if value.get("exporters").is_some() {
sections.push("[exporters]");
}
if value.get("observability").is_some() {
sections.push("[observability]");
}
if value
.get("export")
.and_then(|export| export.get("openinference"))
.is_some()
{
sections.push("[export.openinference]");
}
sections
}
fn parse_json_option(name: &str, value: &str) -> Result<Value, CliError> {
serde_json::from_str::<Value>(value)
.map_err(|error| CliError::Config(format!("invalid {name}: {error}")))
}
pub(crate) fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn header_json(headers: &HeaderMap, name: &str) -> Option<Value> {
header_string(headers, name).and_then(|raw| serde_json::from_str(&raw).ok())
}
#[cfg(test)]
#[path = "../../tests/coverage/shared/config_tests.rs"]
mod tests;