use std::collections::VecDeque;
use std::process::Stdio;
use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use dashmap::DashMap;
use nexo_broker::{topic::topic_matches, AnyBroker, BrokerHandle, Event};
use nexo_config::LlmConfig;
use nexo_llm::LlmRegistry;
use nexo_memory::LongTermMemory;
use nexo_plugin_manifest::PluginManifest;
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{mpsc, oneshot, Mutex, OnceCell};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::agent::nexo_plugin_registry::factory::PluginFactory;
use crate::agent::plugin_host::{
NexoPlugin, PluginInitContext, PluginInitError, PluginShutdownError,
};
const DEFAULT_INIT_TIMEOUT_MS: u64 = 5_000;
const STDIN_CHANNEL_DEPTH: usize = 64;
pub struct SubprocessNexoPlugin {
cached_manifest: PluginManifest,
inner: Mutex<Option<Inner>>,
sandbox: Mutex<Option<Arc<crate::agent::plugin_sandbox::SandboxRunner>>>,
plugin_state_dir: Mutex<Option<std::path::PathBuf>>,
spawn_env: Option<std::collections::HashMap<String, String>>,
instance_label: Option<String>,
}
struct Inner {
stdin_tx: mpsc::Sender<Value>,
pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>>,
next_id: Arc<AtomicU64>,
streaming_pending: Arc<DashMap<u64, crate::agent::llm_remote::StreamingPending>>,
declared_tools: Vec<crate::agent::tool_remote::RemoteToolDef>,
tasks: Vec<JoinHandle<()>>,
child: Arc<Mutex<Option<Child>>>,
cancel: CancellationToken,
}
impl SubprocessNexoPlugin {
pub fn new(manifest: PluginManifest) -> Self {
Self {
cached_manifest: manifest,
inner: Mutex::new(None),
sandbox: Mutex::new(None),
plugin_state_dir: Mutex::new(None),
spawn_env: None,
instance_label: None,
}
}
pub fn with_spawn_env(mut self, env: std::collections::HashMap<String, String>) -> Self {
self.spawn_env = Some(env);
self
}
pub fn with_instance_label(mut self, label: impl Into<String>) -> Self {
let label = label.into();
self.instance_label = if label.trim().is_empty() {
None
} else {
Some(label)
};
self
}
pub async fn register_remote_vector_backends(
&self,
registry: &Arc<crate::agent::vector_backend_registry::VectorBackendRegistry>,
) -> Result<Vec<String>, crate::agent::vector_backend_registry::VectorBackendRegistrationError>
{
let backends = self.cached_manifest.plugin.extends.memory_backends.clone();
if backends.is_empty() {
return Ok(Vec::new());
}
let plugin_id = self.cached_manifest.plugin.id.clone();
let (stdin_tx, pending, next_id) = {
let guard = self.inner.lock().await;
match guard.as_ref() {
Some(inner) => (
inner.stdin_tx.clone(),
inner.pending.clone(),
inner.next_id.clone(),
),
None => {
return Err(
crate::agent::vector_backend_registry::VectorBackendRegistrationError::InnerUnavailable,
);
}
}
};
let mut registered: Vec<String> = Vec::new();
for name in backends {
let backend: Arc<dyn nexo_memory::VectorBackend> =
Arc::new(crate::agent::vector_remote::RemoteVectorBackend::new(
name.clone(),
plugin_id.clone(),
stdin_tx.clone(),
pending.clone(),
next_id.clone(),
));
match registry.register(backend, plugin_id.clone()) {
Ok(()) => registered.push(name),
Err(e) => {
for prior in ®istered {
registry.unregister(prior, &plugin_id);
}
return Err(e);
}
}
}
Ok(registered)
}
pub async fn register_remote_hook_handlers(
&self,
hook_registry: &Arc<crate::agent::hook_registry::HookRegistry>,
) -> Result<Vec<String>, crate::agent::hook_remote::HookHandlerRegistrationError> {
let hooks = self.cached_manifest.plugin.extends.hooks.clone();
if hooks.is_empty() {
return Ok(Vec::new());
}
let plugin_id = self.cached_manifest.plugin.id.clone();
let (stdin_tx, pending, next_id) = {
let guard = self.inner.lock().await;
match guard.as_ref() {
Some(inner) => (
inner.stdin_tx.clone(),
inner.pending.clone(),
inner.next_id.clone(),
),
None => {
return Err(
crate::agent::hook_remote::HookHandlerRegistrationError::InnerUnavailable,
);
}
}
};
let mut registered: Vec<String> = Vec::new();
for hook_name in hooks {
let handler = crate::agent::hook_remote::RemoteHookHandler::new(
hook_name.clone(),
plugin_id.clone(),
stdin_tx.clone(),
pending.clone(),
next_id.clone(),
);
hook_registry.register(&hook_name, plugin_id.clone(), handler);
registered.push(hook_name);
}
Ok(registered)
}
pub async fn register_remote_tool_handlers(
&self,
scoped_registry: &Arc<crate::agent::scoped_tool_registry::ScopedToolRegistry>,
) -> Result<Vec<String>, crate::agent::tool_remote::ToolHandlerRegistrationError> {
let declared = self.cached_manifest.plugin.extends.tools.clone();
if declared.is_empty() {
return Ok(Vec::new());
}
let plugin_id = self.cached_manifest.plugin.id.clone();
let (stdin_tx, pending, next_id, advertised) = {
let guard = self.inner.lock().await;
match guard.as_ref() {
Some(inner) => (
inner.stdin_tx.clone(),
inner.pending.clone(),
inner.next_id.clone(),
inner.declared_tools.clone(),
),
None => {
return Err(
crate::agent::tool_remote::ToolHandlerRegistrationError::InnerUnavailable,
);
}
}
};
let mut registered: Vec<String> = Vec::new();
for tool_name in declared {
let def_match = advertised.iter().find(|d| d.name == tool_name).cloned();
let def = match def_match {
Some(d) => d,
None => continue,
};
let handler = crate::agent::tool_remote::RemoteToolHandler::new(
plugin_id.clone(),
def.clone(),
stdin_tx.clone(),
pending.clone(),
next_id.clone(),
);
let tool_def = nexo_llm::ToolDef {
name: def.name.clone(),
description: def.description.clone(),
parameters: def.input_schema.clone(),
};
match scoped_registry.register_arc(
tool_def,
Arc::new(handler) as Arc<dyn crate::agent::tool_registry::ToolHandler>,
) {
Ok(()) => registered.push(def.name.clone()),
Err(_violation) => {
return Err(
crate::agent::tool_remote::ToolHandlerRegistrationError::ToolNameAlreadyRegistered {
tool_name: def.name,
prior_plugin_hint: "unknown".to_string(),
},
);
}
}
}
Ok(registered)
}
pub async fn register_remote_llm_providers(
&self,
llm_registry: &Arc<nexo_llm::LlmRegistry>,
) -> Result<Vec<String>, crate::agent::llm_remote::LlmProviderRegistrationError> {
let providers = self.cached_manifest.plugin.extends.llm_providers.clone();
if providers.is_empty() {
return Ok(Vec::new());
}
let plugin_id = self.cached_manifest.plugin.id.clone();
let (stdin_tx, pending, streaming_pending, next_id) = {
let guard = self.inner.lock().await;
match guard.as_ref() {
Some(inner) => (
inner.stdin_tx.clone(),
inner.pending.clone(),
inner.streaming_pending.clone(),
inner.next_id.clone(),
),
None => {
return Err(
crate::agent::llm_remote::LlmProviderRegistrationError::InnerUnavailable,
);
}
}
};
let mut registered: Vec<String> = Vec::new();
for provider in providers {
let factory = crate::agent::llm_remote::RemoteLlmFactory::new(
provider.clone(),
plugin_id.clone(),
stdin_tx.clone(),
pending.clone(),
streaming_pending.clone(),
next_id.clone(),
);
match llm_registry.register(Box::new(factory)) {
Ok(()) => registered.push(provider),
Err(_) => {
for prior in ®istered {
llm_registry.unregister(prior);
}
return Err(
crate::agent::llm_remote::LlmProviderRegistrationError::AlreadyRegistered {
name: provider,
},
);
}
}
}
Ok(registered)
}
pub async fn register_remote_channel_adapters(
&self,
channel_registry: &Arc<crate::agent::channel_adapter::ChannelAdapterRegistry>,
) -> Result<Vec<String>, crate::agent::channel_adapter::ChannelAdapterRegistrationError> {
let kinds = self.cached_manifest.plugin.extends.channels.clone();
if kinds.is_empty() {
return Ok(Vec::new());
}
let plugin_id = self.cached_manifest.plugin.id.clone();
let (stdin_tx, pending, next_id) = {
let guard = self.inner.lock().await;
match guard.as_ref() {
Some(inner) => (
inner.stdin_tx.clone(),
inner.pending.clone(),
inner.next_id.clone(),
),
None => return Ok(Vec::new()),
}
};
let mut registered: Vec<String> = Vec::new();
for kind in kinds {
let adapter = Arc::new(crate::agent::channel_adapter::RemoteChannelAdapter::new(
kind.clone(),
plugin_id.clone(),
stdin_tx.clone(),
pending.clone(),
next_id.clone(),
));
match channel_registry.register(adapter, &plugin_id) {
Ok(()) => registered.push(kind),
Err(e) => {
for prior in ®istered {
channel_registry.unregister(prior, &plugin_id);
}
return Err(e);
}
}
}
Ok(registered)
}
async fn spawn_and_handshake(
&self,
ctx_shutdown: CancellationToken,
broker: Option<AnyBroker>,
memory: Option<Arc<LongTermMemory>>,
llm: Option<LlmServices>,
) -> Result<Inner, anyhow::Error> {
let entry = &self.cached_manifest.plugin.entrypoint;
let command = entry
.command
.clone()
.ok_or_else(|| anyhow::anyhow!("manifest has no entrypoint.command — cannot spawn"))?;
if command.trim().is_empty() {
anyhow::bail!("manifest entrypoint.command is empty");
}
for key in entry.env.keys() {
if key.starts_with("NEXO_") {
anyhow::bail!("manifest entrypoint.env may not redefine reserved nexo env `{key}`");
}
}
let (program, prog_args) = {
let runner_guard = self.sandbox.lock().await;
let state_guard = self.plugin_state_dir.lock().await;
match (runner_guard.as_ref(), state_guard.as_ref()) {
(Some(runner), Some(state_dir)) => {
let wrapped = runner
.wrap_command(&self.cached_manifest, state_dir, &command, &entry.args)
.map_err(|e| anyhow::anyhow!("sandbox setup failed: {e}"))?;
if let Some(diag) = &wrapped.diagnostic {
tracing::warn!(
target: "plugin.sandbox",
plugin_id = %self.cached_manifest.plugin.id,
"{}",
diag
);
}
(wrapped.program, wrapped.args)
}
_ => (
std::path::PathBuf::from(&command),
entry
.args
.iter()
.map(std::ffi::OsString::from)
.collect::<Vec<_>>(),
),
}
};
let mut cmd = Command::new(&program);
cmd.args(&prog_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(env_map) = &self.spawn_env {
cmd.env_clear()
.envs(env_map.iter().map(|(k, v)| (k.as_str(), v.as_str())));
}
for (k, v) in &entry.env {
cmd.env(k, v);
}
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("spawn `{command}` failed: {e}"))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow::anyhow!("child has no stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("child has no stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| anyhow::anyhow!("child has no stderr"))?;
let child_handle: Arc<Mutex<Option<Child>>> = Arc::new(Mutex::new(Some(child)));
let cancel = ctx_shutdown.child_token();
let pending: Arc<DashMap<u64, oneshot::Sender<Result<Value, String>>>> =
Arc::new(DashMap::new());
let streaming_pending: Arc<DashMap<u64, crate::agent::llm_remote::StreamingPending>> =
Arc::new(DashMap::new());
let (stdin_tx, mut stdin_rx) = mpsc::channel::<Value>(STDIN_CHANNEL_DEPTH);
let writer_cancel = cancel.clone();
let writer_handle = tokio::spawn(async move {
let mut stdin = stdin;
loop {
let v = tokio::select! {
biased;
_ = writer_cancel.cancelled() => return,
next = stdin_rx.recv() => match next {
Some(v) => v,
None => return, },
};
let line = match serde_json::to_string(&v) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = %e, "subprocess plugin: drop frame, serialize failed");
continue;
}
};
if let Err(e) = stdin.write_all(line.as_bytes()).await {
tracing::warn!(error = %e, "subprocess plugin: stdin write failed");
return;
}
if let Err(e) = stdin.write_all(b"\n").await {
tracing::warn!(error = %e, "subprocess plugin: stdin newline failed");
return;
}
if let Err(e) = stdin.flush().await {
tracing::warn!(error = %e, "subprocess plugin: stdin flush failed");
return;
}
}
});
let stderr_cancel = cancel.clone();
let stderr_plugin_id = self.cached_manifest.plugin.id.clone();
let stderr_tail_capacity = self.cached_manifest.plugin.supervisor.stderr_tail_lines;
let stderr_tail: Arc<Mutex<VecDeque<String>>> = Arc::new(Mutex::new(
VecDeque::with_capacity(stderr_tail_capacity.max(1)),
));
let stderr_tail_for_reader = stderr_tail.clone();
let stderr_handle = tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
loop {
let line = tokio::select! {
biased;
_ = stderr_cancel.cancelled() => return,
next = reader.next_line() => match next {
Ok(Some(l)) => l,
Ok(None) => return, Err(e) => {
tracing::warn!(
target: "plugin.stderr",
plugin_id = %stderr_plugin_id,
error = %e,
"stderr read failed"
);
return;
}
},
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
tracing::info!(
target: "plugin.stderr",
plugin_id = %stderr_plugin_id,
line = %trimmed,
"child stderr"
);
if stderr_tail_capacity > 0 {
let mut buf = stderr_tail_for_reader.lock().await;
if buf.len() >= stderr_tail_capacity {
buf.pop_front();
}
buf.push_back(trimmed.to_string());
}
}
});
let init_id: u64 = 1;
let init_req = json!({
"jsonrpc": "2.0",
"id": init_id,
"method": "initialize",
"params": { "nexo_version": env!("CARGO_PKG_VERSION") },
});
let (init_tx, init_rx) = oneshot::channel::<Result<Value, String>>();
pending.insert(init_id, init_tx);
let bridge_cell: Arc<OnceCell<BridgeContext>> = Arc::new(OnceCell::new());
let plugin_id_for_log = self.cached_manifest.plugin.id.clone();
let reader_cancel = cancel.clone();
let reader_pending = pending.clone();
let reader_streaming_pending = streaming_pending.clone();
let reader_stdin_tx = stdin_tx.clone();
let reader_plugin_id = plugin_id_for_log.clone();
let reader_bridge = bridge_cell.clone();
let reader_handle = tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
loop {
let line = tokio::select! {
biased;
_ = reader_cancel.cancelled() => return,
next = reader.next_line() => match next {
Ok(Some(l)) => l,
Ok(None) => return, Err(e) => {
tracing::warn!(error = %e, plugin = %reader_plugin_id, "stdout read failed");
return;
}
},
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let parsed: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => {
tracing::info!(
target: "plugin.stdout",
plugin_id = %reader_plugin_id,
line = %trimmed,
"child stdout (non-json)"
);
continue;
}
};
let id_val = parsed.get("id").cloned();
let method_str = parsed.get("method").and_then(|v| v.as_str()).unwrap_or("");
if id_val.is_some() && !method_str.is_empty() {
let id_for_reply = id_val.clone().unwrap_or(Value::Null);
let params = parsed.get("params").cloned().unwrap_or(Value::Null);
let response = match reader_bridge.get() {
Some(bridge) => {
handle_child_request(
bridge,
&reader_plugin_id,
method_str,
¶ms,
&reader_stdin_tx,
&id_for_reply,
)
.await
}
None => Err((-32603, "host services not yet wired".to_string())),
};
let frame = match response {
Ok(result) => json!({
"jsonrpc": "2.0",
"id": id_for_reply,
"result": result,
}),
Err((code, msg)) => json!({
"jsonrpc": "2.0",
"id": id_for_reply,
"error": { "code": code, "message": msg },
}),
};
if let Err(e) = reader_stdin_tx.try_send(frame) {
tracing::warn!(
plugin = %reader_plugin_id,
error = %e,
"memory.recall response dropped: stdin queue full or closed"
);
}
continue;
}
if let Some(id) = id_val.as_ref().and_then(|v| v.as_u64()) {
if let Some((_, sender)) = reader_pending.remove(&id) {
let payload = if let Some(err) = parsed.get("error") {
Err(err.to_string())
} else {
Ok(parsed.get("result").cloned().unwrap_or(Value::Null))
};
let _ = sender.send(payload);
continue;
}
if let Some((_, entry)) = reader_streaming_pending.remove(&id) {
let payload = if let Some(err) = parsed.get("error") {
Err(err.to_string())
} else {
let result = parsed.get("result").cloned().unwrap_or(Value::Null);
match serde_json::from_value::<
crate::agent::llm_remote::wire::WireChatResponse,
>(result)
{
Ok(wire) => {
Ok(crate::agent::llm_remote::wire::wire_to_response(wire))
}
Err(e) => Err(format!("decode WireChatResponse: {e}")),
}
};
let _ = entry.final_tx.send(payload);
continue;
}
tracing::warn!(
id,
plugin = %reader_plugin_id,
"stdout: response with unknown id"
);
continue;
}
let method = parsed.get("method").and_then(|v| v.as_str()).unwrap_or("");
if method == "broker.publish" {
let Some(bridge) = reader_bridge.get() else {
tracing::debug!(
plugin = %reader_plugin_id,
"broker.publish before bridge active — drop"
);
continue;
};
handle_child_publish(bridge, &reader_plugin_id, &parsed).await;
continue;
}
if method == "llm.chat.delta" {
let request_id = parsed
.get("params")
.and_then(|p| p.get("request_id"))
.and_then(|v| v.as_u64());
let chunk_value = parsed.get("params").and_then(|p| p.get("chunk")).cloned();
let (Some(rid), Some(chunk_value)) = (request_id, chunk_value) else {
tracing::warn!(
plugin = %reader_plugin_id,
"llm.chat.delta missing request_id / chunk — drop"
);
continue;
};
let wire: crate::agent::llm_remote::wire::WireStreamChunk =
match serde_json::from_value(chunk_value) {
Ok(w) => w,
Err(e) => {
tracing::warn!(
plugin = %reader_plugin_id,
error = %e,
"llm.chat.delta chunk parse failed — drop"
);
continue;
}
};
let chunk = crate::agent::llm_remote::wire::wire_to_chunk(wire);
if let Some(entry) = reader_streaming_pending.get(&rid) {
if entry.delta_tx.send(chunk).is_err() {
tracing::debug!(
plugin = %reader_plugin_id,
request_id = rid,
"llm.chat.delta consumer dropped — chunk discarded"
);
}
} else {
tracing::debug!(
plugin = %reader_plugin_id,
request_id = rid,
"llm.chat.delta with unknown request_id — drop"
);
}
continue;
}
tracing::debug!(
plugin = %reader_plugin_id,
method,
"stdout notification: unhandled method (deferred to 81.20)"
);
}
});
let timeout = Duration::from_millis(
std::env::var("NEXO_PLUGIN_INIT_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_INIT_TIMEOUT_MS),
);
if let Err(e) = stdin_tx.send(init_req).await {
anyhow::bail!("subprocess plugin: stdin channel closed before initialize: {e}");
}
let init_result = tokio::time::timeout(timeout, init_rx).await;
let result = match init_result {
Ok(Ok(Ok(v))) => v,
Ok(Ok(Err(err))) => {
cancel.cancel();
kill_handle(&child_handle).await;
anyhow::bail!("child returned error to initialize: {err}");
}
Ok(Err(_canceled)) => {
cancel.cancel();
kill_handle(&child_handle).await;
anyhow::bail!("initialize oneshot canceled before reply");
}
Err(_elapsed) => {
cancel.cancel();
kill_handle(&child_handle).await;
anyhow::bail!(
"child did not reply to initialize within {}ms",
timeout.as_millis()
);
}
};
let returned_id = result
.pointer("/manifest/plugin/id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("initialize reply missing manifest.plugin.id"))?;
if returned_id != self.cached_manifest.plugin.id {
cancel.cancel();
kill_handle(&child_handle).await;
anyhow::bail!(
"manifest id mismatch: factory expected `{}`, child reported `{}`",
self.cached_manifest.plugin.id,
returned_id
);
}
let declared_tools: Vec<crate::agent::tool_remote::RemoteToolDef> = match result
.pointer("/tools")
{
Some(Value::Array(arr)) => {
let mut out = Vec::with_capacity(arr.len());
for item in arr {
let def: crate::agent::tool_remote::RemoteToolDef =
serde_json::from_value(item.clone()).map_err(|e| {
anyhow::anyhow!("initialize reply tools[]: malformed entry: {e}")
})?;
if !self
.cached_manifest
.plugin
.extends
.tools
.iter()
.any(|t| t == &def.name)
{
cancel.cancel();
kill_handle(&child_handle).await;
anyhow::bail!(
"initialize reply advertises undeclared tool `{}` (not in extends.tools = {:?})",
def.name,
self.cached_manifest.plugin.extends.tools
);
}
out.push(def);
}
out
}
Some(_) => {
cancel.cancel();
kill_handle(&child_handle).await;
anyhow::bail!("initialize reply field `tools` must be an array if present");
}
None => Vec::new(),
};
for t in &self.cached_manifest.plugin.extends.tools {
if !declared_tools.iter().any(|d| &d.name == t) {
tracing::warn!(
target: "plugin.tools",
plugin_id = %self.cached_manifest.plugin.id,
tool = %t,
"manifest declares extends.tools entry but plugin did not advertise it in initialize-reply — runtime calls will fail with ToolNotFound"
);
}
}
let supervisor_broker = broker.clone();
let supervisor_child = child_handle.clone();
let supervisor_cancel = cancel.clone();
let supervisor_plugin_id = self.cached_manifest.plugin.id.clone();
let supervisor_stderr_tail = stderr_tail.clone();
let supervisor_respawn_requested = self.cached_manifest.plugin.supervisor.respawn;
let supervisor_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(500));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
biased;
_ = supervisor_cancel.cancelled() => return,
_ = interval.tick() => {}
}
let exit_status = {
let mut guard = supervisor_child.lock().await;
if let Some(ref mut c) = *guard {
c.try_wait()
} else {
return;
}
};
match exit_status {
Ok(None) => continue, Ok(Some(status)) => {
let exit_code = status.code().unwrap_or(-1);
tracing::warn!(
target: "plugin.supervisor",
plugin_id = %supervisor_plugin_id,
exit_code,
"subprocess plugin exited unexpectedly"
);
let stderr_tail_drained: Vec<String> = {
let mut buf = supervisor_stderr_tail.lock().await;
buf.drain(..).collect()
};
if supervisor_respawn_requested {
tracing::info!(
target: "plugin.supervisor",
plugin_id = %supervisor_plugin_id,
respawn_requested = true,
"respawn config not yet wired (Phase 81.21.b.b); operator must restart the daemon to recover"
);
}
if let Some(broker) = supervisor_broker.as_ref() {
let topic =
format!("plugin.lifecycle.{}.crashed", supervisor_plugin_id);
let payload = json!({
"plugin_id": supervisor_plugin_id,
"exit_code": exit_code,
"stderr_tail": stderr_tail_drained,
});
let event = Event::new(&topic, "plugin.supervisor", payload);
if let Err(e) = broker.publish(&topic, event).await {
tracing::warn!(
target: "plugin.supervisor",
plugin_id = %supervisor_plugin_id,
error = %e,
"broker publish of crashed event failed"
);
}
}
supervisor_cancel.cancel();
return;
}
Err(e) => {
tracing::warn!(
target: "plugin.supervisor",
plugin_id = %supervisor_plugin_id,
error = %e,
"subprocess plugin try_wait failed"
);
return;
}
}
}
});
let mut tasks = vec![
writer_handle,
reader_handle,
stderr_handle,
supervisor_handle,
];
if let Some(broker) = broker {
let kinds: Vec<String> = self
.cached_manifest
.plugin
.channels
.register
.iter()
.map(|c| c.kind.clone())
.collect();
let mut subscribe_patterns: Vec<String> = Vec::with_capacity(kinds.len() * 2);
let mut publish_allowlist: Vec<String> = Vec::with_capacity(kinds.len() * 2);
for kind in &kinds {
subscribe_patterns.push(format!("plugin.outbound.{kind}"));
subscribe_patterns.push(format!("plugin.outbound.{kind}.>"));
publish_allowlist.push(format!("plugin.inbound.{kind}"));
publish_allowlist.push(format!("plugin.inbound.{kind}.>"));
}
if let Some(broker_cap) = self.cached_manifest.plugin.capabilities.broker.as_ref() {
for pattern in &broker_cap.subscribe {
if !subscribe_patterns.iter().any(|p| p == pattern) {
subscribe_patterns.push(pattern.clone());
}
}
for pattern in &broker_cap.publish {
if !publish_allowlist.iter().any(|p| p == pattern) {
publish_allowlist.push(pattern.clone());
}
}
}
for pattern in subscribe_patterns {
let mut sub = match broker.subscribe(&pattern).await {
Ok(s) => s,
Err(e) => {
cancel.cancel();
kill_handle(&child_handle).await;
anyhow::bail!(
"subprocess plugin: broker subscribe `{pattern}` failed: {e}"
);
}
};
let stdin_tx_for_fwd = stdin_tx.clone();
let cancel_for_fwd = cancel.clone();
let plugin_id_for_fwd = plugin_id_for_log.clone();
let task = tokio::spawn(async move {
loop {
let event = tokio::select! {
biased;
_ = cancel_for_fwd.cancelled() => return,
ev = sub.next() => match ev {
Some(e) => e,
None => return,
},
};
let frame = json!({
"jsonrpc": "2.0",
"method": "broker.event",
"params": {
"topic": event.topic.clone(),
"event": event,
},
});
match stdin_tx_for_fwd.try_send(frame) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
tracing::warn!(
plugin = %plugin_id_for_fwd,
"stdin queue full — dropping broker event for child"
);
}
Err(mpsc::error::TrySendError::Closed(_)) => return,
}
}
});
tasks.push(task);
}
let _ = bridge_cell.set(BridgeContext {
broker,
publish_allowlist,
memory,
llm,
});
} else if memory.is_some() || llm.is_some() {
tracing::debug!(
target: "plugin.subprocess",
plugin_id = %plugin_id_for_log,
"memory or llm services provided without broker — bridge inactive, RPC path won't fire"
);
}
Ok(Inner {
stdin_tx,
pending,
next_id: Arc::new(AtomicU64::new(2)),
streaming_pending: Arc::new(DashMap::new()),
declared_tools,
tasks,
child: child_handle,
cancel,
})
}
}
async fn kill_handle(h: &Arc<Mutex<Option<Child>>>) {
let mut guard = h.lock().await;
if let Some(mut c) = guard.take() {
let _ = c.kill().await;
}
}
struct TokenUsageOut {
prompt_tokens: u32,
completion_tokens: u32,
}
#[derive(Clone)]
pub struct LlmServices {
pub registry: Arc<LlmRegistry>,
pub config: Arc<LlmConfig>,
}
struct BridgeContext {
broker: AnyBroker,
publish_allowlist: Vec<String>,
memory: Option<Arc<LongTermMemory>>,
llm: Option<LlmServices>,
}
async fn handle_child_publish(bridge: &BridgeContext, plugin_id: &str, parsed: &Value) {
let topic = parsed
.pointer("/params/topic")
.and_then(|v| v.as_str())
.unwrap_or("");
if topic.is_empty() {
tracing::warn!(
plugin = %plugin_id,
"broker.publish: empty topic — drop"
);
return;
}
if !bridge
.publish_allowlist
.iter()
.any(|pat| topic_matches(pat, topic))
{
tracing::warn!(
plugin = %plugin_id,
topic,
"broker.publish: topic outside child's inbound allowlist — drop"
);
return;
}
let event_val = parsed
.pointer("/params/event")
.cloned()
.unwrap_or(Value::Null);
let event: Event = match serde_json::from_value(event_val) {
Ok(e) => e,
Err(e) => {
tracing::warn!(
plugin = %plugin_id,
topic,
error = %e,
"broker.publish: deserialize Event failed — drop"
);
return;
}
};
if let Err(e) = bridge.broker.publish(topic, event).await {
tracing::warn!(
plugin = %plugin_id,
topic,
error = %e,
"broker.publish: broker forward failed"
);
}
}
async fn handle_child_request(
bridge: &BridgeContext,
plugin_id: &str,
method: &str,
params: &Value,
stdin_tx: &mpsc::Sender<Value>,
request_id: &Value,
) -> Result<Value, (i32, String)> {
match method {
"memory.recall" => handle_memory_recall(bridge, plugin_id, params).await,
"llm.complete" => {
handle_llm_complete(bridge, plugin_id, params, stdin_tx, request_id).await
}
other => Err((-32601, format!("method not found: {other}"))),
}
}
async fn handle_memory_recall(
bridge: &BridgeContext,
plugin_id: &str,
params: &Value,
) -> Result<Value, (i32, String)> {
let memory = bridge
.memory
.as_ref()
.ok_or_else(|| (-32603, "memory not configured".to_string()))?;
let agent_id = params
.get("agent_id")
.and_then(|v| v.as_str())
.ok_or_else(|| (-32602, "missing or invalid `agent_id` (string)".to_string()))?;
let query = params
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| (-32602, "missing or invalid `query` (string)".to_string()))?;
let limit_u64 = params.get("limit").and_then(|v| v.as_u64()).unwrap_or(10);
let limit: usize = (limit_u64 as usize).min(1000);
let entries = memory.recall(agent_id, query, limit).await.map_err(|e| {
tracing::warn!(
plugin_id,
agent_id,
error = %e,
"memory.recall: backend returned error"
);
(-32603, format!("memory recall failed: {e}"))
})?;
let entries_json = serde_json::to_value(&entries).map_err(|e| {
(
-32603,
format!("memory.recall: serialize entries failed: {e}"),
)
})?;
Ok(json!({ "entries": entries_json }))
}
async fn handle_llm_complete(
bridge: &BridgeContext,
plugin_id: &str,
params: &Value,
stdin_tx: &mpsc::Sender<Value>,
request_id: &Value,
) -> Result<Value, (i32, String)> {
use futures::StreamExt;
use nexo_config::ModelConfig;
use nexo_llm::stream::StreamChunk;
use nexo_llm::types::{ChatMessage, ChatRequest, FinishReason, ResponseContent};
let llm = bridge
.llm
.as_ref()
.ok_or_else(|| (-32603, "llm not configured".to_string()))?;
let provider = params
.get("provider")
.and_then(|v| v.as_str())
.ok_or_else(|| (-32602, "missing or invalid `provider` (string)".to_string()))?;
let model = params
.get("model")
.and_then(|v| v.as_str())
.ok_or_else(|| (-32602, "missing or invalid `model` (string)".to_string()))?;
let messages_val = params.get("messages").ok_or_else(|| {
(
-32602,
"missing `messages` (array of {role, content})".to_string(),
)
})?;
let messages: Vec<ChatMessage> = serde_json::from_value(messages_val.clone())
.map_err(|e| {
(
-32602,
format!("invalid `messages`: {e} — expected [{{\"role\":\"user|assistant|system|tool\",\"content\":\"...\"}}]"),
)
})?;
if messages.is_empty() {
return Err((-32602, "`messages` must not be empty".to_string()));
}
let max_tokens = params
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|n| n.min(u32::MAX as u64) as u32)
.unwrap_or(4096);
let temperature = params
.get("temperature")
.and_then(|v| v.as_f64())
.map(|f| f as f32)
.unwrap_or(0.7);
let system_prompt = params
.get("system_prompt")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let stream = params
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let model_cfg = ModelConfig {
provider: provider.to_string(),
model: model.to_string(),
};
let client = llm.registry.build(&llm.config, &model_cfg).map_err(|e| {
tracing::warn!(
plugin_id,
provider,
model,
error = %e,
"llm.complete: client build failed"
);
(-32603, format!("llm client build failed: {e}"))
})?;
let mut req = ChatRequest::new(model.to_string(), messages);
req.max_tokens = max_tokens;
req.temperature = temperature;
req.system_prompt = system_prompt;
if stream {
let mut stream = client.stream(req).await.map_err(|e| {
tracing::warn!(
plugin_id,
provider,
model,
error = %e,
"llm.complete: stream() returned error"
);
(-32603, format!("llm stream failed: {e}"))
})?;
let mut usage: Option<TokenUsageOut> = None;
let mut finish: Option<FinishReason> = None;
let mut emitted_text = false;
let mut emitted_tool_calls = false;
while let Some(chunk_res) = stream.next().await {
let chunk = match chunk_res {
Ok(c) => c,
Err(e) => {
tracing::warn!(
plugin_id,
provider,
model,
error = %e,
"llm.complete: stream chunk error"
);
return Err((-32603, format!("llm stream chunk failed: {e}")));
}
};
match chunk {
StreamChunk::TextDelta { delta } => {
emitted_text = true;
let frame = json!({
"jsonrpc": "2.0",
"method": "llm.complete.delta",
"params": {
"request_id": request_id,
"chunk": delta,
}
});
if let Err(e) = stdin_tx.try_send(frame) {
tracing::warn!(
plugin_id,
error = %e,
"llm.complete.delta dropped: stdin queue full or closed"
);
}
}
StreamChunk::ToolCallStart { .. }
| StreamChunk::ToolCallArgsDelta { .. }
| StreamChunk::ToolCallEnd { .. } => {
emitted_tool_calls = true;
}
StreamChunk::Usage(u) => {
usage = Some(TokenUsageOut {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
});
}
StreamChunk::End { finish_reason } => {
finish = Some(finish_reason);
}
}
}
if emitted_tool_calls && !emitted_text {
return Err((
-32601,
"llm.complete stream returned tool calls only; MVP supports text \
(tool-call wire shape lands in a future contract bump)"
.to_string(),
));
}
let finish_reason = match finish.unwrap_or(FinishReason::Other("stream-no-end".into())) {
FinishReason::Stop => "stop".to_string(),
FinishReason::ToolUse => "tool_use".to_string(),
FinishReason::Length => "length".to_string(),
FinishReason::Other(s) => format!("other:{s}"),
};
let usage_json = match usage {
Some(u) => json!({
"prompt_tokens": u.prompt_tokens,
"completion_tokens": u.completion_tokens,
}),
None => json!({"prompt_tokens": 0, "completion_tokens": 0}),
};
return Ok(json!({
"finish_reason": finish_reason,
"usage": usage_json,
}));
}
let response = client.chat(req).await.map_err(|e| {
tracing::warn!(
plugin_id,
provider,
model,
error = %e,
"llm.complete: chat() returned error"
);
(-32603, format!("llm chat failed: {e}"))
})?;
let content = match response.content {
ResponseContent::Text(s) => s,
ResponseContent::ToolCalls(calls) => {
tracing::info!(
plugin_id,
provider,
model,
num_tool_calls = calls.len(),
"llm.complete: provider returned tool calls — MVP surfaces -32601 not_implemented"
);
return Err((
-32601,
"llm.complete returned tool calls; MVP supports text responses only \
(tool-call wire shape lands in a future contract bump)"
.to_string(),
));
}
};
let finish_reason = match response.finish_reason {
nexo_llm::types::FinishReason::Stop => "stop".to_string(),
nexo_llm::types::FinishReason::ToolUse => "tool_use".to_string(),
nexo_llm::types::FinishReason::Length => "length".to_string(),
nexo_llm::types::FinishReason::Other(s) => format!("other:{s}"),
};
Ok(json!({
"content": content,
"finish_reason": finish_reason,
"usage": {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
},
}))
}
#[async_trait]
impl NexoPlugin for SubprocessNexoPlugin {
fn manifest(&self) -> &PluginManifest {
&self.cached_manifest
}
async fn init(&self, ctx: &mut PluginInitContext<'_>) -> Result<(), PluginInitError> {
let plugin_id = self.cached_manifest.plugin.id.clone();
let plugin_state_dir = ctx.plugin_state_dir(&plugin_id);
*self.sandbox.lock().await = Some(ctx.sandbox.clone());
*self.plugin_state_dir.lock().await = Some(plugin_state_dir);
let llm = Some(LlmServices {
registry: ctx.llm_registry.clone(),
config: ctx.llm_config.clone(),
});
let inner = self
.spawn_and_handshake(
ctx.shutdown.clone(),
Some(ctx.broker.clone()),
ctx.long_term_memory.clone(),
llm,
)
.await
.map_err(|source| PluginInitError::Other {
plugin_id: self.cached_manifest.plugin.id.clone(),
source,
})?;
*self.inner.lock().await = Some(inner);
Ok(())
}
async fn shutdown(&self) -> Result<(), PluginShutdownError> {
let mut inner_guard = self.inner.lock().await;
let Some(mut inner) = inner_guard.take() else {
return Ok(()); };
let shutdown_id: u64 = 2;
let shutdown_req = json!({
"jsonrpc": "2.0",
"id": shutdown_id,
"method": "shutdown",
"params": { "reason": "host requested" },
});
let (tx, rx) = oneshot::channel::<Result<Value, String>>();
inner.pending.insert(shutdown_id, tx);
let send_ok = inner.stdin_tx.send(shutdown_req).await.is_ok();
if send_ok {
let _ = tokio::time::timeout(Duration::from_secs(5), rx).await;
}
inner.cancel.cancel();
let child_taken = inner.child.lock().await.take();
if let Some(mut c) = child_taken {
let _ = tokio::time::timeout(Duration::from_secs(1), c.wait()).await;
let _ = c.kill().await;
}
for task in inner.tasks.drain(..) {
let _ = tokio::time::timeout(Duration::from_secs(1), task).await;
}
Ok(())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
pub fn subprocess_plugin_factory(manifest: PluginManifest) -> PluginFactory {
Box::new(move |reg_manifest| {
let _ = reg_manifest;
let plugin: Arc<dyn NexoPlugin> = Arc::new(SubprocessNexoPlugin::new(manifest.clone()));
Ok(plugin)
})
}
pub fn subprocess_plugin_factory_with_env(
manifest: PluginManifest,
spawn_env: std::collections::HashMap<String, String>,
instance_label: String,
) -> PluginFactory {
Box::new(move |_reg_manifest| {
let plugin: Arc<dyn NexoPlugin> = Arc::new(
SubprocessNexoPlugin::new(manifest.clone())
.with_spawn_env(spawn_env.clone())
.with_instance_label(instance_label.clone()),
);
Ok(plugin)
})
}
#[cfg(test)]
mod tests {
use super::*;
use nexo_plugin_manifest::EntrypointSection;
fn manifest_with_entrypoint(command: Option<&str>) -> PluginManifest {
let toml_str = r#"
[plugin]
id = "test_plugin"
version = "0.1.0"
name = "test"
description = "fixture"
min_nexo_version = ">=0.1.0"
[plugin.requires]
nexo_capabilities = ["broker"]
"#;
let mut m: PluginManifest = toml::from_str(toml_str).unwrap();
m.plugin.entrypoint = EntrypointSection {
command: command.map(|s| s.to_string()),
args: Vec::new(),
env: Default::default(),
};
m
}
#[test]
fn entrypoint_section_serde_roundtrip() {
let toml_str = r#"
[plugin]
id = "x"
version = "0.1.0"
name = "x"
description = "x"
min_nexo_version = ">=0.1.0"
[plugin.entrypoint]
command = "/usr/local/bin/plugin-x"
args = ["--mode", "stdio"]
[plugin.entrypoint.env]
RUST_LOG = "info"
"#;
let m: PluginManifest = toml::from_str(toml_str).unwrap();
assert!(m.plugin.entrypoint.is_subprocess());
assert_eq!(
m.plugin.entrypoint.command.as_deref(),
Some("/usr/local/bin/plugin-x")
);
assert_eq!(m.plugin.entrypoint.args, vec!["--mode", "stdio"]);
assert_eq!(
m.plugin.entrypoint.env.get("RUST_LOG").map(String::as_str),
Some("info")
);
}
#[test]
fn is_subprocess_returns_false_for_in_tree_default() {
let m = manifest_with_entrypoint(None);
assert!(!m.plugin.entrypoint.is_subprocess());
let m2 = manifest_with_entrypoint(Some(" "));
assert!(!m2.plugin.entrypoint.is_subprocess());
}
#[test]
fn subprocess_plugin_manifest_returns_cached() {
let m = manifest_with_entrypoint(Some("/bin/true"));
let plugin = SubprocessNexoPlugin::new(m.clone());
assert_eq!(plugin.manifest().plugin.id, m.plugin.id);
}
#[tokio::test]
async fn init_fails_when_command_not_found() {
let m = manifest_with_entrypoint(Some("/definitely/does/not/exist/nexo-plugin-test-bin"));
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let result = plugin.spawn_and_handshake(cancel, None, None, None).await;
match result {
Ok(_) => panic!("spawn must fail for missing command"),
Err(err) => assert!(
err.to_string().contains("spawn"),
"error should mention spawn, got: {err}"
),
}
}
#[tokio::test]
async fn init_fails_when_env_collides_with_nexo_reserved() {
let mut m = manifest_with_entrypoint(Some("/bin/true"));
m.plugin
.entrypoint
.env
.insert("NEXO_STATE_ROOT".to_string(), "/tmp/evil".to_string());
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let result = plugin.spawn_and_handshake(cancel, None, None, None).await;
match result {
Ok(_) => panic!("env collision must fail"),
Err(err) => assert!(
err.to_string().contains("NEXO_"),
"error should mention reserved env, got: {err}"
),
}
}
#[tokio::test]
async fn init_times_out_when_child_silent() {
std::env::set_var("NEXO_PLUGIN_INIT_TIMEOUT_MS", "150");
let m = manifest_with_entrypoint(Some("/bin/cat"));
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let result = plugin.spawn_and_handshake(cancel, None, None, None).await;
std::env::remove_var("NEXO_PLUGIN_INIT_TIMEOUT_MS");
match result {
Ok(_) => panic!("silent child must time out"),
Err(err) => assert!(
err.to_string().contains("initialize"),
"error should mention initialize timeout, got: {err}"
),
}
}
#[tokio::test]
async fn init_fails_when_manifest_id_mismatch_on_initialize_reply() {
let script = r#"#!/bin/sh
read line
echo '{"jsonrpc":"2.0","id":1,"result":{"manifest":{"plugin":{"id":"impostor","version":"0.1.0","name":"x","description":"x","min_nexo_version":">=0.1.0"}},"server_version":"test-0.1.0"}}'
sleep 30
"#;
let dir = std::env::temp_dir().join("nexo-subprocess-test-mismatch");
std::fs::create_dir_all(&dir).unwrap();
let script_path = dir.join("plugin.sh");
std::fs::write(&script_path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&script_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script_path, perms).unwrap();
}
std::env::set_var("NEXO_PLUGIN_INIT_TIMEOUT_MS", "500");
let m = manifest_with_entrypoint(Some(script_path.to_str().unwrap()));
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let result = plugin.spawn_and_handshake(cancel, None, None, None).await;
std::env::remove_var("NEXO_PLUGIN_INIT_TIMEOUT_MS");
match result {
Ok(_) => panic!("id mismatch must fail"),
Err(err) => assert!(
err.to_string().contains("manifest id mismatch"),
"error should mention id mismatch, got: {err}"
),
}
}
#[tokio::test]
async fn factory_helper_produces_arc_dyn_nexoplugin() {
let m = manifest_with_entrypoint(Some("/bin/true"));
let factory = subprocess_plugin_factory(m.clone());
match factory(&m) {
Ok(plugin) => assert_eq!(plugin.manifest().plugin.id, m.plugin.id),
Err(e) => panic!("factory should build Arc<dyn NexoPlugin>, got: {e}"),
}
}
#[tokio::test]
async fn shutdown_is_idempotent_when_never_started() {
let m = manifest_with_entrypoint(Some("/bin/true"));
let plugin = SubprocessNexoPlugin::new(m);
plugin.shutdown().await.expect("shutdown idempotent");
plugin
.shutdown()
.await
.expect("second shutdown also idempotent");
}
use nexo_broker::AnyBroker;
use nexo_plugin_manifest::ChannelDecl;
fn manifest_with_channel(command: &str, kind: &str) -> PluginManifest {
let mut m = manifest_with_entrypoint(Some(command));
m.plugin.channels.register.push(ChannelDecl {
kind: kind.to_string(),
adapter: "MockAdapter".to_string(),
});
m
}
fn write_bridge_mock_script(
dir_name: &str,
plugin_id: &str,
publish_topic: Option<&str>,
) -> std::path::PathBuf {
let publish_line = match publish_topic {
Some(t) => format!(
concat!(
"echo '{{\"jsonrpc\":\"2.0\",\"method\":\"broker.publish\",",
"\"params\":{{\"topic\":\"{}\",",
"\"event\":{{\"id\":\"00000000-0000-0000-0000-000000000001\",",
"\"timestamp\":\"2026-05-01T00:00:00Z\",",
"\"topic\":\"{}\",\"source\":\"mock\",\"session_id\":null,",
"\"payload\":{{\"hello\":\"world\"}}}}}}}}'\n"
),
t, t
),
None => String::new(),
};
let script = format!(
r#"#!/bin/sh
read line
echo '{{"jsonrpc":"2.0","id":1,"result":{{"manifest":{{"plugin":{{"id":"{plugin_id}","version":"0.1.0","name":"x","description":"x","min_nexo_version":">=0.1.0"}}}},"server_version":"mock-0.1.0"}}}}'
sleep 0.3
{publish_line}
sleep 5
"#
);
let dir = std::env::temp_dir().join(dir_name);
std::fs::create_dir_all(&dir).unwrap();
let script_path = dir.join("plugin.sh");
std::fs::write(&script_path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&script_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&script_path, perms).unwrap();
}
script_path
}
#[tokio::test]
async fn bridge_subscribes_outbound_topics_for_each_channel_register_kind() {
std::env::set_var("NEXO_PLUGIN_INIT_TIMEOUT_MS", "1000");
let path = write_bridge_mock_script("nexo-bridge-test-subscribe", "test_plugin", None);
let m = manifest_with_channel(path.to_str().unwrap(), "slack");
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let broker = AnyBroker::Local(nexo_broker::LocalBroker::new());
let res = plugin
.spawn_and_handshake(cancel.clone(), Some(broker), None, None)
.await;
std::env::remove_var("NEXO_PLUGIN_INIT_TIMEOUT_MS");
let inner = res.expect("handshake + bridge wiring must succeed");
assert_eq!(
inner.tasks.len(),
6,
"expected writer + stdout reader + stderr reader + supervisor + 2 forwarder tasks"
);
cancel.cancel();
}
#[tokio::test]
async fn bridge_forwards_valid_child_publish_to_broker() {
std::env::set_var("NEXO_PLUGIN_INIT_TIMEOUT_MS", "1000");
let path = write_bridge_mock_script(
"nexo-bridge-test-forward",
"test_plugin",
Some("plugin.inbound.slack"),
);
let m = manifest_with_channel(path.to_str().unwrap(), "slack");
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let broker = AnyBroker::Local(nexo_broker::LocalBroker::new());
let mut sub = broker
.subscribe("plugin.inbound.slack")
.await
.expect("subscribe before init");
let _inner = plugin
.spawn_and_handshake(cancel.clone(), Some(broker), None, None)
.await
.expect("handshake + bridge wiring");
std::env::remove_var("NEXO_PLUGIN_INIT_TIMEOUT_MS");
let event = tokio::time::timeout(Duration::from_secs(2), sub.next())
.await
.expect("event arrives within 2s");
let event = event.expect("subscription delivers Some");
assert_eq!(event.topic, "plugin.inbound.slack");
assert_eq!(event.source, "mock");
cancel.cancel();
}
#[tokio::test]
async fn bridge_rejects_child_publish_outside_inbound_allowlist() {
std::env::set_var("NEXO_PLUGIN_INIT_TIMEOUT_MS", "1000");
let path = write_bridge_mock_script(
"nexo-bridge-test-reject",
"test_plugin",
Some("agent.route.system_critical"),
);
let m = manifest_with_channel(path.to_str().unwrap(), "slack");
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let broker = AnyBroker::Local(nexo_broker::LocalBroker::new());
let mut rogue_sub = broker
.subscribe("agent.route.system_critical")
.await
.expect("subscribe rogue");
let _inner = plugin
.spawn_and_handshake(cancel.clone(), Some(broker), None, None)
.await
.expect("handshake");
std::env::remove_var("NEXO_PLUGIN_INIT_TIMEOUT_MS");
let result = tokio::time::timeout(Duration::from_millis(500), rogue_sub.next()).await;
cancel.cancel();
assert!(
result.is_err(),
"rogue topic must NOT deliver — bridge dropped"
);
}
#[tokio::test]
async fn bridge_skipped_when_broker_is_none() {
std::env::set_var("NEXO_PLUGIN_INIT_TIMEOUT_MS", "1000");
let path = write_bridge_mock_script("nexo-bridge-test-none", "test_plugin", None);
let m = manifest_with_channel(path.to_str().unwrap(), "slack");
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let res = plugin
.spawn_and_handshake(cancel.clone(), None, None, None)
.await;
std::env::remove_var("NEXO_PLUGIN_INIT_TIMEOUT_MS");
let inner = res.expect("handshake must succeed without broker");
assert_eq!(
inner.tasks.len(),
4,
"expected writer + stdout reader + stderr reader + supervisor, no forwarders"
);
cancel.cancel();
}
#[tokio::test]
async fn stderr_is_piped_so_reader_can_construct() {
let script = r#"#!/bin/sh
echo "boot diag from child" >&2
read line
echo '{"jsonrpc":"2.0","id":1,"result":{"manifest":{"plugin":{"id":"test_plugin","version":"0.1.0","name":"x","description":"x","min_nexo_version":">=0.1.0"}},"server_version":"mock-0.1.0"}}'
echo "post-init diag" >&2
sleep 5
"#;
let dir = std::env::temp_dir().join("nexo-stderr-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("plugin.sh");
std::fs::write(&path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
}
std::env::set_var("NEXO_PLUGIN_INIT_TIMEOUT_MS", "1000");
let m = manifest_with_entrypoint(Some(path.to_str().unwrap()));
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let res = plugin
.spawn_and_handshake(cancel.clone(), None, None, None)
.await;
std::env::remove_var("NEXO_PLUGIN_INIT_TIMEOUT_MS");
let inner = res.expect("handshake must succeed with stderr piped");
assert_eq!(
inner.tasks.len(),
4,
"writer + stdout reader + stderr reader + supervisor = 4"
);
cancel.cancel();
}
#[tokio::test]
async fn supervisor_publishes_crashed_event_on_child_exit() {
let script = r#"#!/bin/sh
read line
echo '{"jsonrpc":"2.0","id":1,"result":{"manifest":{"plugin":{"id":"test_plugin","version":"0.1.0","name":"x","description":"x","min_nexo_version":">=0.1.0"}},"server_version":"mock-0.1.0"}}'
echo "diag line 1" >&2
echo "diag line 2" >&2
echo "fatal: simulated crash cause" >&2
sleep 0.2
exit 7
"#;
let dir = std::env::temp_dir().join("nexo-supervisor-test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("plugin.sh");
std::fs::write(&path, script).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
}
std::env::set_var("NEXO_PLUGIN_INIT_TIMEOUT_MS", "1000");
let m = manifest_with_entrypoint(Some(path.to_str().unwrap()));
let plugin = SubprocessNexoPlugin::new(m);
let cancel = CancellationToken::new();
let broker = AnyBroker::Local(nexo_broker::LocalBroker::new());
let mut sub = broker
.subscribe("plugin.lifecycle.test_plugin.crashed")
.await
.expect("subscribe to crash topic");
let _inner = plugin
.spawn_and_handshake(cancel.clone(), Some(broker), None, None)
.await
.expect("handshake");
std::env::remove_var("NEXO_PLUGIN_INIT_TIMEOUT_MS");
let event = tokio::time::timeout(Duration::from_secs(2), sub.next())
.await
.expect("crashed event arrives within 2s");
let event = event.expect("subscription delivers Some");
assert_eq!(event.topic, "plugin.lifecycle.test_plugin.crashed");
assert_eq!(event.source, "plugin.supervisor");
assert_eq!(
event.payload.get("plugin_id").and_then(|v| v.as_str()),
Some("test_plugin")
);
assert_eq!(
event.payload.get("exit_code").and_then(|v| v.as_i64()),
Some(7)
);
let stderr_tail = event
.payload
.get("stderr_tail")
.and_then(|v| v.as_array())
.expect("stderr_tail field must be an array");
let lines: Vec<&str> = stderr_tail.iter().filter_map(|v| v.as_str()).collect();
assert_eq!(
lines,
vec!["diag line 1", "diag line 2", "fatal: simulated crash cause"]
);
cancel.cancel();
}
#[test]
fn manifest_validate_rejects_stderr_tail_above_cap() {
use nexo_plugin_manifest::SUPERVISOR_STDERR_TAIL_MAX;
let toml_str = format!(
r#"
[plugin]
id = "x"
version = "0.1.0"
name = "x"
description = "x"
min_nexo_version = ">=0.0.1"
[plugin.supervisor]
stderr_tail_lines = {}
"#,
SUPERVISOR_STDERR_TAIL_MAX + 1
);
let manifest: PluginManifest = toml::from_str(&toml_str)
.expect("manifest parses (cap is enforced at validate time, not parse)");
let mut errors = Vec::new();
nexo_plugin_manifest::validate::run_all(
&manifest,
&semver::Version::parse("0.1.0").unwrap(),
&mut errors,
);
assert!(
errors.iter().any(|e| matches!(
e,
nexo_plugin_manifest::ManifestError::SupervisorStderrTailExceedsCap { .. }
)),
"expected SupervisorStderrTailExceedsCap, got {errors:?}"
);
}
#[tokio::test]
async fn memory_recall_handler_returns_seeded_entry() {
use nexo_memory::LongTermMemory;
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("test_memory.db");
let memory = Arc::new(
LongTermMemory::open(db_path.to_str().unwrap())
.await
.expect("open long-term memory"),
);
memory
.remember("agent_x", "user prefers concise answers", &["preference"])
.await
.expect("seed memory entry");
let bridge = BridgeContext {
broker: AnyBroker::Local(nexo_broker::LocalBroker::new()),
publish_allowlist: vec![],
memory: Some(memory),
llm: None,
};
let params = json!({
"agent_id": "agent_x",
"query": "concise",
"limit": 5,
});
let result = handle_memory_recall(&bridge, "test_plugin", ¶ms)
.await
.expect("memory.recall must succeed");
let entries = result
.get("entries")
.and_then(|v| v.as_array())
.expect("entries field is an array");
assert!(
!entries.is_empty(),
"expected at least one entry for query that matches seed"
);
let first = &entries[0];
assert_eq!(
first.get("agent_id").and_then(|v| v.as_str()),
Some("agent_x")
);
assert!(
first
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.contains("concise"),
"content field must contain the seeded text"
);
}
#[tokio::test]
async fn memory_recall_handler_returns_neg_32603_when_memory_none() {
let bridge = BridgeContext {
broker: AnyBroker::Local(nexo_broker::LocalBroker::new()),
publish_allowlist: vec![],
memory: None,
llm: None,
};
let params = json!({"agent_id": "any", "query": "any"});
let result = handle_memory_recall(&bridge, "test_plugin", ¶ms).await;
match result {
Ok(v) => panic!("expected error, got Ok({v:?})"),
Err((code, msg)) => {
assert_eq!(code, -32603);
assert!(
msg.contains("not configured"),
"error message should mention 'not configured', got: {msg}"
);
}
}
}
#[tokio::test]
async fn memory_recall_handler_returns_neg_32602_on_bad_params() {
use nexo_memory::LongTermMemory;
let tmp = tempfile::tempdir().unwrap();
let db_path = tmp.path().join("test_memory.db");
let memory = Arc::new(
LongTermMemory::open(db_path.to_str().unwrap())
.await
.expect("open long-term memory"),
);
let bridge = BridgeContext {
broker: AnyBroker::Local(nexo_broker::LocalBroker::new()),
publish_allowlist: vec![],
memory: Some(memory),
llm: None,
};
let r = handle_memory_recall(&bridge, "test_plugin", &json!({"query": "x"})).await;
match r {
Err((-32602, msg)) => assert!(msg.contains("agent_id")),
other => panic!("expected -32602 missing agent_id, got {other:?}"),
}
let r = handle_memory_recall(
&bridge,
"test_plugin",
&json!({"agent_id": "x", "query": 42}),
)
.await;
match r {
Err((-32602, msg)) => assert!(msg.contains("query")),
other => panic!("expected -32602 invalid query, got {other:?}"),
}
}
#[tokio::test]
async fn llm_complete_handler_returns_neg_32603_when_llm_none() {
let bridge = BridgeContext {
broker: AnyBroker::Local(nexo_broker::LocalBroker::new()),
publish_allowlist: vec![],
memory: None,
llm: None,
};
let params = json!({
"provider": "minimax",
"model": "x",
"messages": [{"role":"user","content":"hi"}],
});
match {
let (_dummy_tx, _dummy_rx) = mpsc::channel::<Value>(8);
let _dummy_id = json!(99);
handle_llm_complete(&bridge, "test_plugin", ¶ms, &_dummy_tx, &_dummy_id).await
} {
Ok(v) => panic!("expected -32603, got Ok({v:?})"),
Err((code, msg)) => {
assert_eq!(code, -32603);
assert!(
msg.contains("not configured"),
"msg should mention 'not configured', got: {msg}"
);
}
}
}
#[tokio::test]
async fn llm_complete_handler_returns_neg_32602_on_bad_params() {
let bridge = BridgeContext {
broker: AnyBroker::Local(nexo_broker::LocalBroker::new()),
publish_allowlist: vec![],
memory: None,
llm: Some(LlmServices {
registry: Arc::new(LlmRegistry::new()),
config: Arc::new(LlmConfig {
providers: std::collections::HashMap::new(),
retry: Default::default(),
context_optimization: Default::default(),
tenants: std::collections::HashMap::new(),
}),
}),
};
let (_dtx, _drx) = mpsc::channel::<Value>(8);
let dummy_id = json!(99);
let r = handle_llm_complete(
&bridge,
"test_plugin",
&json!({"model": "x", "messages": []}),
&_dtx,
&dummy_id,
)
.await;
match r {
Err((-32602, msg)) => assert!(msg.contains("provider")),
other => panic!("expected -32602 missing provider, got {other:?}"),
}
let r = handle_llm_complete(
&bridge,
"test_plugin",
&json!({"provider": "p", "model": "x"}),
&_dtx,
&dummy_id,
)
.await;
match r {
Err((-32602, msg)) => assert!(msg.contains("messages")),
other => panic!("expected -32602 missing messages, got {other:?}"),
}
let r = handle_llm_complete(
&bridge,
"test_plugin",
&json!({"provider": "p", "model": "x", "messages": []}),
&_dtx,
&dummy_id,
)
.await;
match r {
Err((-32602, msg)) => assert!(msg.contains("must not be empty")),
other => panic!("expected -32602 empty messages, got {other:?}"),
}
let r = handle_llm_complete(
&bridge,
"test_plugin",
&json!({
"provider": "p",
"model": "x",
"messages": [{"role": 42, "content": "hi"}],
}),
&_dtx,
&dummy_id,
)
.await;
match r {
Err((-32602, msg)) => assert!(msg.contains("messages")),
other => panic!("expected -32602 malformed role, got {other:?}"),
}
}
#[tokio::test]
async fn llm_complete_handler_returns_neg_32603_when_provider_not_registered() {
let bridge = BridgeContext {
broker: AnyBroker::Local(nexo_broker::LocalBroker::new()),
publish_allowlist: vec![],
memory: None,
llm: Some(LlmServices {
registry: Arc::new(LlmRegistry::new()), config: Arc::new(LlmConfig {
providers: std::collections::HashMap::new(),
retry: Default::default(),
context_optimization: Default::default(),
tenants: std::collections::HashMap::new(),
}),
}),
};
let params = json!({
"provider": "nonexistent_provider",
"model": "x",
"messages": [{"role":"user","content":"hi"}],
});
match {
let (_dummy_tx, _dummy_rx) = mpsc::channel::<Value>(8);
let _dummy_id = json!(99);
handle_llm_complete(&bridge, "test_plugin", ¶ms, &_dummy_tx, &_dummy_id).await
} {
Ok(v) => panic!("expected -32603, got Ok({v:?})"),
Err((code, msg)) => {
assert_eq!(code, -32603);
assert!(
msg.contains("client build failed") || msg.contains("nonexistent_provider"),
"msg should mention build failure, got: {msg}"
);
}
}
}
#[test]
fn with_spawn_env_populates_field() {
let manifest = manifest_with_entrypoint(Some("/bin/cat"));
let mut env = std::collections::HashMap::new();
env.insert("FOO".to_string(), "bar".to_string());
env.insert("PATH".to_string(), "/usr/bin".to_string());
let plugin = SubprocessNexoPlugin::new(manifest)
.with_spawn_env(env.clone())
.with_instance_label("bot1");
assert_eq!(
plugin
.spawn_env
.as_ref()
.unwrap()
.get("FOO")
.map(|s| s.as_str()),
Some("bar")
);
assert_eq!(
plugin
.spawn_env
.as_ref()
.unwrap()
.get("PATH")
.map(|s| s.as_str()),
Some("/usr/bin")
);
assert_eq!(plugin.instance_label.as_deref(), Some("bot1"));
}
#[test]
fn with_instance_label_normalises_empty_to_none() {
let manifest = manifest_with_entrypoint(Some("/bin/cat"));
let plugin1 = SubprocessNexoPlugin::new(manifest.clone()).with_instance_label("");
assert_eq!(plugin1.instance_label, None);
let plugin2 = SubprocessNexoPlugin::new(manifest.clone()).with_instance_label(" ");
assert_eq!(plugin2.instance_label, None);
let plugin3 = SubprocessNexoPlugin::new(manifest).with_instance_label("real");
assert_eq!(plugin3.instance_label.as_deref(), Some("real"));
}
#[test]
fn default_inherits_daemon_env_when_spawn_env_not_set() {
let manifest = manifest_with_entrypoint(Some("/bin/cat"));
let plugin = SubprocessNexoPlugin::new(manifest);
assert!(plugin.spawn_env.is_none());
assert!(plugin.instance_label.is_none());
}
}