nemo-relay-cli 0.7.0

Coding-agent gateway CLI for NeMo Relay observability.
Documentation
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

//! Environment names shared by MCP generation and gateway compatibility checks.

use std::collections::BTreeSet;

use serde_json::Value;

use crate::installation::generation::{GENERATION_FILE_ENV, GENERATION_TOKEN_ENV};

const BASE_MCP_ENV_VARS: &[&str] = &[
    "ALL_PROXY",
    "ANTHROPIC_API_KEY",
    "APPDATA",
    "AWS_ACCESS_KEY_ID",
    "AWS_ALLOW_HTTP",
    "AWS_CA_BUNDLE",
    "AWS_CONFIG_FILE",
    "AWS_CONTAINER_AUTHORIZATION_TOKEN",
    "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
    "AWS_CONTAINER_CREDENTIALS_FULL_URI",
    "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
    "AWS_DEFAULT_REGION",
    "AWS_EC2_METADATA_DISABLED",
    "AWS_ENDPOINT_URL",
    "AWS_PROFILE",
    "AWS_REGION",
    "AWS_ROLE_ARN",
    "AWS_ROLE_SESSION_NAME",
    "AWS_SDK_LOAD_CONFIG",
    "AWS_SECRET_ACCESS_KEY",
    "AWS_SESSION_TOKEN",
    "AWS_SHARED_CREDENTIALS_FILE",
    "AWS_STS_REGIONAL_ENDPOINTS",
    "AWS_WEB_IDENTITY_TOKEN_FILE",
    "HOME",
    "HTTPS_PROXY",
    "HTTP_PROXY",
    "LOCALAPPDATA",
    "NEMO_RELAY_ANTHROPIC_AUTH_HEADER",
    "NEMO_RELAY_ANTHROPIC_BASE_URL",
    "NEMO_RELAY_GATEWAY_URL",
    "NEMO_RELAY_MAX_HOOK_PAYLOAD_BYTES",
    "NEMO_RELAY_MAX_PASSTHROUGH_BODY_BYTES",
    "NEMO_RELAY_OPENAI_AUTH_HEADER",
    "NEMO_RELAY_OPENAI_BASE_URL",
    "NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS",
    "NEMO_RELAY_PYTHON",
    "NEMO_RELAY_TRANSPARENT_RUN",
    "NO_PROXY",
    "OPENAI_API_KEY",
    "OTEL_EXPORTER_OTLP_COMPRESSION",
    "OTEL_EXPORTER_OTLP_ENDPOINT",
    "OTEL_EXPORTER_OTLP_HEADERS",
    "OTEL_EXPORTER_OTLP_PROTOCOL",
    "OTEL_EXPORTER_OTLP_TIMEOUT",
    "OTEL_EXPORTER_OTLP_TRACES_COMPRESSION",
    "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
    "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
    "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
    "OTEL_EXPORTER_OTLP_TRACES_TIMEOUT",
    "OTEL_RESOURCE_ATTRIBUTES",
    "OTEL_SDK_DISABLED",
    "OTEL_SERVICE_NAME",
    "SSL_CERT_DIR",
    "SSL_CERT_FILE",
    "TEMP",
    "TMPDIR",
    "USERPROFILE",
    "XDG_CONFIG_HOME",
    "XDG_RUNTIME_DIR",
    "all_proxy",
    "http_proxy",
    "https_proxy",
    "no_proxy",
];

const BLOCKED_MCP_ENV_VARS: &[&str] = &[
    "NEMO_RELAY_BINDING_KIND",
    "NEMO_RELAY_BOOTSTRAP_AGENT",
    "NEMO_RELAY_BOOTSTRAP_FINGERPRINT",
    "NEMO_RELAY_BOOTSTRAP_STATE_DIR",
    "NEMO_RELAY_BOOTSTRAP_SHUTDOWN_TOKEN",
    "NEMO_RELAY_CONFIG_SCOPE",
    "NEMO_RELAY_FAIL_CLOSED",
    "NEMO_RELAY_GATEWAY_BIND",
    "NEMO_RELAY_HOST_SOCKET",
    "NEMO_RELAY_MCP_GENERATION",
    "NEMO_RELAY_MCP_GENERATION_FILE",
    "NEMO_RELAY_NATIVE_ABI_VERSION",
    "NEMO_RELAY_PLUGIN_BINARY",
    "NEMO_RELAY_PLUGIN_BIND",
    "NEMO_RELAY_SIDECAR_JOB_NAME",
    "NEMO_RELAY_PLUGIN_CONFIG_PATH",
    "NEMO_RELAY_PLUGIN_GATEWAY_URL",
    "NEMO_RELAY_PLUGIN_ID",
    "NEMO_RELAY_RUNTIME_OWNER",
    "NEMO_RELAY_WORKER_ENDPOINT_FILE",
    "NEMO_RELAY_WORKER_ID",
    "NEMO_RELAY_WORKER_SOCKET",
    "NEMO_RELAY_WORKER_TOKEN",
];

pub(crate) fn forwarded_names(
    environment: impl IntoIterator<Item = String>,
    config: Option<&Value>,
) -> Vec<String> {
    forwarded_names_for_platform(environment, config, cfg!(windows))
}

