Skip to content

Commit 385f7ed

Browse files
committed
feat(plugin): load rust_dynamic plugins in relay
Signed-off-by: Will Killian <wkillian@nvidia.com>
1 parent f95e8eb commit 385f7ed

16 files changed

Lines changed: 4058 additions & 35 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/cli/src/launcher.rs

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use crate::error::CliError;
2222
use crate::installer::{
2323
generated_hooks, hook_forward_command, merge_hermes_config, merge_hooks, read_json_file,
2424
};
25+
use crate::plugins::lifecycle::ActiveDynamicPluginComponent;
2526
use crate::server;
2627

2728
/// Runs a child coding-agent command behind an ephemeral local gateway.
@@ -87,6 +88,7 @@ struct TransparentRun {
8788
agent: CodingAgent,
8889
prepared: PreparedRun,
8990
resolved: ResolvedConfig,
91+
dynamic_plugins: Vec<ActiveDynamicPluginComponent>,
9092
listener: TcpListener,
9193
gateway_url: String,
9294
dry_run: bool,
@@ -99,7 +101,15 @@ impl TransparentRun {
99101
async fn new(command: RunCommand, inherited: Option<&ServerArgs>) -> Result<Self, CliError> {
100102
let dry_run = command.dry_run;
101103
let print = command.print;
104+
let explicit_config = command
105+
.config
106+
.as_ref()
107+
.or_else(|| inherited.and_then(|args| args.config.as_ref()));
102108
let mut resolved = resolve_run_config(&command, inherited)?;
109+
let dynamic_plugins = crate::plugins::lifecycle::active_dynamic_plugin_components(
110+
explicit_config,
111+
&resolved,
112+
)?;
103113
let (agent, argv) = resolve_agent_and_argv(&command, &resolved.agents)?;
104114
let listener = TcpListener::bind("127.0.0.1:0").await?;
105115
let address = listener.local_addr()?;
@@ -111,6 +121,7 @@ impl TransparentRun {
111121
agent,
112122
prepared,
113123
resolved,
124+
dynamic_plugins,
114125
listener,
115126
gateway_url,
116127
dry_run,
@@ -134,9 +145,10 @@ impl TransparentRun {
134145
}
135146
self.prepared
136147
.print_live_status(self.agent, &self.gateway_url, &self.resolved);
137-
execute_live_run(
148+
execute_live_run_with_dynamic(
138149
self.listener,
139150
self.resolved.gateway,
151+
self.dynamic_plugins,
140152
&self.gateway_url,
141153
self.prepared,
142154
)
@@ -146,13 +158,24 @@ impl TransparentRun {
146158

147159
// Starts the gateway, waits for readiness, runs the child command, restores temporary state, and then
148160
// maps the child process status to the launcher's exit code.
161+
#[cfg(test)]
149162
async fn execute_live_run(
150163
listener: TcpListener,
151164
gateway_config: GatewayConfig,
152165
gateway_url: &str,
153166
prepared: PreparedRun,
154167
) -> Result<ExitCode, CliError> {
155-
let running_server = RunningGateway::start(listener, gateway_config);
168+
execute_live_run_with_dynamic(listener, gateway_config, Vec::new(), gateway_url, prepared).await
169+
}
170+
171+
async fn execute_live_run_with_dynamic(
172+
listener: TcpListener,
173+
gateway_config: GatewayConfig,
174+
dynamic_plugins: Vec<ActiveDynamicPluginComponent>,
175+
gateway_url: &str,
176+
prepared: PreparedRun,
177+
) -> Result<ExitCode, CliError> {
178+
let running_server = RunningGateway::start(listener, gateway_config, dynamic_plugins);
156179
if let Err(error) = wait_for_health(gateway_url).await {
157180
let restore = prepared.restore();
158181
let server_result = running_server.stop().await;
@@ -269,10 +292,20 @@ struct RunningGateway {
269292
impl RunningGateway {
270293
// Starts the gateway listener on a background task and keeps the shutdown sender paired with the
271294
// task handle so health failures and normal exits use identical cleanup semantics.
272-
fn start(listener: TcpListener, config: crate::config::GatewayConfig) -> Self {
295+
fn start(
296+
listener: TcpListener,
297+
config: crate::config::GatewayConfig,
298+
dynamic_plugins: Vec<ActiveDynamicPluginComponent>,
299+
) -> Self {
273300
let (shutdown_tx, shutdown_rx) = oneshot::channel();
274301
let task = tokio::spawn(async move {
275-
server::serve_listener(listener, config, Some(shutdown_rx)).await
302+
server::serve_listener_with_dynamic(
303+
listener,
304+
config,
305+
dynamic_plugins,
306+
Some(shutdown_rx),
307+
)
308+
.await
276309
});
277310
Self { shutdown_tx, task }
278311
}

crates/cli/src/main.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,12 @@ async fn run_default(server_args: &ServerArgs) -> Result<ExitCode, error::CliErr
196196
// exists. Once configured, bare `nemo-relay` becomes a quick health check; explicit
197197
// `nemo-relay config` remains the reconfiguration path.
198198
if server_args.requested_daemon_mode() {
199-
let config = config::resolve_server_config(server_args)?;
200-
server::serve(config.gateway).await?;
199+
let resolved = config::resolve_server_config(server_args)?;
200+
let dynamic_plugins = plugins::lifecycle::active_dynamic_plugin_components(
201+
server_args.config.as_ref(),
202+
&resolved,
203+
)?;
204+
server::serve_with_dynamic(resolved.gateway, dynamic_plugins).await?;
201205
Ok(ExitCode::SUCCESS)
202206
} else if config::any_config_file_exists() {
203207
doctor::run_doctor(None, false).await

crates/cli/src/plugins/lifecycle.rs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ use std::path::PathBuf;
88
use std::process::ExitCode;
99

1010
use nemo_relay::plugin::dynamic::{
11-
DynamicPluginCheckState, DynamicPluginCompatibility, DynamicPluginLoadContract,
12-
DynamicPluginManifest, DynamicPluginRecord, DynamicPluginValidationStatus,
11+
DynamicPluginCheckState, DynamicPluginCompatibility, DynamicPluginKind,
12+
DynamicPluginLoadContract, DynamicPluginManifest, DynamicPluginRecord,
13+
DynamicPluginValidationStatus,
1314
};
14-
use serde_json::Value;
15+
use serde_json::{Map, Value};
1516

1617
use crate::config::{
1718
PluginsAddCommand, PluginsDisableCommand, PluginsEnableCommand, PluginsInspectCommand,
@@ -339,6 +340,51 @@ pub(crate) fn remove(command: PluginsRemoveCommand, server: &ServerArgs) -> Resu
339340
Ok(())
340341
}
341342

343+
#[derive(Debug, Clone, PartialEq, Eq)]
344+
pub(crate) struct ActiveDynamicPluginComponent {
345+
pub(crate) plugin_id: String,
346+
pub(crate) kind: DynamicPluginKind,
347+
pub(crate) manifest_ref: String,
348+
pub(crate) config: Map<String, Value>,
349+
}
350+
351+
pub(crate) fn active_dynamic_plugin_components(
352+
explicit: Option<&PathBuf>,
353+
resolved: &ResolvedConfig,
354+
) -> Result<Vec<ActiveDynamicPluginComponent>, CliError> {
355+
let scopes = load_and_hydrate_scopes(explicit, resolved)?;
356+
let host_config_by_id = host_config_by_id(resolved);
357+
let mut components = Vec::new();
358+
359+
for resolved_plugin in &resolved.dynamic_plugins {
360+
let Some(entry) = find_record_by_id(&scopes, &resolved_plugin.plugin_id)? else {
361+
return Err(CliError::Config(format!(
362+
"dynamic plugin '{}' is present in resolved config but not lifecycle state",
363+
resolved_plugin.plugin_id
364+
)));
365+
};
366+
if entry.record.is_tombstoned() || !entry.record.spec.enabled {
367+
continue;
368+
}
369+
let host_config = host_config_by_id
370+
.get(&entry.record.metadata.id)
371+
.ok_or_else(|| {
372+
CliError::Config(format!(
373+
"dynamic plugin '{}' is enabled but has no resolved host config",
374+
entry.record.metadata.id
375+
))
376+
})?;
377+
components.push(ActiveDynamicPluginComponent {
378+
plugin_id: entry.record.metadata.id.clone(),
379+
kind: entry.record.metadata.kind,
380+
manifest_ref: manifest_ref_from_record(&entry.record)?,
381+
config: host_config.config.clone(),
382+
});
383+
}
384+
385+
Ok(components)
386+
}
387+
342388
fn mutate_enabled_state(
343389
plugin_id: String,
344390
server: &ServerArgs,

crates/cli/src/server.rs

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ use axum::extract::{DefaultBodyLimit, State};
99
use axum::http::HeaderMap;
1010
use axum::routing::{get, post};
1111
use axum::{Json, Router};
12-
use nemo_relay::plugin::{PluginConfig, clear_plugin_configuration, initialize_plugins_exact};
12+
use nemo_relay::plugin::dynamic::{
13+
DynamicPluginKind, NativePluginActivation, NativePluginLoadSpec, load_native_plugins,
14+
};
15+
use nemo_relay::plugin::{
16+
PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins_exact,
17+
};
1318
use nemo_relay_adaptive::plugin_component::register_adaptive_component;
1419
use nemo_relay_pii_redaction::component::register_pii_redaction_component;
1520
use reqwest::Client;
@@ -21,6 +26,7 @@ use crate::adapters::{claude_code, codex, cursor, hermes};
2126
use crate::config::GatewayConfig;
2227
use crate::error::CliError;
2328
use crate::gateway;
29+
use crate::plugins::lifecycle::ActiveDynamicPluginComponent;
2430
use crate::session::SessionManager;
2531

2632
const HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
@@ -35,11 +41,11 @@ pub(crate) struct AppState {
3541
pub(crate) last_activity: Arc<Mutex<Instant>>,
3642
}
3743

38-
/// Binds the configured address and serves until the process is stopped.
39-
///
40-
/// Tests and transparent run mode use `serve_listener` directly so they can supply an already
41-
/// bound ephemeral listener and optional shutdown channel.
42-
pub(crate) async fn serve(config: GatewayConfig) -> Result<(), CliError> {
44+
/// Binds the configured address and activates enabled dynamic plugins before serving.
45+
pub(crate) async fn serve_with_dynamic(
46+
config: GatewayConfig,
47+
dynamic_plugins: Vec<ActiveDynamicPluginComponent>,
48+
) -> Result<(), CliError> {
4349
let listener = TcpListener::bind(config.bind).await.map_err(|err| {
4450
// Translate the common bind-failure (port already in use) into an actionable message.
4551
// Plain `io error: Address already in use (os error 48)` is unhelpful; the friendly
@@ -58,19 +64,31 @@ pub(crate) async fn serve(config: GatewayConfig) -> Result<(), CliError> {
5864
CliError::Io(err)
5965
}
6066
})?;
61-
serve_listener(listener, config, None).await
67+
serve_listener_with_dynamic(listener, config, dynamic_plugins, None).await
6268
}
6369

6470
/// Serves the gateway router on a caller-owned listener with optional graceful shutdown.
6571
///
6672
/// A provided shutdown receiver is best-effort: the send side may be dropped after the child agent
6773
/// exits, and either receiving or channel closure is enough to let Axum drain the listener.
74+
#[cfg(test)]
6875
pub(crate) async fn serve_listener(
6976
listener: TcpListener,
7077
config: GatewayConfig,
7178
shutdown: Option<oneshot::Receiver<()>>,
7279
) -> Result<(), CliError> {
73-
let plugin_activation = PluginActivation::initialize(config.plugin_config.clone()).await?;
80+
serve_listener_with_dynamic(listener, config, Vec::new(), shutdown).await
81+
}
82+
83+
/// Serves the gateway router and activates enabled dynamic plugin components.
84+
pub(crate) async fn serve_listener_with_dynamic(
85+
listener: TcpListener,
86+
config: GatewayConfig,
87+
dynamic_plugins: Vec<ActiveDynamicPluginComponent>,
88+
shutdown: Option<oneshot::Receiver<()>>,
89+
) -> Result<(), CliError> {
90+
let plugin_activation =
91+
PluginActivation::initialize(config.plugin_config.clone(), dynamic_plugins).await?;
7492
let state = AppState::new(config);
7593
let sessions = state.sessions.clone();
7694
let last_activity = state.last_activity.clone();
@@ -197,35 +215,79 @@ async fn idle_shutdown_future(
197215

198216
struct PluginActivation {
199217
active: bool,
218+
native: Option<NativePluginActivation>,
200219
}
201220

202221
impl PluginActivation {
203-
async fn initialize(config: Option<Value>) -> Result<Self, CliError> {
204-
let Some(config) = config else {
205-
return Ok(Self { active: false });
222+
async fn initialize(
223+
config: Option<Value>,
224+
dynamic_plugins: Vec<ActiveDynamicPluginComponent>,
225+
) -> Result<Self, CliError> {
226+
if config.is_none() && dynamic_plugins.is_empty() {
227+
return Ok(Self {
228+
active: false,
229+
native: None,
230+
});
206231
};
207232
register_adaptive_component().map_err(|error| {
208233
CliError::Config(format!("adaptive plugin registration failed: {error}"))
209234
})?;
210235
register_pii_redaction_component().map_err(|error| {
211236
CliError::Config(format!("PII redaction plugin registration failed: {error}"))
212237
})?;
238+
let native_specs = dynamic_plugins
239+
.iter()
240+
.filter(|plugin| plugin.kind == DynamicPluginKind::RustDynamic)
241+
.map(|plugin| NativePluginLoadSpec {
242+
plugin_id: plugin.plugin_id.clone(),
243+
manifest_ref: plugin.manifest_ref.clone(),
244+
})
245+
.collect::<Vec<_>>();
246+
let native =
247+
if native_specs.is_empty() {
248+
None
249+
} else {
250+
Some(load_native_plugins(native_specs).map_err(|error| {
251+
CliError::Config(format!("native plugin load failed: {error}"))
252+
})?)
253+
};
213254
// Gateway already resolved its config; activate exactly (no re-discovery).
214-
let plugin_config: PluginConfig = serde_json::from_value(config)
215-
.map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?;
255+
let mut plugin_config: PluginConfig = match config {
256+
Some(config) => serde_json::from_value(config)
257+
.map_err(|error| CliError::Config(format!("invalid plugin config: {error}")))?,
258+
None => PluginConfig::default(),
259+
};
260+
plugin_config
261+
.components
262+
.extend(
263+
dynamic_plugins
264+
.into_iter()
265+
.map(|plugin| PluginComponentSpec {
266+
kind: plugin.plugin_id,
267+
enabled: true,
268+
config: plugin.config,
269+
}),
270+
);
216271
initialize_plugins_exact(plugin_config)
217272
.await
218273
.map_err(|error| CliError::Config(format!("plugin activation failed: {error}")))?;
219-
Ok(Self { active: true })
274+
Ok(Self {
275+
active: true,
276+
native,
277+
})
220278
}
221279

222280
fn clear(mut self) -> Result<(), CliError> {
223-
if self.active {
281+
let result = if self.active {
224282
self.active = false;
225283
clear_plugin_configuration()
226284
.map_err(|error| CliError::Config(format!("plugin teardown failed: {error}")))?;
227-
}
228-
Ok(())
285+
Ok(())
286+
} else {
287+
Ok(())
288+
};
289+
self.native.take();
290+
result
229291
}
230292
}
231293

0 commit comments

Comments
 (0)