Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 25 additions & 43 deletions crates/cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,6 @@ pub(crate) struct ServerArgs {
/// Upstream Anthropic base URL (e.g. https://api.anthropic.com)
#[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")]
pub(crate) anthropic_base_url: Option<String>,
/// Generic plugin configuration JSON for process-level gateway plugin activation.
#[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG")]
pub(crate) plugin_config: Option<String>,
}

impl ServerArgs {
Expand All @@ -210,7 +207,6 @@ impl ServerArgs {
self.bind.is_some()
|| self.openai_base_url.is_some()
|| self.anthropic_base_url.is_some()
|| self.plugin_config.is_some()
|| self.config.is_some()
}
}
Expand All @@ -222,6 +218,7 @@ pub(crate) struct GatewayConfig {
pub(crate) anthropic_base_url: String,
pub(crate) metadata: Option<Value>,
pub(crate) plugin_config: Option<Value>,
pub(crate) plugin_config_source: Option<String>,
}

#[derive(Debug, Clone, Args)]
Expand All @@ -234,8 +231,6 @@ pub(crate) struct HookForwardCommand {
pub(crate) profile: Option<String>,
#[arg(long)]
pub(crate) session_metadata: Option<String>,
#[arg(long)]
pub(crate) plugin_config: Option<String>,
#[arg(long, value_enum)]
pub(crate) gateway_mode: Option<GatewayMode>,
#[arg(long)]
Expand Down Expand Up @@ -267,8 +262,6 @@ pub(crate) struct RunCommand {
#[arg(long)]
pub(crate) session_metadata: Option<String>,
#[arg(long)]
pub(crate) plugin_config: Option<String>,
#[arg(long)]
pub(crate) dry_run: bool,
#[arg(long)]
pub(crate) print: bool,
Expand Down Expand Up @@ -312,13 +305,11 @@ impl GatewayConfig {
pub(crate) fn session_config_from_headers(&self, headers: &HeaderMap) -> SessionConfig {
let metadata =
header_json(headers, "x-nemo-relay-session-metadata").or_else(|| self.metadata.clone());
let plugin_config = header_json(headers, "x-nemo-relay-plugin-config")
.or_else(|| self.plugin_config.clone());
let profile = header_string(headers, "x-nemo-relay-config-profile");
let gateway_mode = header_string(headers, "x-nemo-relay-gateway-mode");
SessionConfig {
metadata,
plugin_config,
plugin_config: self.plugin_config.clone(),
profile,
gateway_mode,
}
Expand Down Expand Up @@ -423,6 +414,7 @@ impl Default for GatewayConfig {
anthropic_base_url: "https://api.anthropic.com".into(),
metadata: None,
plugin_config: None,
plugin_config_source: None,
}
}
}
Expand All @@ -440,8 +432,8 @@ pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result<ResolvedConfig,
/// Resolves transparent `run` configuration and switches the gateway to an ephemeral bind address.
///
/// Explicit run arguments override inherited top-level server flags, which override shared config.
/// Session metadata and plugin config are parsed as JSON here so malformed CLI values fail before
/// the child agent is spawned.
/// Session metadata is parsed as JSON here so malformed CLI values fail before the child agent is
/// spawned.
pub(crate) fn resolve_run_config(
command: &RunCommand,
inherited: Option<&ServerArgs>,
Expand All @@ -452,16 +444,7 @@ pub(crate) fn resolve_run_config(
.or_else(|| inherited.and_then(|args| args.config.as_ref()));
let mut resolved = load_shared_config(config)?;
if let Some(args) = inherited {
// Run-subcommand plugin config has higher precedence than inherited top-level plugin
// config. Skip only that inherited field so file/plugins.toml conflicts are still caught
// when the run-level override is applied below.
if command.plugin_config.is_some() && args.plugin_config.is_some() {
let mut inherited = args.clone();
inherited.plugin_config = None;
apply_server_overrides(&mut resolved.gateway, &inherited)?;
} else {
apply_server_overrides(&mut resolved.gateway, args)?;
}
apply_server_overrides(&mut resolved.gateway, args)?;
}
apply_run_overrides(&mut resolved.gateway, command)?;
resolved.gateway.bind = "127.0.0.1:0"
Expand All @@ -471,7 +454,7 @@ pub(crate) fn resolve_run_config(
}

// Applies subcommand-specific `run` overrides after inherited top-level flags. JSON-bearing fields
// are parsed here so invalid metadata or plugin config fails before the gateway binds a port.
// are parsed here so invalid metadata fails before the gateway binds a port.
fn apply_run_overrides(config: &mut GatewayConfig, command: &RunCommand) -> Result<(), CliError> {
apply_run_url_overrides(config, command);
apply_run_json_overrides(config, command)?;
Expand All @@ -489,18 +472,15 @@ fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunCommand) {
}
}

// Parses JSON-bearing run overrides after simple values. Invalid metadata or plugin config fails
// before transparent run mode binds its ephemeral gateway listener.
// Parses JSON-bearing run overrides after simple values. Invalid metadata fails before transparent
// run mode binds its ephemeral gateway listener.
fn apply_run_json_overrides(
config: &mut GatewayConfig,
command: &RunCommand,
) -> Result<(), CliError> {
if let Some(value) = &command.session_metadata {
config.metadata = Some(parse_json_option("session metadata", value)?);
}
if let Some(value) = &command.plugin_config {
apply_cli_plugin_config(config, value)?;
}
Ok(())
}

Expand All @@ -516,9 +496,6 @@ fn apply_server_overrides(config: &mut GatewayConfig, args: &ServerArgs) -> Resu
if let Some(value) = &args.anthropic_base_url {
config.anthropic_base_url = value.clone();
}
if let Some(value) = &args.plugin_config {
apply_cli_plugin_config(config, value)?;
}
Ok(())
}

Expand Down Expand Up @@ -568,6 +545,9 @@ fn load_shared_config(explicit: Option<&PathBuf>) -> Result<ResolvedConfig, CliE
..ResolvedConfig::default()
};
apply_file_config(&mut resolved, merged)?;
if let Some(source) = config_toml_plugin_sources.first() {
resolved.gateway.plugin_config_source = Some(config_toml_plugin_source(source));
}
apply_plugin_toml_config(
&mut resolved.gateway,
config_toml_plugin_sources.first(),
Expand Down Expand Up @@ -781,25 +761,16 @@ fn apply_plugin_toml_config(
};
if let Some(config_source) = config_toml_plugin_source {
return Err(CliError::Config(format!(
"plugin config is defined in both {} and {}; choose one source",
"plugin config is defined in both {} and {}; choose one file source",
config_source.display(),
format_paths(&plugin_toml.sources)
)));
}
gateway.plugin_config_source = Some(plugin_toml_source(&plugin_toml.sources));
gateway.plugin_config = Some(plugin_toml.value);
Ok(())
}

fn apply_cli_plugin_config(config: &mut GatewayConfig, value: &str) -> Result<(), CliError> {
if config.plugin_config.is_some() {
return Err(CliError::Config(
"plugin config is defined by both --plugin-config and file configuration; choose one source".into(),
));
}
config.plugin_config = Some(parse_json_option("plugin config", value)?);
Ok(())
}

// Applies configured agent commands and Cursor's temporary-hook behavior. Cursor's
// `patch_restore_hooks` flag is intentionally tri-state in file config so omitted values preserve
// the safe default while explicit `false` disables temporary hook mutation.
Expand Down Expand Up @@ -879,6 +850,9 @@ fn merge_plugin_toml(left: &mut toml::Value, right: toml::Value) {
}
}