pub(crate) fn forwarded_names_for_platform(
    environment: impl IntoIterator<Item = String>,
    config: Option<&Value>,
    windows: bool,
) -> Vec<String> {
    let mut names = BTreeSet::new();
    for name in BASE_MCP_ENV_VARS {
        insert_name(&mut names, (*name).to_string(), windows);
    }
    for name in environment {
        if prefix_allowed(&name, windows) && !blocked(&name) {
            insert_name(&mut names, name, windows);
        }
    }
    if let Some(config) = config {
        collect_config_names(config, &mut names, windows);
    }
    names.into_iter().collect()
}

/// Removes unresolved `${NAME}` values injected by MCP hosts before CLI parsing.
///
/// Hermes forwards environment names through placeholder values rather than a separate
/// `env_vars` list. When a variable is absent, Hermes preserves the self-placeholder. Relay must
/// treat that value as unset before clap reads numeric or socket-valued environment options. The
/// generation fence scopes this cleanup to managed persistent MCP launches; internal variables
/// remain untouched so malformed or retired generation identities fail closed during validation.
pub(crate) fn remove_unresolved_mcp_placeholders() {
    if std::env::var_os(GENERATION_FILE_ENV).is_none()
        || std::env::var_os(GENERATION_TOKEN_ENV).is_none()
    {
        return;
    }
    let unresolved = std::env::vars_os()
        .filter_map(|(name, value)| {
            let name_text = name.to_str()?;
            let value = value.to_str()?;
            (!blocked(name_text)
                && unresolved_self_placeholder_for_platform(name_text, value, cfg!(windows)))
            .then_some(name)
        })
        .collect::<Vec<_>>();
    for name in unresolved {
        // SAFETY: The synchronous CLI entrypoint calls this before constructing the Tokio runtime,
        // so no other thread can read or write the process environment concurrently.
        unsafe { std::env::remove_var(name) };
    }
}

pub(crate) fn forwarded_names_match_for_platform(left: &str, right: &str, windows: bool) -> bool {
    if windows {
        left.eq_ignore_ascii_case(right)
    } else {
        left == right
    }
}

/// Returns whether a name could have been captured from an earlier process environment.
///
/// Arbitrary config-referenced names remain in the current expected set. Historical extras are
/// therefore limited to the static allowlist and approved dynamic prefixes.
pub(crate) fn previously_forwardable_name_for_platform(name: &str, windows: bool) -> bool {
    !blocked(name)
        && (BASE_MCP_ENV_VARS
            .iter()
            .any(|base| forwarded_names_match_for_platform(name, base, windows))
            || prefix_allowed(name, windows))
}

pub(crate) fn unresolved_self_placeholder_for_platform(
    name: &str,
    value: &str,
    windows: bool,
) -> bool {
    value
        .strip_prefix("${")
        .and_then(|value| value.strip_suffix('}'))
        .is_some_and(|placeholder| forwarded_names_match_for_platform(name, placeholder, windows))
}

fn prefix_allowed(name: &str, windows: bool) -> bool {
    ["NEMO_RELAY_", "OTEL_", "AWS_"].iter().any(|prefix| {
        if windows {
            starts_with_ignore_ascii_case(name, prefix)
        } else {
            name.starts_with(prefix)
        }
    })
}

fn blocked(name: &str) -> bool {
    BLOCKED_MCP_ENV_VARS
        .iter()
        .any(|blocked| name.eq_ignore_ascii_case(blocked))
        || starts_with_ignore_ascii_case(name, "NEMO_RELAY_TEST_")
}

fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool {
    value
        .get(..prefix.len())
        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
}

fn insert_name(names: &mut BTreeSet<String>, name: String, windows: bool) {
    if !windows
        || !names
            .iter()
            .any(|existing| existing.eq_ignore_ascii_case(&name))
    {
        names.insert(name);
    }
}

fn collect_config_names(value: &Value, names: &mut BTreeSet<String>, windows: bool) {
    match value {
        Value::Object(object) => {
            for (key, value) in object {
                collect_config_field(key, value, names, windows);
            }
        }
        Value::Array(values) => {
            for value in values {
                collect_config_names(value, names, windows);
            }
        }
        _ => {}
    }
}

fn collect_config_field(key: &str, value: &Value, names: &mut BTreeSet<String>, windows: bool) {
    match key {
        "header_env" => collect_header_env_names(value, names, windows),
        "secret_access_key_var" | "session_token_var" => {
            if let Some(name) = value.as_str() {
                collect_config_name(name, names, windows);
            }
        }
        _ => collect_config_names(value, names, windows),
    }
}

fn collect_header_env_names(value: &Value, names: &mut BTreeSet<String>, windows: bool) {
    if let Some(headers) = value.as_object() {
        for name in headers.values().filter_map(Value::as_str) {
            collect_config_name(name, names, windows);
        }
    }
}

fn collect_config_name(name: &str, names: &mut BTreeSet<String>, windows: bool) {
    if !name.is_empty() && !blocked(name) {
        insert_name(names, name.to_owned(), windows);
    }
}