// Mirrors the runtime layering merge in `nemo_relay::plugin`
// (`merge_plugin_components` over `serde_json::Value`). Keep the two in sync if
// the by-`kind` component merge rule changes.
fn merge_plugin_components(left: &mut toml::Value, right: toml::Value) {
let toml::Value::Array(left_components) = left else {
*left = right;
Expand Down Expand Up @@ -964,6 +938,14 @@ fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> {
sections
}

fn config_toml_plugin_source(path: &Path) -> String {
format!("[plugins].config in {}", path.display())
}

fn plugin_toml_source(paths: &[PathBuf]) -> String {
format!("plugins.toml {}", format_paths(paths))
}

fn format_paths(paths: &[PathBuf]) -> String {
paths
.iter()
Expand Down
12 changes: 8 additions & 4 deletions crates/cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,18 +577,22 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec<Check> {
checks.push(Check {
name: "Plugins",
status: Status::Info,
details: "plugins.toml not configured".into(),
details: "plugin config not configured".into(),
});
return checks;
};
let source = gateway
.plugin_config_source
.as_deref()
.unwrap_or("plugin config");

let plugin_config = match serde_json::from_value::<PluginConfig>(plugin_value.clone()) {
Ok(config) => config,
Err(err) => {
checks.push(Check {
name: "Plugins",
status: Status::Fail,
details: format!("invalid plugin config: {err}"),
details: format!("invalid plugin config from {source}: {err}"),
});
return checks;
}
Expand All @@ -606,7 +610,7 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec<Check> {
checks.push(Check {
name: "Plugins",
status: Status::Pass,
details: "validation passed".into(),
details: format!("validation passed from {source}"),
});
} else {
for diagnostic in report.diagnostics {
Expand All @@ -617,7 +621,7 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec<Check> {
} else {
Status::Warn
},
details: format!("{}: {}", diagnostic.code, diagnostic.message),
details: format!("{source}: {}: {}", diagnostic.code, diagnostic.message),
});
}
}
Expand Down
4 changes: 0 additions & 4 deletions crates/cli/src/installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ const HERMES_HOOK_EVENTS: &[&str] = &[
/// `--fail-closed` converts missing URLs, HTTP failures, and upstream errors into process errors.
pub(crate) async fn hook_forward(command: HookForwardCommand) -> Result<(), CliError> {
validate_optional_json("session metadata", command.session_metadata.as_deref())?;
validate_optional_json("plugin config", command.plugin_config.as_deref())?;

let input = read_hook_payload()?;
let Some(url) = hook_forward_url(&command)? else {
Expand Down Expand Up @@ -138,7 +137,6 @@ async fn send_hook_forward_request(
.headers(gateway_headers(
command.profile.as_deref(),
command.session_metadata.as_deref(),
command.plugin_config.as_deref(),
command.gateway_mode,
)?)
.header(CONTENT_TYPE, "application/json")
Expand Down Expand Up @@ -435,7 +433,6 @@ fn validate_optional_json(name: &str, value: Option<&str>) -> Result<(), CliErro
fn gateway_headers(
profile: Option<&str>,
session_metadata: Option<&str>,
plugin_config: Option<&str>,
gateway_mode: Option<GatewayMode>,
) -> Result<HeaderMap, CliError> {
let mut headers = HeaderMap::new();
Expand All @@ -445,7 +442,6 @@ fn gateway_headers(
"x-nemo-relay-session-metadata",
session_metadata,
)?;
insert_header(&mut headers, "x-nemo-relay-plugin-config", plugin_config)?;
insert_header(
&mut headers,
"x-nemo-relay-gateway-mode",
Expand Down
7 changes: 6 additions & 1 deletion crates/cli/src/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ pub(crate) async fn easy_path(
openai_base_url: None,
anthropic_base_url: None,
session_metadata: None,
plugin_config: None,
dry_run: false,
print: false,
command: command.command,
Expand Down Expand Up @@ -538,6 +537,9 @@ impl PreparedRun {
));
}
}
if let Some(source) = &resolved.gateway.plugin_config_source {
lines.push(format!(" Plugins {source}"));
}
if !self.notes.is_empty() {
lines.push(String::new());
for note in &self.notes {
Expand Down Expand Up @@ -592,6 +594,9 @@ impl PreparedRun {
if let Some(cursor) = &self.cursor_restore {
println!("cursor_hooks = {}", cursor.path.display());
}
if let Some(source) = &resolved.gateway.plugin_config_source {
println!("plugin_config_source = {source}");
}
for note in &self.notes {
println!("note = {note}");
}
Expand Down
20 changes: 13 additions & 7 deletions crates/cli/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ pub(crate) async fn serve_listener(
config: GatewayConfig,
shutdown: Option<oneshot::Receiver<()>>,
) -> Result<(), CliError> {
let plugin_activation = PluginActivation::initialize(config.plugin_config.clone()).await?;
let plugin_activation = PluginActivation::initialize(
config.plugin_config.clone(),
config.plugin_config_source.as_deref(),
)
.await?;
let state = AppState::new(config);
let sessions = state.sessions.clone();
let app = router_with_state(state);
Expand Down Expand Up @@ -150,18 +154,20 @@ struct PluginActivation {
}

impl PluginActivation {
async fn initialize(config: Option<Value>) -> Result<Self, CliError> {
async fn initialize(config: Option<Value>, source: Option<&str>) -> Result<Self, CliError> {
let Some(config) = config else {
return Ok(Self { active: false });
};
let source = source.unwrap_or("plugin config");
register_adaptive_component().map_err(|error| {
CliError::Config(format!("adaptive plugin registration failed: {error}"))
})?;
let plugin_config: PluginConfig = serde_json::from_value(config)
.map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?;
initialize_plugins(plugin_config)
.await
.map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?;
let plugin_config: PluginConfig = serde_json::from_value(config).map_err(|error| {
CliError::Config(format!("invalid plugin config from {source}: {error}"))
})?;
initialize_plugins(plugin_config).await.map_err(|error| {
CliError::Config(format!("plugin activation failed for {source}: {error}"))
})?;
Ok(Self { active: true })
}

Expand Down
Loading