From 793162f57c195466b74c1972f1ae295819528eb0 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Thu, 4 Jun 2026 14:21:08 -0400 Subject: [PATCH 1/7] feat: add claude code and codex plugins Signed-off-by: Will Killian --- .agents/plugins/marketplace.json | 20 + .claude-plugin/marketplace.json | 16 + .github/ci-path-filters.yml | 4 + .github/workflows/ci.yaml | 3 + RELEASING.md | 15 +- crates/cli/src/config.rs | 49 +- crates/cli/src/gateway.rs | 2 + crates/cli/src/main.rs | 13 +- crates/cli/src/plugin_install/host.rs | 330 +++++ crates/cli/src/plugin_install/marketplace.rs | 151 ++ crates/cli/src/plugin_install/mod.rs | 380 +++++ crates/cli/src/plugin_install/setup.rs | 103 ++ crates/cli/src/plugin_install/state.rs | 265 ++++ crates/cli/src/plugin_shim/claude.rs | 169 +++ crates/cli/src/plugin_shim/codex.rs | 580 ++++++++ crates/cli/src/plugin_shim/command.rs | 80 + crates/cli/src/plugin_shim/mod.rs | 197 +++ crates/cli/src/plugin_shim/shared.rs | 556 +++++++ crates/cli/src/server.rs | 60 +- crates/cli/src/session.rs | 24 + crates/cli/tests/cli_tests.rs | 69 + crates/cli/tests/coverage/gateway_tests.rs | 2 + crates/cli/tests/coverage/installer_tests.rs | 107 ++ .../tests/coverage/plugin_install_tests.rs | 837 +++++++++++ .../cli/tests/coverage/plugin_shim_tests.rs | 1309 +++++++++++++++++ crates/cli/tests/coverage/server_tests.rs | 133 ++ integrations/coding-agents/README.md | 89 +- .../claude-code/.claude-plugin/plugin.json | 9 +- .../coding-agents/claude-code/README.md | 150 +- .../claude-code/hooks/hooks.json | 26 +- .../codex/.codex-plugin/plugin.json | 14 +- integrations/coding-agents/codex/README.md | 181 ++- .../coding-agents/codex/hooks/hooks.json | 26 +- justfile | 28 + 34 files changed, 5917 insertions(+), 80 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .claude-plugin/marketplace.json create mode 100644 crates/cli/src/plugin_install/host.rs create mode 100644 crates/cli/src/plugin_install/marketplace.rs create mode 100644 crates/cli/src/plugin_install/mod.rs create mode 100644 crates/cli/src/plugin_install/setup.rs create mode 100644 crates/cli/src/plugin_install/state.rs create mode 100644 crates/cli/src/plugin_shim/claude.rs create mode 100644 crates/cli/src/plugin_shim/codex.rs create mode 100644 crates/cli/src/plugin_shim/command.rs create mode 100644 crates/cli/src/plugin_shim/mod.rs create mode 100644 crates/cli/src/plugin_shim/shared.rs create mode 100644 crates/cli/tests/coverage/plugin_install_tests.rs create mode 100644 crates/cli/tests/coverage/plugin_shim_tests.rs diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 000000000..a608fc210 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "nemo-relay", + "interface": { + "displayName": "NeMo Relay" + }, + "plugins": [ + { + "name": "nemo-relay-plugin", + "source": { + "source": "local", + "path": "./integrations/coding-agents/codex" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Coding" + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..a276b9452 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "nemo-relay", + "description": "NeMo Relay plugins for coding-agent observability.", + "owner": { + "name": "NVIDIA Corporation and Affiliates", + "email": "noreply@nvidia.com" + }, + "plugins": [ + { + "name": "nemo-relay-plugin", + "description": "Forward Claude Code lifecycle hooks to a local NeMo Relay sidecar.", + "source": "./integrations/coding-agents/claude-code", + "category": "development" + } + ] +} diff --git a/.github/ci-path-filters.yml b/.github/ci-path-filters.yml index 4b5274b24..3b79c81dc 100644 --- a/.github/ci-path-filters.yml +++ b/.github/ci-path-filters.yml @@ -109,6 +109,8 @@ dependencies: - 'uv.lock' docs: + - '.agents/plugins/marketplace.json' + - '.claude-plugin/marketplace.json' - 'CONTRIBUTING.md' - 'README.md' - 'docs/**' @@ -129,6 +131,8 @@ docs: - 'scripts/docs/**' rust: + - '.agents/plugins/marketplace.json' + - '.claude-plugin/marketplace.json' - 'Cargo.lock' - 'Cargo.toml' - 'crates/**/Cargo.toml' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b3496037b..e288c9af4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -282,6 +282,9 @@ jobs: permissions: contents: write steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Download CLI binary artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/RELEASING.md b/RELEASING.md index 1b4cc3241..e1c308a26 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -33,6 +33,7 @@ The release pipeline publishes these package surfaces from a tag push: | crates.io | `nemo-relay`, `nemo-relay-adaptive`, `nemo-relay-ffi`, `nemo-relay-cli` | | PyPI | `nemo-relay` | | npm | `nemo-relay-node`, `nemo-relay-openclaw`, `nemo-relay-wasm` | +| GitHub Releases | CLI binaries and `SHA256SUMS` | | Fern | The documentation site | Go remains source-first. There is no separate Go package-manager publication @@ -159,7 +160,6 @@ The helper updates: 4. [`integrations/openclaw/package.json`](integrations/openclaw/package.json) and the `integrations/openclaw` entry in the root [`package-lock.json`](package-lock.json) to the same release version. - Review docs and snippets that mention explicit versions, including: - [`README.md`](README.md) @@ -236,6 +236,8 @@ The release pipeline then: - `package-openclaw` packs the npm OpenClaw plugin package. - `package-python` builds platform wheels. - `package-wasm` packs the npm WebAssembly package. + - The CLI release-asset job uploads each platform `nemo-relay` binary and + includes those binaries in `SHA256SUMS`. 4. Publishes packages from the top-level workflow after the reusable packaging jobs complete: - `publish-rust` stamps Cargo workspace versions from the release tag, then @@ -249,6 +251,9 @@ The release pipeline then: - Stable tags publish to the npm `latest` dist-tag - Prerelease tags such as `0.1.0-rc.1` publish to the npm `next` dist-tag so they do not become the default upgrade target + - The GitHub Release entry remains a draft until a maintainer publishes it. + End-user `nemo-relay install ...` commands require the CLI binary to be + installed and available on `PATH`. The workflow boundary is split intentionally: @@ -275,9 +280,9 @@ NVIDIA Artifactory publication for the same tag: npm trusted publishing has its own registry-side constraints: - Each npm package can only have one trusted publisher configured at a time. -- Because this repository publishes `nemo-relay-node`, `nemo-relay-openclaw`, and - `nemo-relay-wasm`, configure trusted publishers for all three packages before - pushing a release tag. +- Because this repository publishes `nemo-relay-node`, `nemo-relay-openclaw`, + and `nemo-relay-wasm`, configure trusted publishers for all three packages + before pushing a release tag. - npm trusted publishing currently supports GitHub-hosted runners, not self-hosted runners. @@ -292,6 +297,8 @@ After the tag pipeline succeeds, publish or finalize the GitHub Release entry for that tag. - Keep complete release notes in GitHub Releases. +- Publish the draft release before announcing `nemo-relay install` commands for + the new version. - Do not copy those notes into `CHANGELOG.md` or duplicate the full release history in the docs site. - If you use GitHub-generated notes, review them before publishing. The diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 2307065fc..1d8e0f018 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -11,6 +11,7 @@ use serde::Deserialize; use serde_json::Value; use crate::error::CliError; +use crate::plugin_shim::PluginShimCommand; #[derive(Debug, Clone, Parser)] #[command(name = "nemo-relay")] @@ -79,6 +80,10 @@ pub(crate) enum Command { Config(ConfigCommand), /// Create or edit plugin configuration (writes `plugins.toml`) Plugins(PluginsCommand), + /// Install coding-agent plugins from the local nemo-relay CLI. + Install(InstallCommand), + /// Uninstall coding-agent plugins installed by `nemo-relay install`. + Uninstall(UninstallCommand), /// Diagnose env, agents, config, observability (optionally scoped to one agent) Doctor(DoctorCommand), /// List supported and locally-detected agents (use `--json` for machine output) @@ -90,6 +95,9 @@ pub(crate) enum Command { /// Internal: subprocess used by installed hooks to forward events. Not typed by humans. #[command(hide = true)] HookForward(HookForwardCommand), + /// Internal: plugin-local hook and sidecar supervisor. Not typed by humans. + #[command(hide = true)] + PluginShim(PluginShimCommand), } /// Args for `nemo-relay doctor`. `--json` is on this command (rather than as a global flag) @@ -99,12 +107,42 @@ pub(crate) struct DoctorCommand { /// Limit readiness checks to one supported agent. #[arg(value_enum)] pub(crate) agent: Option, + /// Diagnose an installed coding-agent plugin instead of the normal relay config. + #[arg(long, value_enum)] + pub(crate) plugin: Option, + /// Plugin install state directory. Defaults to the platform data directory. + #[arg(long)] + pub(crate) install_dir: Option, /// Emit machine-readable JSON instead of the formatted human report. Versioned via /// `schema_version`; stable shape for CI / evaluation harness consumption. - #[arg(long)] + #[arg(long, conflicts_with = "plugin")] pub(crate) json: bool, } +#[derive(Debug, Clone, Args)] +pub(crate) struct InstallCommand { + #[arg(value_enum)] + pub(crate) host: PluginHost, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) force: bool, + #[arg(long)] + pub(crate) dry_run: bool, + #[arg(long)] + pub(crate) skip_doctor: bool, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct UninstallCommand { + #[arg(value_enum)] + pub(crate) host: PluginHost, + #[arg(long)] + pub(crate) install_dir: Option, + #[arg(long)] + pub(crate) dry_run: bool, +} + /// Args for `nemo-relay agents`. Shares the `--json` shape with `nemo-relay doctor`'s /// `agents` field so the two outputs can be unified by downstream consumers. #[derive(Debug, Clone, Args)] @@ -289,6 +327,15 @@ pub(crate) enum CodingAgent { Hermes, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum PluginHost { + Codex, + #[value(name = "claude-code", alias = "claude")] + ClaudeCode, + All, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] #[value(rename_all = "kebab-case")] pub(crate) enum GatewayMode { diff --git a/crates/cli/src/gateway.rs b/crates/cli/src/gateway.rs index f19624e19..9562491ab 100644 --- a/crates/cli/src/gateway.rs +++ b/crates/cli/src/gateway.rs @@ -48,6 +48,7 @@ pub(crate) async fn passthrough( State(state): State, request: Request, ) -> Result, CliError> { + state.touch(); let prepared = prepare_gateway_request(&state.config, request).await?; let prep = state .sessions @@ -883,6 +884,7 @@ pub(crate) async fn models( State(state): State, request: Request, ) -> Result, CliError> { + state.touch(); let (parts, _body) = request.into_parts(); if parts.method != Method::GET { return build_response( diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 91a2eaa3a..c0375b7df 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -14,6 +14,8 @@ mod gateway; mod installer; mod launcher; mod model; +mod plugin_install; +mod plugin_shim; mod plugins; mod server; mod session; @@ -54,6 +56,9 @@ async fn run() -> Result { installer::hook_forward(command).await?; Ok(ExitCode::SUCCESS) } + Some(Command::PluginShim(command)) => plugin_shim::run(command), + Some(Command::Install(command)) => plugin_install::install(command), + Some(Command::Uninstall(command)) => plugin_install::uninstall(command), Some(Command::Run(command)) => launcher::run(command, Some(&cli.server)).await, Some(Command::Claude(command)) => { launcher::easy_path(CodingAgent::ClaudeCode, command, Some(&cli.server)).await @@ -81,7 +86,13 @@ async fn run() -> Result { } Ok(ExitCode::SUCCESS) } - Some(Command::Doctor(command)) => doctor::run_doctor(command.agent, command.json).await, + Some(Command::Doctor(command)) => { + if let Some(plugin) = command.plugin { + plugin_install::doctor(plugin, command.install_dir, command.json) + } else { + doctor::run_doctor(command.agent, command.json).await + } + } Some(Command::Agents(command)) => doctor::run_agents(command.json).await, Some(Command::Completions(command)) => { if command.install { diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs new file mode 100644 index 000000000..58cf44216 --- /dev/null +++ b/crates/cli/src/plugin_install/host.rs @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Host CLI discovery and marketplace registration commands. + +use std::env; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::config::PluginHost; + +use super::state::{PluginInstallOptions, PluginLayout}; +use super::{MARKETPLACE_NAME, PLUGIN_NAME, RELAY_COMMAND, host_cli}; + +pub(super) fn run_host_marketplace_registration( + host: PluginHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + run_command( + host_cli(host), + &[ + "plugin".into(), + "marketplace".into(), + "add".into(), + layout.marketplace_root.display().to_string(), + ], + options, + runner, + ) +} + +pub(super) fn run_host_plugin_registration( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + match host { + PluginHost::Codex => run_command( + host_cli(host), + &[ + "plugin".into(), + "add".into(), + format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"), + ], + options, + runner, + ), + PluginHost::ClaudeCode => run_command( + host_cli(host), + &[ + "plugin".into(), + "install".into(), + format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"), + "--scope".into(), + "user".into(), + ], + options, + runner, + ), + PluginHost::All => unreachable!("all is expanded before host registration"), + } +} + +pub(super) fn run_host_plugin_removal( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + match host { + PluginHost::Codex => run_command( + host_cli(host), + &[ + "plugin".into(), + "remove".into(), + format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"), + ], + options, + runner, + )?, + PluginHost::ClaudeCode => run_command( + host_cli(host), + &["plugin".into(), "uninstall".into(), PLUGIN_NAME.into()], + options, + runner, + )?, + PluginHost::All => unreachable!("all is expanded before host unregistration"), + } + Ok(()) +} + +pub(super) fn run_host_marketplace_removal( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + run_command( + host_cli(host), + &[ + "plugin".into(), + "marketplace".into(), + "remove".into(), + MARKETPLACE_NAME.into(), + ], + options, + runner, + ) +} + +pub(super) fn require_relay( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + if options.dry_run { + return Ok(PathBuf::from(RELAY_COMMAND)); + } + runner + .resolve_executable(RELAY_COMMAND)? + .ok_or_else(|| "required `nemo-relay` executable was not found on PATH".into()) +} + +pub(super) fn validate_relay_plugin_shim( + relay: &Path, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + return Ok(()); + } + let args = ["plugin-shim".into(), "hook".into(), "--help".into()]; + let status = runner.run_quiet(relay, &args)?; + if status == 0 { + Ok(()) + } else { + Err(format!( + "{} failed with exit code {status}; installed hooks require `nemo-relay plugin-shim hook` support", + format_command(&relay.display().to_string(), &args) + )) + } +} + +pub(super) fn require_host_cli( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + return Ok(()); + } + let cli = host_cli(host); + runner + .resolve_executable(cli)? + .map(|_| ()) + .ok_or_else(|| format!("required `{cli}` CLI was not found on PATH")) +} + +pub(super) fn run_command( + program: &str, + args: &[String], + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + println!("{}", format_command(program, args)); + return Ok(()); + } + let resolved = runner + .resolve_executable(program)? + .ok_or_else(|| format!("required `{program}` executable was not found on PATH"))?; + run_path_command(&resolved, args, options, runner) +} + +pub(super) fn run_path_command( + program: &Path, + args: &[String], + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if options.dry_run { + println!("{}", format_command(&program.display().to_string(), args)); + return Ok(()); + } + let status = runner.run(program, args)?; + if status == 0 { + Ok(()) + } else { + Err(format!( + "{} failed with exit code {status}", + format_command(&program.display().to_string(), args) + )) + } +} + +pub(super) fn format_command(program: &str, args: &[String]) -> String { + let mut parts = vec![program.to_string()]; + parts.extend(args.iter().cloned()); + format!( + "$ {}", + parts + .iter() + .map(|part| shell_quote(part)) + .collect::>() + .join(" ") + ) +} + +fn shell_quote(raw: &str) -> String { + if raw.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') + }) { + raw.into() + } else { + let mut escaped = String::new(); + for ch in raw.chars() { + if matches!(ch, '"' | '\\' | '$' | '`') { + escaped.push('\\'); + } + escaped.push(ch); + } + format!("\"{escaped}\"") + } +} + +pub(super) trait CommandRunner { + fn resolve_executable(&self, command: &str) -> Result, String>; + fn run(&self, program: &Path, args: &[String]) -> Result; + fn run_quiet(&self, program: &Path, args: &[String]) -> Result; +} + +pub(super) struct RealCommandRunner; + +impl CommandRunner for RealCommandRunner { + fn resolve_executable(&self, command: &str) -> Result, String> { + Ok(find_executable(command)) + } + + fn run(&self, program: &Path, args: &[String]) -> Result { + #[cfg(windows)] + if is_windows_command_script(program) { + let status = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) + .args(["/d", "/s", "/c"]) + .arg(windows_command_line(program, args)) + .status() + .map_err(|error| format!("failed to run {}: {error}", program.display()))?; + return Ok(status.code().unwrap_or(1)); + } + + let status = Command::new(program) + .args(args) + .status() + .map_err(|error| format!("failed to run {}: {error}", program.display()))?; + Ok(status.code().unwrap_or(1)) + } + + fn run_quiet(&self, program: &Path, args: &[String]) -> Result { + #[cfg(windows)] + if is_windows_command_script(program) { + let status = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) + .args(["/d", "/s", "/c"]) + .arg(windows_command_line(program, args)) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|error| format!("failed to run {}: {error}", program.display()))?; + return Ok(status.code().unwrap_or(1)); + } + + let status = Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(|error| format!("failed to run {}: {error}", program.display()))?; + Ok(status.code().unwrap_or(1)) + } +} + +fn find_executable(command: &str) -> Option { + let path = env::var_os("PATH")?; + let candidates = env::split_paths(&path); + let extensions = executable_extensions(command); + for dir in candidates { + for extension in &extensions { + let candidate = dir.join(format!("{command}{extension}")); + if candidate.is_file() { + return Some(candidate); + } + } + } + None +} + +fn executable_extensions(command: &str) -> Vec { + if cfg!(windows) && Path::new(command).extension().is_none() { + env::var("PATHEXT") + .unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into()) + .split(';') + .map(str::to_string) + .collect() + } else { + vec![String::new()] + } +} + +#[cfg(windows)] +fn is_windows_command_script(program: &Path) -> bool { + program + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") + }) +} + +#[cfg(windows)] +fn windows_command_line(program: &Path, args: &[String]) -> String { + std::iter::once(windows_command_argument(&program.display().to_string())) + .chain(args.iter().map(|arg| windows_command_argument(arg))) + .collect::>() + .join(" ") +} + +#[cfg(windows)] +fn windows_command_argument(argument: &str) -> String { + format!("\"{}\"", argument.replace('"', "\\\"")) +} diff --git a/crates/cli/src/plugin_install/marketplace.rs b/crates/cli/src/plugin_install/marketplace.rs new file mode 100644 index 000000000..0169fd435 --- /dev/null +++ b/crates/cli/src/plugin_install/marketplace.rs @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Generated local marketplace and plugin manifest files. + +use std::fs; + +use serde_json::{Value, json}; + +use crate::config::{CodingAgent, PluginHost}; +use crate::installer::generated_hooks; + +use super::state::{PluginInstallOptions, PluginLayout, remove_path, write_json}; +use super::{MARKETPLACE_NAME, PLUGIN_NAME}; + +pub(super) fn write_plugin_marketplace( + host: PluginHost, + layout: &PluginLayout, + options: &PluginInstallOptions, +) -> Result<(), String> { + if options.dry_run { + println!("write {}", layout.marketplace_manifest.display()); + println!("write {}", layout.plugin_manifest.display()); + if plugin_has_hooks_template(host) { + println!("write {}", layout.hooks_path.display()); + } + return Ok(()); + } + remove_path(&layout.plugin_root, options)?; + fs::create_dir_all( + layout + .plugin_root + .parent() + .unwrap_or(&layout.marketplace_root), + ) + .map_err(|error| format!("failed to create {}: {error}", layout.plugin_root.display()))?; + if plugin_has_hooks_template(host) { + fs::create_dir_all(layout.hooks_path.parent().unwrap_or(&layout.plugin_root)).map_err( + |error| format!("failed to create {}: {error}", layout.hooks_path.display()), + )?; + } + write_json(&layout.marketplace_manifest, &marketplace_manifest(host))?; + write_json(&layout.plugin_manifest, &plugin_manifest(host))?; + if plugin_has_hooks_template(host) { + write_json(&layout.hooks_path, &plugin_hooks(host))?; + } + Ok(()) +} + +pub(super) fn marketplace_manifest(host: PluginHost) -> Value { + match host { + PluginHost::Codex => json!({ + "name": MARKETPLACE_NAME, + "interface": { + "displayName": "NeMo Relay Local" + }, + "plugins": [{ + "name": PLUGIN_NAME, + "source": { + "source": "local", + "path": "./plugins/nemo-relay-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Coding" + }] + }), + PluginHost::ClaudeCode => json!({ + "name": MARKETPLACE_NAME, + "metadata": { + "description": "Local NeMo Relay plugins for Claude Code." + }, + "owner": { + "name": "NVIDIA Corporation and Affiliates", + "email": "noreply@nvidia.com" + }, + "plugins": [{ + "name": PLUGIN_NAME, + "description": "Forward Claude Code lifecycle hooks to a local NeMo Relay sidecar.", + "source": "./plugins/nemo-relay-plugin", + "category": "development" + }] + }), + PluginHost::All => unreachable!("all is expanded before manifest generation"), + } +} + +pub(super) fn plugin_manifest(host: PluginHost) -> Value { + let description = match host { + PluginHost::Codex => "Codex hooks that forward canonical lifecycle payloads to nemo-relay.", + PluginHost::ClaudeCode => { + "Claude Code hooks that forward canonical lifecycle payloads to nemo-relay." + } + PluginHost::All => unreachable!("all is expanded before manifest generation"), + }; + let keywords = match host { + PluginHost::Codex => json!(["nemo-relay", "codex", "hooks", "observability"]), + PluginHost::ClaudeCode => json!(["nemo-relay", "claude-code", "hooks", "observability"]), + PluginHost::All => unreachable!("all is expanded before manifest generation"), + }; + let mut manifest = json!({ + "name": PLUGIN_NAME, + "version": env!("CARGO_PKG_VERSION"), + "description": description, + "author": { + "name": "NVIDIA Corporation and Affiliates", + "url": "https://github.com/NVIDIA/NeMo-Relay" + }, + "homepage": "https://github.com/NVIDIA/NeMo-Relay", + "repository": "https://github.com/NVIDIA/NeMo-Relay", + "license": "Apache-2.0", + "keywords": keywords + }); + if matches!(host, PluginHost::Codex) { + manifest["interface"] = json!({ + "displayName": "NeMo Relay Plugin", + "shortDescription": "Forward Codex lifecycle hooks to a local NeMo Relay sidecar.", + "longDescription": "Installs command hooks that preserve Codex hook payloads and forward them to nemo-relay for agent, subagent, tool, and lifecycle observability. Full LLM capture also requires sidecar provider routing.", + "developerName": "NVIDIA", + "category": "Coding", + "capabilities": ["Read"], + "defaultPrompt": ["Capture this Codex session with NeMo Relay observability."], + "websiteURL": "https://github.com/NVIDIA/NeMo-Relay", + "brandColor": "#76B900" + }); + } + manifest +} + +pub(super) fn plugin_hooks(host: PluginHost) -> Value { + match host { + PluginHost::Codex => { + generated_hooks(CodingAgent::Codex, "nemo-relay plugin-shim hook codex") + } + PluginHost::ClaudeCode => generated_hooks( + CodingAgent::ClaudeCode, + "nemo-relay plugin-shim hook claude", + ), + PluginHost::All => unreachable!("all is expanded before hook generation"), + } +} + +pub(super) fn plugin_has_hooks_template(host: PluginHost) -> bool { + match host { + PluginHost::Codex => false, + PluginHost::ClaudeCode => true, + PluginHost::All => unreachable!("all is expanded before hook generation"), + } +} diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs new file mode 100644 index 000000000..9dabdf887 --- /dev/null +++ b/crates/cli/src/plugin_install/mod.rs @@ -0,0 +1,380 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Local marketplace installer for Claude Code and Codex plugins. + +mod host; +mod marketplace; +mod setup; +mod state; + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use crate::config::{InstallCommand, PluginHost, UninstallCommand}; +use crate::error::CliError; + +use host::{ + CommandRunner, RealCommandRunner, require_host_cli, require_relay, + run_host_marketplace_registration, run_host_marketplace_removal, run_host_plugin_registration, + run_host_plugin_removal, validate_relay_plugin_shim, +}; +use marketplace::write_plugin_marketplace; +use setup::{ + PluginSetupRunner, RealPluginSetupRunner, run_plugin_doctor, run_plugin_setup, + run_plugin_uninstall, +}; +use state::{ + CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, PluginInstallOptions, + PluginLayout, PluginState, default_install_dir, mark_plugin_setup_installed, read_state, + remove_path, state_path, write_state, write_state_for_host, +}; + +pub(super) const DEFAULT_GATEWAY_URL: &str = "http://127.0.0.1:47632"; +pub(super) const MARKETPLACE_NAME: &str = "nemo-relay-local"; +pub(super) const PLUGIN_NAME: &str = "nemo-relay-plugin"; +pub(super) const RELAY_COMMAND: &str = "nemo-relay"; + +pub(crate) fn install(command: InstallCommand) -> Result { + let options = PluginInstallOptions { + install_dir: command + .install_dir + .unwrap_or_else(default_install_dir) + .canonicalize_or_self(), + force: command.force, + dry_run: command.dry_run, + skip_doctor: command.skip_doctor, + }; + run_for_hosts( + command.host, + HostSelectionMode::Install, + &options, + |host, options, runner, setup_runner| install_host(host, options, runner, setup_runner), + ) +} + +pub(crate) fn uninstall(command: UninstallCommand) -> Result { + let options = PluginInstallOptions { + install_dir: command + .install_dir + .unwrap_or_else(default_install_dir) + .canonicalize_or_self(), + force: false, + dry_run: command.dry_run, + skip_doctor: true, + }; + run_for_hosts( + command.host, + HostSelectionMode::InstalledState, + &options, + |host, options, runner, setup_runner| uninstall_host(host, options, runner, setup_runner), + ) +} + +pub(crate) fn doctor( + host: PluginHost, + install_dir: Option, + _json: bool, +) -> Result { + let options = PluginInstallOptions { + install_dir: install_dir + .unwrap_or_else(default_install_dir) + .canonicalize_or_self(), + force: false, + dry_run: false, + skip_doctor: true, + }; + run_for_hosts( + host, + HostSelectionMode::InstalledState, + &options, + |host, options, runner, setup_runner| doctor_host(host, options, runner, setup_runner), + ) +} + +fn run_for_hosts( + host: PluginHost, + mode: HostSelectionMode, + options: &PluginInstallOptions, + mut action: F, +) -> Result +where + F: FnMut( + PluginHost, + &PluginInstallOptions, + &dyn CommandRunner, + &dyn PluginSetupRunner, + ) -> Result<(), String>, +{ + let runner = RealCommandRunner; + let setup_runner = RealPluginSetupRunner; + let hosts = select_hosts(host, mode, options, &runner)?; + if hosts.is_empty() { + return Err(CliError::Install(match host { + PluginHost::All => match mode { + HostSelectionMode::Install => { + "no supported Claude Code or Codex host CLI was detected".into() + } + HostSelectionMode::InstalledState => { + "no installed Claude Code or Codex plugin state was found".into() + } + }, + _ => "no supported plugin host selected".into(), + })); + } + for host in hosts { + action(host, options, &runner, &setup_runner).map_err(CliError::Install)?; + } + Ok(ExitCode::SUCCESS) +} + +fn select_hosts( + host: PluginHost, + mode: HostSelectionMode, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result, CliError> { + if host != PluginHost::All { + return Ok(vec![host]); + } + let mut hosts = Vec::new(); + for candidate in [PluginHost::Codex, PluginHost::ClaudeCode] { + let selected = match mode { + HostSelectionMode::Install => runner + .resolve_executable(host_cli(candidate)) + .map_err(CliError::Install)? + .is_some(), + HostSelectionMode::InstalledState => { + state_path(candidate, &options.install_dir).exists() + } + }; + if selected { + hosts.push(candidate); + } + } + Ok(hosts) +} + +fn install_host( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + let relay = require_relay(options, runner)?; + validate_relay_plugin_shim(&relay, options, runner)?; + require_host_cli(host, options, runner)?; + let layout = PluginLayout::new(host, &options.install_dir); + if options.force { + force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; + } + write_plugin_marketplace(host, &layout, options)?; + write_state(&layout, options)?; + let mut registration = HostRegistrationProgress::default(); + let mut setup_attempted = false; + let result = (|| { + run_host_marketplace_registration(host, &layout, options, runner)?; + registration.host_marketplace_added = true; + run_host_plugin_registration(host, options, runner)?; + registration.host_plugin_added = true; + setup_attempted = true; + run_plugin_setup(host, options, setup_runner)?; + mark_plugin_setup_installed(host, &layout, options)?; + if !options.skip_doctor { + run_plugin_doctor(host, options, setup_runner)?; + } + Ok(()) + })(); + if let Err(error) = result { + if let Err(rollback_error) = rollback_install( + host, + &layout, + registration, + setup_attempted, + options, + runner, + setup_runner, + ) { + return Err(format!( + "{error}; additionally failed to roll back install: {rollback_error}" + )); + } + return Err(error); + } + println!( + "installed {} plugin marketplace at {}", + host_label(host), + layout.marketplace_root.display() + ); + Ok(()) +} + +fn uninstall_host( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + uninstall_host_with_setup_override(host, options, runner, setup_runner, false) +} + +fn uninstall_host_with_setup_override( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, + force_plugin_setup_uninstall: bool, +) -> Result<(), String> { + let state = read_state(host, &options.install_dir).unwrap_or_else(|| { + let layout = PluginLayout::new(host, &options.install_dir); + PluginState { + marketplace_root: layout.marketplace_root, + plugin_root: layout.plugin_root, + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: true, + } + }); + let relay = require_relay(options, runner)?; + validate_relay_plugin_shim(&relay, options, runner)?; + let mut state = state; + if force_plugin_setup_uninstall && !state.plugin_setup_installed { + state.plugin_setup_installed = true; + write_state_for_host(host, &state, &options.install_dir, options)?; + } + run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; + if force_plugin_setup_uninstall || state.plugin_setup_installed { + run_plugin_uninstall(host, options, setup_runner)?; + state.plugin_setup_installed = false; + write_state_for_host(host, &state, &options.install_dir, options)?; + } + remove_path(&state.marketplace_root, options)?; + remove_path(&state_path(host, &options.install_dir), options)?; + println!("uninstalled {} plugin", host_label(host)); + Ok(()) +} + +fn doctor_host( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + let relay = require_relay(options, runner)?; + validate_relay_plugin_shim(&relay, options, runner)?; + let state = read_state(host, &options.install_dir) + .ok_or_else(|| format!("no installed {} plugin state found", host_label(host)))?; + println!("nemo-relay: {}", relay.display()); + println!("host: {}", host_arg(host)); + println!("marketplace: {}", state.marketplace_root.display()); + println!("plugin: {}", state.plugin_root.display()); + run_plugin_doctor(host, options, setup_runner) +} + +fn force_cleanup_existing_install( + host: PluginHost, + layout: &PluginLayout, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + if layout.state_path.exists() { + uninstall_host(host, options, runner, setup_runner)?; + } else { + let mut state = PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: false, + }; + run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; + remove_path(&layout.marketplace_root, options)?; + remove_path(&layout.state_path, options)?; + } + Ok(()) +} + +fn rollback_install( + host: PluginHost, + layout: &PluginLayout, + registration: HostRegistrationProgress, + setup_attempted: bool, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + if setup_attempted { + return uninstall_host_with_setup_override(host, options, runner, setup_runner, true); + } + let mut state = read_state(host, &options.install_dir).unwrap_or_else(|| PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: false, + }); + if registration.any_added() { + state.host_plugin_removed |= !registration.host_plugin_added; + state.host_marketplace_removed |= !registration.host_marketplace_added; + write_state_for_host(host, &state, &options.install_dir, options)?; + run_host_unregistration(host, &mut state, &options.install_dir, options, runner)?; + } + remove_path(&layout.marketplace_root, options)?; + remove_path(&layout.state_path, options) +} + +fn run_host_unregistration( + host: PluginHost, + state: &mut PluginState, + install_dir: &Path, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result<(), String> { + if !state.host_plugin_removed { + require_host_cli(host, options, runner)?; + run_host_plugin_removal(host, options, runner)?; + state.host_plugin_removed = true; + write_state_for_host(host, state, install_dir, options)?; + } + if !state.host_marketplace_removed { + require_host_cli(host, options, runner)?; + run_host_marketplace_removal(host, options, runner)?; + state.host_marketplace_removed = true; + write_state_for_host(host, state, install_dir, options)?; + } + Ok(()) +} + +fn host_arg(host: PluginHost) -> &'static str { + match host { + PluginHost::Codex => "codex", + PluginHost::ClaudeCode => "claude-code", + PluginHost::All => "all", + } +} + +fn host_label(host: PluginHost) -> &'static str { + match host { + PluginHost::Codex => "Codex", + PluginHost::ClaudeCode => "Claude Code", + PluginHost::All => "all", + } +} + +fn host_cli(host: PluginHost) -> &'static str { + match host { + PluginHost::Codex => "codex", + PluginHost::ClaudeCode => "claude", + PluginHost::All => unreachable!("all is expanded before host CLI resolution"), + } +} + +#[cfg(test)] +use marketplace::*; +#[cfg(test)] +use state::*; + +#[cfg(test)] +#[path = "../../tests/coverage/plugin_install_tests.rs"] +mod tests; diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/plugin_install/setup.rs new file mode 100644 index 000000000..448784cfc --- /dev/null +++ b/crates/cli/src/plugin_install/setup.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Plugin-shim setup, restore, and doctor delegation. + +use crate::config::{CodingAgent, PluginHost}; +use crate::plugin_shim; + +use super::DEFAULT_GATEWAY_URL; +use super::state::PluginInstallOptions; + +pub(super) fn run_plugin_setup( + host: PluginHost, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + if options.dry_run { + println!("{}", setup_action_description(host, "configure")); + return Ok(()); + } + setup_runner.setup(host, DEFAULT_GATEWAY_URL) +} + +pub(super) fn run_plugin_uninstall( + host: PluginHost, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + if options.dry_run { + println!("{}", setup_action_description(host, "restore")); + return Ok(()); + } + setup_runner.uninstall(host, DEFAULT_GATEWAY_URL) +} + +pub(super) fn run_plugin_doctor( + host: PluginHost, + options: &PluginInstallOptions, + setup_runner: &dyn PluginSetupRunner, +) -> Result<(), String> { + if options.dry_run { + println!("{}", setup_action_description(host, "doctor")); + return Ok(()); + } + setup_runner.doctor(host, DEFAULT_GATEWAY_URL) +} + +pub(super) fn setup_action_description(host: PluginHost, action: &str) -> String { + match (host, action) { + (PluginHost::Codex, "configure") => { + "configure Codex provider and hook-supervised lazy startup".into() + } + (PluginHost::Codex, "restore") => { + "restore Codex provider and generated hook configuration".into() + } + (PluginHost::Codex, "doctor") => "check Codex provider and generated hooks".into(), + (PluginHost::ClaudeCode, "configure") => { + "enable Claude Code provider routing through NeMo Relay".into() + } + (PluginHost::ClaudeCode, "restore") => { + "restore Claude Code provider routing from NeMo Relay backup".into() + } + (PluginHost::ClaudeCode, "doctor") => "check Claude Code provider routing".into(), + (PluginHost::All, _) => unreachable!("all is expanded before plugin setup"), + (_, _) => unreachable!("unsupported setup action"), + } +} + +pub(super) trait PluginSetupRunner { + fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; + fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; + fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; +} + +pub(super) struct RealPluginSetupRunner; + +impl PluginSetupRunner for RealPluginSetupRunner { + fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + match host { + PluginHost::Codex => plugin_shim::install_codex_plugin(gateway_url), + PluginHost::ClaudeCode => plugin_shim::enable_claude_provider(gateway_url), + PluginHost::All => unreachable!("all is expanded before plugin setup"), + } + } + + fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + match host { + PluginHost::Codex => plugin_shim::uninstall_codex_plugin(gateway_url), + PluginHost::ClaudeCode => plugin_shim::restore_claude_provider(gateway_url), + PluginHost::All => unreachable!("all is expanded before plugin uninstall"), + } + } + + fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + match host { + PluginHost::Codex => plugin_shim::doctor_plugin(CodingAgent::Codex, gateway_url), + PluginHost::ClaudeCode => { + plugin_shim::doctor_plugin(CodingAgent::ClaudeCode, gateway_url) + } + PluginHost::All => unreachable!("all is expanded before plugin doctor"), + } + } +} diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/plugin_install/state.rs new file mode 100644 index 000000000..25fcac847 --- /dev/null +++ b/crates/cli/src/plugin_install/state.rs @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Install directory layout, persisted state, and filesystem helpers. + +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::config::PluginHost; + +use super::{PLUGIN_NAME, host_arg}; + +#[derive(Debug, Clone)] +pub(super) struct PluginInstallOptions { + pub(super) install_dir: PathBuf, + pub(super) force: bool, + pub(super) dry_run: bool, + pub(super) skip_doctor: bool, +} + +#[derive(Debug, Clone, Copy)] +pub(super) enum HostSelectionMode { + Install, + InstalledState, +} + +#[derive(Debug, Clone, Copy, Default)] +pub(super) struct HostRegistrationProgress { + pub(super) host_plugin_added: bool, + pub(super) host_marketplace_added: bool, +} + +impl HostRegistrationProgress { + pub(super) fn any_added(self) -> bool { + self.host_plugin_added || self.host_marketplace_added + } +} + +#[derive(Debug, Clone)] +pub(super) struct PluginLayout { + pub(super) host: PluginHost, + pub(super) marketplace_root: PathBuf, + pub(super) marketplace_manifest: PathBuf, + pub(super) plugin_root: PathBuf, + pub(super) plugin_manifest: PathBuf, + pub(super) hooks_path: PathBuf, + pub(super) state_path: PathBuf, +} + +impl PluginLayout { + pub(super) fn new(host: PluginHost, install_dir: &Path) -> Self { + let marketplace_root = install_dir.join(format!("{}-marketplace", host_arg(host))); + let marketplace_manifest = match host { + PluginHost::Codex => marketplace_root + .join(".agents") + .join("plugins") + .join("marketplace.json"), + PluginHost::ClaudeCode => marketplace_root + .join(".claude-plugin") + .join("marketplace.json"), + PluginHost::All => unreachable!("all is expanded before layout resolution"), + }; + let plugin_root = marketplace_root.join("plugins").join(PLUGIN_NAME); + let plugin_manifest = match host { + PluginHost::Codex => plugin_root.join(".codex-plugin").join("plugin.json"), + PluginHost::ClaudeCode => plugin_root.join(".claude-plugin").join("plugin.json"), + PluginHost::All => unreachable!("all is expanded before layout resolution"), + }; + let hooks_path = plugin_root.join("hooks").join("hooks.json"); + let state_path = state_path(host, install_dir); + Self { + host, + marketplace_root, + marketplace_manifest, + plugin_root, + plugin_manifest, + hooks_path, + state_path, + } + } +} + +#[derive(Debug, Clone)] +pub(super) struct PluginState { + pub(super) marketplace_root: PathBuf, + pub(super) plugin_root: PathBuf, + pub(super) host_plugin_removed: bool, + pub(super) host_marketplace_removed: bool, + pub(super) plugin_setup_installed: bool, +} + +pub(super) trait CanonicalizeOrSelf { + fn canonicalize_or_self(self) -> Self; +} + +impl CanonicalizeOrSelf for PathBuf { + fn canonicalize_or_self(self) -> Self { + self.canonicalize().unwrap_or(self) + } +} + +pub(super) fn default_install_dir() -> PathBuf { + default_install_dir_for( + env::consts::OS, + env::var_os("HOME"), + env::var_os("USERPROFILE"), + env::var_os("LOCALAPPDATA"), + env::var_os("XDG_DATA_HOME"), + ) +} + +pub(super) fn default_install_dir_for( + os: &str, + home: Option, + userprofile: Option, + localappdata: Option, + xdg_data_home: Option, +) -> PathBuf { + let home = home + .or(userprofile) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + match os { + "macos" => home + .join("Library") + .join("Application Support") + .join("nemo-relay") + .join("plugins"), + "windows" => localappdata + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Local")) + .join("nemo-relay") + .join("plugins"), + _ => xdg_data_home + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".local").join("share")) + .join("nemo-relay") + .join("plugins"), + } +} + +pub(super) fn write_state( + layout: &PluginLayout, + options: &PluginInstallOptions, +) -> Result<(), String> { + write_state_for_host( + layout.host, + &PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: false, + }, + layout.state_path.parent().unwrap_or_else(|| Path::new(".")), + options, + ) +} + +pub(super) fn mark_plugin_setup_installed( + host: PluginHost, + layout: &PluginLayout, + options: &PluginInstallOptions, +) -> Result<(), String> { + let mut state = read_state(host, &options.install_dir).unwrap_or_else(|| PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: false, + host_marketplace_removed: false, + plugin_setup_installed: false, + }); + state.plugin_setup_installed = true; + write_state_for_host(host, &state, &options.install_dir, options) +} + +pub(super) fn write_state_for_host( + host: PluginHost, + state: &PluginState, + install_dir: &Path, + options: &PluginInstallOptions, +) -> Result<(), String> { + let path = state_path(host, install_dir); + if options.dry_run { + println!("write {}", path.display()); + return Ok(()); + } + write_json( + &path, + &json!({ + "host": host_arg(host), + "marketplaceRoot": state.marketplace_root, + "pluginRoot": state.plugin_root, + "hostUnregistered": state.host_plugin_removed && state.host_marketplace_removed, + "hostPluginRemoved": state.host_plugin_removed, + "hostMarketplaceRemoved": state.host_marketplace_removed, + "pluginSetupInstalled": state.plugin_setup_installed + }), + ) +} + +pub(super) fn read_state(host: PluginHost, install_dir: &Path) -> Option { + let raw = fs::read_to_string(state_path(host, install_dir)).ok()?; + let value = serde_json::from_str::(&raw).ok()?; + let legacy_host_unregistered = value + .get("hostUnregistered") + .and_then(Value::as_bool) + .unwrap_or(false); + Some(PluginState { + marketplace_root: PathBuf::from(value.get("marketplaceRoot")?.as_str()?), + plugin_root: PathBuf::from(value.get("pluginRoot")?.as_str()?), + host_plugin_removed: value + .get("hostPluginRemoved") + .and_then(Value::as_bool) + .unwrap_or(legacy_host_unregistered), + host_marketplace_removed: value + .get("hostMarketplaceRemoved") + .and_then(Value::as_bool) + .unwrap_or(legacy_host_unregistered), + plugin_setup_installed: value + .get("pluginSetupInstalled") + .and_then(Value::as_bool) + .unwrap_or(true), + }) +} + +pub(super) fn state_path(host: PluginHost, install_dir: &Path) -> PathBuf { + install_dir.join(format!("{}.json", host_arg(host))) +} + +pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; + bytes.push(b'\n'); + fs::write(path, bytes).map_err(|error| format!("failed to write {}: {error}", path.display())) +} + +pub(super) fn remove_path(path: &Path, options: &PluginInstallOptions) -> Result<(), String> { + if options.dry_run { + println!("remove {}", path.display()); + return Ok(()); + } + fs::remove_dir_all(path) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(error) + } + }) + .or_else(|_| fs::remove_file(path)) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(format!("failed to remove {}: {error}", path.display())) + } + }) +} diff --git a/crates/cli/src/plugin_shim/claude.rs b/crates/cli/src/plugin_shim/claude.rs new file mode 100644 index 000000000..b7bb78218 --- /dev/null +++ b/crates/cli/src/plugin_shim/claude.rs @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Claude Code-specific provider routing setup. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use serde_json::{Value, json}; + +use super::command::PluginShimProviderAction; +use super::shared::{ + backup, backup_path, home_dir, read_json_object, remove_backup, restore_file_snapshot, + snapshot_optional_file, write_json, +}; + +pub(super) fn claude_provider( + action: PluginShimProviderAction, + gateway_url: &str, +) -> Result { + match action { + PluginShimProviderAction::Enable => { + let path = claude_settings_path()?; + let mut settings = read_json_object(&path)?; + if settings.get("env").is_some_and(|env| !env.is_object()) { + return Err(format!("{} has a non-object env field", path.display())); + } + let backup_snapshot = snapshot_optional_file(&backup_path(&path))?; + if json_env_string(&settings, "ANTHROPIC_BASE_URL") != Some(gateway_url) { + backup_claude_settings(&path)?; + } + let env = settings + .as_object_mut() + .expect("read_json_object returns an object") + .entry("env") + .or_insert_with(|| json!({})); + let env = env.as_object_mut().expect("env was validated as an object"); + env.insert("ANTHROPIC_BASE_URL".into(), json!(gateway_url)); + if let Err(error) = write_json(&path, &settings) { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); + } + println!("set ANTHROPIC_BASE_URL={gateway_url} in {}", path.display()); + Ok(ExitCode::SUCCESS) + } + PluginShimProviderAction::Restore => { + let path = claude_settings_path()?; + let backup = backup_path(&path); + if !backup.exists() { + println!( + "no backup found at {}; no managed Claude provider routing to restore", + backup.display() + ); + return Ok(ExitCode::SUCCESS); + } + let mut settings = read_json_object(&path)?; + if json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url) { + let backup_settings = read_json_object(&backup)?; + restore_json_env_value(&mut settings, &backup_settings, "ANTHROPIC_BASE_URL")?; + write_json(&path, &settings)?; + remove_backup(&path)?; + println!( + "restored managed ANTHROPIC_BASE_URL in {} from {}", + path.display(), + backup.display() + ); + } else { + remove_backup(&path)?; + println!( + "current Claude provider routing is not managed by Relay; left {} unchanged", + path.display() + ); + } + Ok(ExitCode::SUCCESS) + } + PluginShimProviderAction::Status => { + println!( + "{}", + claude_settings_base_url().unwrap_or_else(|| { + "ANTHROPIC_BASE_URL is not configured in Claude settings".into() + }) + ); + Ok(ExitCode::SUCCESS) + } + } +} + +pub(super) fn json_env_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get("env") + .and_then(Value::as_object) + .and_then(|env| env.get(key)) + .and_then(Value::as_str) +} + +pub(super) fn remove_json_env_string(value: &mut Value, key: &str) -> Result { + let Some(object) = value.as_object_mut() else { + return Err("Claude settings must be a JSON object".into()); + }; + let Some(env) = object.get_mut("env") else { + return Ok(false); + }; + let Some(env) = env.as_object_mut() else { + return Err("Claude settings env field must be a JSON object".into()); + }; + let removed = env.remove(key).is_some(); + if env.is_empty() { + object.remove("env"); + } + Ok(removed) +} + +pub(super) fn restore_json_env_value( + value: &mut Value, + backup: &Value, + key: &str, +) -> Result<(), String> { + let backup_value = backup + .get("env") + .and_then(Value::as_object) + .and_then(|env| env.get(key)) + .cloned(); + if let Some(backup_value) = backup_value { + let Some(object) = value.as_object_mut() else { + return Err("Claude settings must be a JSON object".into()); + }; + let env = object.entry("env").or_insert_with(|| json!({})); + let Some(env) = env.as_object_mut() else { + return Err("Claude settings env field must be a JSON object".into()); + }; + env.insert(key.into(), backup_value); + } else { + remove_json_env_string(value, key)?; + } + Ok(()) +} + +pub(super) fn backup_claude_settings(path: &Path) -> Result<(), String> { + let backup_file = backup_path(path); + if backup_file.exists() { + return Ok(()); + } + if path.exists() { + backup(path) + } else { + if let Some(parent) = backup_file.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + fs::write(&backup_file, b"{}\n") + .map_err(|error| format!("failed to write {}: {error}", backup_file.display())) + } +} + +pub(super) fn claude_settings_path() -> Result { + Ok(home_dir()?.join(".claude").join("settings.json")) +} + +pub(super) fn claude_settings_base_url() -> Option { + let path = claude_settings_path().ok()?; + let value = read_json_object(&path).ok()?; + value + .get("env") + .and_then(Value::as_object) + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) +} diff --git a/crates/cli/src/plugin_shim/codex.rs b/crates/cli/src/plugin_shim/codex.rs new file mode 100644 index 000000000..8f2c7e9b6 --- /dev/null +++ b/crates/cli/src/plugin_shim/codex.rs @@ -0,0 +1,580 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Codex-specific plugin setup, provider routing, and hook configuration. + +use std::fs; +use std::path::Path; +use std::process::ExitCode; + +use serde_json::{Value, json}; +use toml_edit::{DocumentMut, Item, Table, value}; + +use crate::config::CodingAgent; +use crate::installer::{generated_hooks, merge_hooks}; + +use super::shared::{ + FileSnapshot, atomic_write, backup, backup_path, current_exe, ensure_table, home_dir, + read_json_object, remove_backup, restore_file_snapshot, snapshot_optional_file, write_json, +}; + +pub(super) fn install_codex(gateway_url: &str) -> Result { + let codex_dir = home_dir()?.join(".codex"); + fs::create_dir_all(&codex_dir) + .map_err(|error| format!("failed to create {}: {error}", codex_dir.display()))?; + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + prepare_codex_config(&config_path)?; + let hooks_snapshot = snapshot_optional_file(&hooks_path)?; + let hooks_backup_snapshot = snapshot_optional_file(&backup_path(&hooks_path))?; + if let Err(error) = install_codex_hooks(&hooks_path, gateway_url) { + if let Err(rollback_error) = + restore_codex_hooks_snapshot(&hooks_snapshot, &hooks_backup_snapshot) + { + return Err(format!( + "{error}; additionally failed to roll back Codex hooks at {}: {rollback_error}", + hooks_path.display() + )); + } + return Err(error); + } + if let Err(error) = install_codex_config(&config_path, gateway_url) { + if let Err(rollback_error) = + restore_codex_hooks_snapshot(&hooks_snapshot, &hooks_backup_snapshot) + { + return Err(format!( + "{error}; additionally failed to roll back Codex hooks at {}: {rollback_error}", + hooks_path.display() + )); + } + return Err(error); + } + println!("updated {}", config_path.display()); + println!("updated {}", hooks_path.display()); + println!("Codex Relay sidecar startup is hook-supervised; no daemon was installed."); + Ok(ExitCode::SUCCESS) +} + +pub(super) fn uninstall_codex(installed_gateway_url: &str) -> Result { + let codex_dir = home_dir()?.join(".codex"); + let config_path = codex_dir.join("config.toml"); + let hooks_path = codex_dir.join("hooks.json"); + let hook_gateway_url = + codex_provider_gateway_url(&config_path).unwrap_or_else(|| installed_gateway_url.into()); + let hooks_snapshot = snapshot_optional_file(&hooks_path)?; + let hooks_backup_snapshot = snapshot_optional_file(&backup_path(&hooks_path))?; + let has_remaining_hooks = uninstall_codex_hooks(&hooks_path, &hook_gateway_url)?; + if let Err(error) = + uninstall_codex_config(&config_path, installed_gateway_url, has_remaining_hooks) + { + if let Err(rollback_error) = + restore_codex_hooks_snapshot(&hooks_snapshot, &hooks_backup_snapshot) + { + return Err(format!( + "{error}; additionally failed to roll back Codex hooks at {}: {rollback_error}", + hooks_path.display() + )); + } + return Err(error); + } + println!("updated {}", config_path.display()); + println!("updated {}", hooks_path.display()); + println!("removed Codex Relay hook-supervised sidecar setup."); + Ok(ExitCode::SUCCESS) +} + +pub(super) fn prepare_codex_config(path: &Path) -> Result<(), String> { + let raw = read_optional_text(path)?; + raw.parse::() + .map(|_| ()) + .map_err(|error| format!("invalid TOML in {}: {error}", path.display())) +} + +pub(super) fn install_codex_config(path: &Path, gateway_url: &str) -> Result<(), String> { + let raw = read_optional_text(path)?; + let mut doc = raw + .parse::() + .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; + let backup_snapshot = snapshot_optional_file(&backup_path(path))?; + if !codex_config_doc_has_managed_install(&doc, gateway_url) { + backup(path)?; + } + doc["model_provider"] = value("nemo-relay-openai"); + ensure_table(&mut doc, "features")["hooks"] = value(true); + + let providers = ensure_table(&mut doc, "model_providers"); + let mut provider = Table::new(); + provider["name"] = value("NeMo Relay"); + provider["base_url"] = value(gateway_url); + provider["wire_api"] = value("responses"); + provider["requires_openai_auth"] = value(true); + provider["supports_websockets"] = value(false); + providers["nemo-relay-openai"] = Item::Table(provider); + + if let Err(error) = atomic_write(path, doc.to_string().as_bytes()) { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); + } + Ok(()) +} + +pub(super) fn read_optional_text(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(raw) => Ok(raw), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } +} + +pub(super) fn uninstall_codex_config( + path: &Path, + gateway_url: &str, + preserve_hooks: bool, +) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let mut doc = raw + .parse::() + .map_err(|error| format!("invalid TOML in {}: {error}", path.display()))?; + let backup = backup_path(path); + let backup_doc = if backup.exists() { + let raw = fs::read_to_string(&backup) + .map_err(|error| format!("failed to read {}: {error}", backup.display()))?; + Some( + raw.parse::() + .map_err(|error| format!("invalid TOML in {}: {error}", backup.display()))?, + ) + } else { + None + }; + + let provider_is_managed = codex_provider_item_is_managed(&doc, gateway_url); + if let Some(backup_doc) = backup_doc.as_ref() { + if provider_is_managed { + restore_top_level_item_if_str( + &mut doc, + backup_doc, + "model_provider", + "nemo-relay-openai", + ); + restore_table_item(&mut doc, backup_doc, "model_providers", "nemo-relay-openai"); + } + if !preserve_hooks || feature_hooks_enabled(&doc) != Some(true) { + restore_table_item_if_bool(&mut doc, backup_doc, "features", "hooks", true); + } + } else { + if provider_is_managed + && doc + .get("model_provider") + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some("nemo-relay-openai") + { + doc.as_table_mut().remove("model_provider"); + } + if provider_is_managed + && let Some(providers) = doc.get_mut("model_providers").and_then(Item::as_table_mut) + { + providers.remove("nemo-relay-openai"); + } + if !preserve_hooks { + remove_table_item_if_bool(&mut doc, "features", "hooks", true); + } + } + + remove_empty_table(&mut doc, "model_providers"); + remove_empty_table(&mut doc, "features"); + atomic_write(path, doc.to_string().as_bytes())?; + remove_backup(path) +} + +pub(super) fn install_codex_hooks(path: &Path, gateway_url: &str) -> Result<(), String> { + let relay = current_exe()?; + let command = codex_hook_command(gateway_url); + let generated = generated_hooks(CodingAgent::Codex, &command); + let mut existing = if path.exists() { + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let existing = serde_json::from_str::(&raw) + .map_err(|error| format!("invalid JSON in {}: {error}", path.display()))?; + if !hook_config_contains_generated_groups(&existing, &generated) { + backup(path)?; + } + existing + } else { + json!({}) + }; + remove_managed_codex_hook_groups(&mut existing, &relay, Some(gateway_url)); + let merged = merge_hooks(existing, generated).map_err(|error| error.to_string())?; + let bytes = serde_json::to_vec_pretty(&merged).map_err(|error| error.to_string())?; + let mut output = bytes; + output.push(b'\n'); + atomic_write(path, &output) +} + +pub(super) fn uninstall_codex_hooks(path: &Path, _gateway_url: &str) -> Result { + if !path.exists() { + return Ok(false); + } + let mut value = read_json_object(path)?; + let relay = current_exe()?; + remove_managed_codex_hook_groups(&mut value, &relay, None); + let has_remaining_hooks = hook_config_has_hook_groups(&value); + write_json(path, &value)?; + Ok(has_remaining_hooks) +} + +pub(super) fn remove_managed_codex_hook_groups( + value: &mut Value, + relay: &Path, + keep_gateway_url: Option<&str>, +) { + let Some(hooks) = value.get_mut("hooks").and_then(Value::as_object_mut) else { + return; + }; + let events: Vec = hooks.keys().cloned().collect(); + for event in events { + let should_remove_event = hooks + .get_mut(&event) + .and_then(Value::as_array_mut) + .map(|groups| { + groups.retain(|group| { + !managed_codex_hook_group_for_relay(group, relay, keep_gateway_url) + }); + groups.is_empty() + }) + .unwrap_or(false); + if should_remove_event { + hooks.remove(&event); + } + } +} + +pub(super) fn managed_codex_hook_group_for_relay( + group: &Value, + relay: &Path, + keep_gateway_url: Option<&str>, +) -> bool { + let Some(hooks) = group.get("hooks").and_then(Value::as_array) else { + return false; + }; + let [hook] = hooks.as_slice() else { + return false; + }; + if hook.get("type").and_then(Value::as_str) != Some("command") + || hook.get("timeout").and_then(Value::as_u64) != Some(30) + { + return false; + } + let Some(command) = hook.get("command").and_then(Value::as_str) else { + return false; + }; + if keep_gateway_url.is_some_and(|gateway_url| command == codex_hook_command(gateway_url)) { + return false; + } + command == legacy_codex_hook_command(relay) + || command == legacy_named_codex_hook_command() + || command.starts_with("nemo-relay plugin-shim hook codex --gateway-url ") + || command.starts_with(&format!( + "{} plugin-shim hook codex --gateway-url ", + shell_quote(relay) + )) +} + +pub(super) fn hook_config_contains_generated_groups(existing: &Value, generated: &Value) -> bool { + let Some(generated_hooks) = generated.get("hooks").and_then(Value::as_object) else { + return false; + }; + generated_hooks.iter().all(|(event, groups)| { + groups.as_array().is_some_and(|groups| { + groups + .iter() + .all(|group| generated_event_contains_group(existing, event, group)) + }) + }) +} + +pub(super) fn generated_event_contains_group(config: &Value, event: &str, group: &Value) -> bool { + config + .get("hooks") + .and_then(Value::as_object) + .and_then(|hooks| hooks.get(event)) + .and_then(Value::as_array) + .is_some_and(|groups| groups.iter().any(|existing| existing == group)) +} + +pub(super) fn hook_config_has_hook_groups(config: &Value) -> bool { + config + .get("hooks") + .and_then(Value::as_object) + .is_some_and(|hooks| { + hooks + .values() + .any(|groups| groups.as_array().is_some_and(|groups| !groups.is_empty())) + }) +} + +pub(super) fn codex_config_doc_has_managed_install(doc: &DocumentMut, gateway_url: &str) -> bool { + doc.get("model_provider") + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some("nemo-relay-openai") + && codex_provider_item_is_managed(doc, gateway_url) + && feature_hooks_enabled(doc) == Some(true) +} + +pub(super) fn codex_provider_gateway_url(path: &Path) -> Option { + let raw = fs::read_to_string(path).ok()?; + let doc = raw.parse::().ok()?; + doc.get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get("nemo-relay-openai")) + .and_then(Item::as_table) + .and_then(|provider| provider.get("base_url")) + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) +} + +pub(super) fn restore_top_level_item(doc: &mut DocumentMut, backup: &DocumentMut, key: &str) { + if let Some(item) = backup.as_table().get(key).cloned() { + doc.as_table_mut().insert(key, item); + } else { + doc.as_table_mut().remove(key); + } +} + +pub(super) fn restore_top_level_item_if_str( + doc: &mut DocumentMut, + backup: &DocumentMut, + key: &str, + expected: &str, +) { + let current = doc + .get(key) + .and_then(Item::as_value) + .and_then(|value| value.as_str()); + if current == Some(expected) { + restore_top_level_item(doc, backup, key); + } +} + +pub(super) fn restore_table_item( + doc: &mut DocumentMut, + backup: &DocumentMut, + table: &str, + key: &str, +) { + if let Some(item) = backup + .get(table) + .and_then(Item::as_table) + .and_then(|table| table.get(key)) + .cloned() + { + ensure_table(doc, table).insert(key, item); + } else if let Some(table) = doc.get_mut(table).and_then(Item::as_table_mut) { + table.remove(key); + } +} + +pub(super) fn restore_table_item_if_bool( + doc: &mut DocumentMut, + backup: &DocumentMut, + table: &str, + key: &str, + expected: bool, +) { + let current = doc + .get(table) + .and_then(Item::as_table) + .and_then(|table| table.get(key)) + .and_then(Item::as_value) + .and_then(|value| value.as_bool()); + if current == Some(expected) { + restore_table_item(doc, backup, table, key); + } +} + +pub(super) fn codex_provider_item_is_managed(doc: &DocumentMut, gateway_url: &str) -> bool { + doc.get("model_providers") + .and_then(Item::as_table) + .and_then(|providers| providers.get("nemo-relay-openai")) + .and_then(Item::as_table) + .is_some_and(|provider| codex_provider_table_is_managed_for_gateway(provider, gateway_url)) +} + +pub(super) fn codex_provider_table_is_managed_for_gateway( + provider: &Table, + gateway_url: &str, +) -> bool { + provider + .get("name") + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some("NeMo Relay") + && provider + .get("base_url") + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some(gateway_url) + && provider + .get("wire_api") + .and_then(Item::as_value) + .and_then(|value| value.as_str()) + == Some("responses") + && provider + .get("requires_openai_auth") + .and_then(Item::as_value) + .and_then(|value| value.as_bool()) + == Some(true) + && provider + .get("supports_websockets") + .and_then(Item::as_value) + .and_then(|value| value.as_bool()) + == Some(false) +} + +pub(super) fn feature_hooks_enabled(doc: &DocumentMut) -> Option { + doc.get("features") + .and_then(Item::as_table) + .and_then(|table| table.get("hooks")) + .and_then(Item::as_value) + .and_then(|value| value.as_bool()) +} + +pub(super) fn remove_empty_table(doc: &mut DocumentMut, key: &str) { + let is_empty = doc + .get(key) + .and_then(Item::as_table) + .is_some_and(Table::is_empty); + if is_empty { + doc.as_table_mut().remove(key); + } +} + +pub(super) fn remove_table_item_if_bool( + doc: &mut DocumentMut, + table: &str, + key: &str, + expected: bool, +) { + let should_remove = doc + .get(table) + .and_then(Item::as_table) + .and_then(|table| table.get(key)) + .and_then(Item::as_value) + .and_then(|value| value.as_bool()) + == Some(expected); + if should_remove && let Some(table) = doc.get_mut(table).and_then(Item::as_table_mut) { + table.remove(key); + } +} + +pub(super) fn codex_provider_installed(gateway_url: &str) -> bool { + let Ok(path) = home_dir().map(|home| home.join(".codex").join("config.toml")) else { + return false; + }; + let Ok(raw) = fs::read_to_string(path) else { + return false; + }; + let Ok(doc) = raw.parse::() else { + return false; + }; + codex_config_doc_has_managed_install(&doc, gateway_url) +} + +pub(super) fn codex_hooks_installed(gateway_url: &str) -> Result { + let path = home_dir()?.join(".codex").join("hooks.json"); + let value = read_json_object(&path)?; + let generated = generated_hooks(CodingAgent::Codex, &codex_hook_command(gateway_url)); + Ok(hook_config_contains_generated_groups(&value, &generated)) +} + +pub(super) fn restore_codex_hooks_snapshot( + hooks: &FileSnapshot, + hooks_backup: &FileSnapshot, +) -> Result<(), String> { + restore_file_snapshot(hooks)?; + restore_file_snapshot(hooks_backup) +} + +pub(super) fn shell_quote(path: &Path) -> String { + shell_quote_for_platform(path, cfg!(windows)) +} + +pub(super) fn shell_quote_for_platform(path: &Path, windows: bool) -> String { + shell_quote_arg_for_platform(&path.display().to_string(), windows) +} + +pub(super) fn shell_quote_arg_for_platform(raw: &str, windows: bool) -> String { + if windows { + return cmd_quote_arg(raw); + } + posix_quote_arg(raw) +} + +pub(super) fn posix_quote_arg(raw: &str) -> String { + if raw.is_empty() { + "''".into() + } else if raw + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '.' | '_' | '-')) + { + raw.to_string() + } else { + format!("'{}'", raw.replace('\'', "'\\''")) + } +} + +pub(super) fn cmd_quote_arg(raw: &str) -> String { + if raw.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!(ch, '/' | '\\' | ':' | '.' | '_' | '-' | '=' | '@' | '+') + }) { + raw.to_string() + } else { + let mut escaped = String::new(); + for ch in raw.chars() { + match ch { + '%' => escaped.push_str("%%"), + '"' | '^' | '&' | '|' | '<' | '>' => { + escaped.push('^'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + format!("\"{escaped}\"") + } +} + +pub(super) fn codex_hook_command(gateway_url: &str) -> String { + format!( + "nemo-relay plugin-shim hook codex --gateway-url {}", + shell_quote_arg_for_platform(gateway_url, cfg!(windows)) + ) +} + +#[cfg(test)] +pub(super) fn codex_hook_command_for_platform( + relay: &Path, + gateway_url: &str, + windows: bool, +) -> String { + format!( + "{} plugin-shim hook codex --gateway-url {}", + shell_quote_for_platform(relay, windows), + shell_quote_arg_for_platform(gateway_url, windows) + ) +} + +pub(super) fn legacy_codex_hook_command(relay: &Path) -> String { + format!("{} plugin-shim hook codex", shell_quote(relay)) +} + +pub(super) fn legacy_named_codex_hook_command() -> &'static str { + "nemo-relay plugin-shim hook codex" +} diff --git a/crates/cli/src/plugin_shim/command.rs b/crates/cli/src/plugin_shim/command.rs new file mode 100644 index 000000000..c451e8899 --- /dev/null +++ b/crates/cli/src/plugin_shim/command.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Hidden `plugin-shim` CLI surface used by installed hooks and installer orchestration. + +use clap::{Args, Subcommand, ValueEnum}; + +use crate::config::CodingAgent; + +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginShimCommand { + #[command(subcommand)] + pub(crate) command: PluginShimSubcommand, +} + +#[derive(Debug, Clone, Subcommand)] +pub(crate) enum PluginShimSubcommand { + Serve(PluginShimServeCommand), + Hook(PluginShimHookCommand), + Install(PluginShimInstallCommand), + Uninstall(PluginShimUninstallCommand), + Provider(PluginShimProviderCommand), + Doctor(PluginShimDoctorCommand), +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginShimServeCommand { + #[arg(allow_hyphen_values = true, trailing_var_arg = true)] + pub(crate) args: Vec, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginShimHookCommand { + #[arg(value_enum)] + pub(crate) agent: CodingAgent, + #[arg(long)] + pub(crate) gateway_url: Option, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginShimInstallCommand { + #[arg(value_enum)] + pub(crate) agent: CodingAgent, + #[arg(long, default_value = "http://127.0.0.1:47632")] + pub(crate) gateway_url: String, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginShimUninstallCommand { + #[arg(value_enum)] + pub(crate) agent: CodingAgent, + #[arg(long, default_value = "http://127.0.0.1:47632")] + pub(crate) gateway_url: String, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginShimProviderCommand { + #[arg(value_enum)] + pub(crate) agent: CodingAgent, + #[arg(value_enum)] + pub(crate) action: PluginShimProviderAction, + #[arg(long, default_value = "http://127.0.0.1:47632")] + pub(crate) gateway_url: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub(crate) enum PluginShimProviderAction { + Enable, + Restore, + Status, +} + +#[derive(Debug, Clone, Args)] +pub(crate) struct PluginShimDoctorCommand { + #[arg(value_enum)] + pub(crate) agent: CodingAgent, + #[arg(long, default_value = "http://127.0.0.1:47632")] + pub(crate) gateway_url: String, +} diff --git a/crates/cli/src/plugin_shim/mod.rs b/crates/cli/src/plugin_shim/mod.rs new file mode 100644 index 000000000..fc969e391 --- /dev/null +++ b/crates/cli/src/plugin_shim/mod.rs @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Platform-neutral launcher and hook shim for packaged coding-agent plugins. + +mod claude; +mod codex; +mod command; +mod shared; + +pub(crate) use command::PluginShimCommand; + +use std::env; +use std::io::{Read, Write}; +use std::process::{Command, ExitCode}; + +use claude::{claude_provider, claude_settings_base_url}; +use codex::{codex_hooks_installed, codex_provider_installed, install_codex, uninstall_codex}; +use command::{ + PluginShimDoctorCommand, PluginShimInstallCommand, PluginShimProviderAction, + PluginShimProviderCommand, PluginShimSubcommand, PluginShimUninstallCommand, +}; +use shared::{ + ExecOrStatus, current_exe, fail_closed, gateway_url, healthz, plugin_idle_timeout, post_hook, + print_check, print_info, relay_binary, +}; + +use crate::config::CodingAgent; +use crate::error::CliError; + +pub(super) const DEFAULT_BIND: &str = "127.0.0.1:47632"; +pub(super) const DEFAULT_URL: &str = "http://127.0.0.1:47632"; +pub(super) const HEALTHZ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500); +pub(super) const STALE_LOCK_AFTER: std::time::Duration = std::time::Duration::from_secs(10); + +pub(crate) fn run(command: PluginShimCommand) -> Result { + match command.command { + PluginShimSubcommand::Serve(command) => serve(command.args), + PluginShimSubcommand::Hook(command) => hook(command.agent, command.gateway_url.as_deref()), + PluginShimSubcommand::Install(command) => install(command), + PluginShimSubcommand::Uninstall(command) => uninstall(command), + PluginShimSubcommand::Provider(command) => provider(command), + PluginShimSubcommand::Doctor(command) => doctor(command), + } + .map_err(CliError::Install) +} + +fn serve(args: Vec) -> Result { + let relay = relay_binary()?; + let bind = env::var("NEMO_RELAY_PLUGIN_BIND").unwrap_or_else(|_| DEFAULT_BIND.into()); + let mut command = Command::new(relay); + command.arg("--bind").arg(bind).args(args); + command.env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", plugin_idle_timeout()); + command + .exec_or_status() + .map_err(|error| format!("failed to start nemo-relay sidecar: {error}")) +} + +fn hook(agent: CodingAgent, explicit_gateway_url: Option<&str>) -> Result { + let url = gateway_url(agent, explicit_gateway_url); + let mut payload = Vec::new(); + std::io::stdin() + .read_to_end(&mut payload) + .map_err(|error| format!("failed to read hook payload: {error}"))?; + if payload.iter().all(u8::is_ascii_whitespace) { + payload = b"{}".to_vec(); + } + shared::ensure_sidecar(agent, &url); + match post_hook(agent, &url, &payload) { + Ok(body) => { + if !body.is_empty() { + std::io::stdout() + .write_all(&body) + .map_err(|error| format!("failed to write hook response: {error}"))?; + } + Ok(ExitCode::SUCCESS) + } + Err(error) if fail_closed() => Err(error), + Err(error) => { + eprintln!("{error}"); + Ok(ExitCode::SUCCESS) + } + } +} + +fn install(command: PluginShimInstallCommand) -> Result { + match command.agent { + CodingAgent::Codex => install_codex(&command.gateway_url), + other => Err(format!( + "plugin install supports codex, got {}", + other.as_arg() + )), + } +} + +fn uninstall(command: PluginShimUninstallCommand) -> Result { + match command.agent { + CodingAgent::Codex => uninstall_codex(&command.gateway_url), + other => Err(format!( + "plugin uninstall supports codex, got {}", + other.as_arg() + )), + } +} + +fn provider(command: PluginShimProviderCommand) -> Result { + match command.agent { + CodingAgent::ClaudeCode => claude_provider(command.action, &command.gateway_url), + other => Err(format!( + "plugin provider supports claude, got {}", + other.as_arg() + )), + } +} + +fn doctor(command: PluginShimDoctorCommand) -> Result { + Ok(if doctor_ok(command.agent, &command.gateway_url)? { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) +} + +pub(crate) fn install_codex_plugin(gateway_url: &str) -> Result<(), String> { + install_codex(gateway_url).map(|_| ()) +} + +pub(crate) fn uninstall_codex_plugin(gateway_url: &str) -> Result<(), String> { + uninstall_codex(gateway_url).map(|_| ()) +} + +pub(crate) fn enable_claude_provider(gateway_url: &str) -> Result<(), String> { + claude_provider(PluginShimProviderAction::Enable, gateway_url).map(|_| ()) +} + +pub(crate) fn restore_claude_provider(gateway_url: &str) -> Result<(), String> { + claude_provider(PluginShimProviderAction::Restore, gateway_url).map(|_| ()) +} + +pub(crate) fn doctor_plugin(agent: CodingAgent, gateway_url: &str) -> Result<(), String> { + if doctor_ok(agent, gateway_url)? { + Ok(()) + } else { + Err(format!("{} plugin doctor checks failed", agent.as_arg())) + } +} + +fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { + let mut ok = true; + ok &= print_check( + "plugin binary", + current_exe().ok().is_some_and(|path| path.exists()), + ); + if healthz(gateway_url) { + print_info("sidecar health", "running"); + } else { + print_info( + "sidecar health", + "not running; hooks start it lazily on first use", + ); + } + match agent { + CodingAgent::ClaudeCode => { + ok &= print_check( + "claude provider routing", + claude_settings_base_url().as_deref() == Some(gateway_url), + ); + } + CodingAgent::Codex => { + ok &= print_check( + "codex provider alias", + codex_provider_installed(gateway_url), + ); + ok &= print_check("codex hooks", codex_hooks_installed(gateway_url)?); + } + other => { + return Err(format!( + "plugin doctor supports claude and codex, got {}", + other.as_arg() + )); + } + } + Ok(ok) +} + +#[cfg(test)] +use crate::installer::generated_hooks; +#[cfg(test)] +use claude::*; +#[cfg(test)] +use codex::*; +#[cfg(test)] +use shared::*; + +#[cfg(test)] +#[path = "../../tests/coverage/plugin_shim_tests.rs"] +mod tests; diff --git a/crates/cli/src/plugin_shim/shared.rs b/crates/cli/src/plugin_shim/shared.rs new file mode 100644 index 000000000..fd236b7c9 --- /dev/null +++ b/crates/cli/src/plugin_shim/shared.rs @@ -0,0 +1,556 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared plugin-shim filesystem, sidecar, HTTP, and formatting helpers. + +use std::env; +use std::fs::{self, OpenOptions}; +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; +use std::thread; +use std::time::Duration; + +use serde_json::{Value, json}; +use toml_edit::{DocumentMut, Item, Table}; + +use crate::config::CodingAgent; + +use super::{DEFAULT_URL, HEALTHZ_TIMEOUT, STALE_LOCK_AFTER}; + +pub(super) fn ensure_sidecar(agent: CodingAgent, url: &str) { + if healthz(url) { + return; + } + let runtime = runtime_dir(); + let _ = fs::create_dir_all(&runtime); + let lock = runtime.join(format!("{}-sidecar.lock", agent.as_arg())); + let mut acquired = false; + for _ in 0..40 { + match fs::create_dir(&lock) { + Ok(()) => { + acquired = true; + break; + } + Err(_) if healthz(url) => return, + Err(_) if repair_stale_lock(&lock) => continue, + Err(_) => thread::sleep(Duration::from_millis(50)), + } + } + if !acquired { + eprintln!("nemo-relay sidecar lock timed out"); + return; + } + let result = start_sidecar(agent, url, &runtime); + let _ = fs::remove_dir(&lock); + if let Err(error) = result { + eprintln!("{error}"); + } +} + +pub(super) fn repair_stale_lock(lock: &Path) -> bool { + repair_stale_lock_after(lock, STALE_LOCK_AFTER) +} + +pub(super) fn repair_stale_lock_after(lock: &Path, stale_after: Duration) -> bool { + if !lock.exists() || !lock_is_old(lock, stale_after) { + return false; + } + match fs::remove_dir_all(lock) { + Ok(()) => return true, + Err(error) => eprintln!("failed to repair stale nemo-relay sidecar lock: {error}"), + } + false +} + +pub(super) fn lock_is_old(lock: &Path, stale_after: Duration) -> bool { + lock.metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|elapsed| elapsed >= stale_after) +} + +pub(super) fn ensure_table<'a>(doc: &'a mut DocumentMut, name: &str) -> &'a mut Table { + if !doc.as_table().contains_key(name) || !doc[name].is_table() { + doc[name] = Item::Table(Table::new()); + } + doc[name].as_table_mut().expect("table was just inserted") +} + +pub(super) fn read_json_object(path: &Path) -> Result { + if !path.exists() { + return Ok(json!({})); + } + let raw = fs::read_to_string(path) + .map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let value = serde_json::from_str::(&raw) + .map_err(|error| format!("invalid JSON in {}: {error}", path.display()))?; + if value.is_object() { + Ok(value) + } else { + Err(format!("{} must contain a JSON object", path.display())) + } +} + +pub(super) fn write_json(path: &Path, value: &Value) -> Result<(), String> { + let mut bytes = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; + bytes.push(b'\n'); + atomic_write(path, &bytes) +} + +pub(super) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("failed to create {}: {error}", parent.display()))?; + } + let tmp = path.with_extension(format!( + "{}tmp", + path.extension() + .and_then(|value| value.to_str()) + .map(|value| format!("{value}.")) + .unwrap_or_default() + )); + fs::write(&tmp, bytes) + .map_err(|error| format!("failed to write {}: {error}", tmp.display()))?; + replace_file(&tmp, path) +} + +#[cfg(not(windows))] +pub(super) fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + fs::rename(tmp, path).map_err(|error| format!("failed to replace {}: {error}", path.display())) +} + +#[cfg(windows)] +pub(super) fn replace_file(tmp: &Path, path: &Path) -> Result<(), String> { + if !path.exists() { + return fs::rename(tmp, path) + .map_err(|error| format!("failed to replace {}: {error}", path.display())); + } + + let backup = replace_backup_path(path); + match fs::remove_file(&backup) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "failed to remove stale replacement backup {}: {error}", + backup.display() + )); + } + } + + match fs::rename(path, &backup) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return fs::rename(tmp, path) + .map_err(|error| format!("failed to replace {}: {error}", path.display())); + } + Err(error) => { + return Err(format!( + "failed to prepare replacement for {}: {error}", + path.display() + )); + } + } + + match fs::rename(tmp, path) { + Ok(()) => { + let _ = fs::remove_file(&backup); + Ok(()) + } + Err(error) => match fs::rename(&backup, path) { + Ok(()) => Err(format!("failed to replace {}: {error}", path.display())), + Err(restore_error) => Err(format!( + "failed to replace {}: {error}; additionally failed to restore {}: {restore_error}", + path.display(), + backup.display() + )), + }, + } +} + +#[cfg(windows)] +pub(super) fn replace_backup_path(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("config"); + path.with_file_name(format!(".{file_name}.nemo-relay-replace.tmp")) +} + +pub(super) fn backup(path: &Path) -> Result<(), String> { + let backup = backup_path(path); + if backup.exists() { + return Ok(()); + } + if path.exists() { + fs::copy(path, &backup).map_err(|error| { + format!( + "failed to back up {} to {}: {error}", + path.display(), + backup.display() + ) + })?; + } + Ok(()) +} + +pub(super) fn remove_backup(path: &Path) -> Result<(), String> { + let backup = backup_path(path); + match fs::remove_file(&backup) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("failed to remove {}: {error}", backup.display())), + } +} + +pub(super) fn backup_path(path: &Path) -> PathBuf { + let mut extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_string(); + if extension.is_empty() { + extension = "nemo-relay.bak".into(); + } else { + extension.push_str(".nemo-relay.bak"); + } + path.with_extension(extension) +} + +pub(super) fn home_dir() -> Result { + env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or_else(|| "cannot determine home directory (set HOME or USERPROFILE)".into()) +} + +pub(super) fn print_check(label: &str, ok: bool) -> bool { + println!("{} {label}", if ok { "ok" } else { "missing" }); + ok +} + +pub(super) fn print_info(label: &str, message: &str) { + println!("info {label}: {message}"); +} + +pub(super) struct FileSnapshot { + path: PathBuf, + bytes: Option>, +} + +pub(super) fn snapshot_optional_file(path: &Path) -> Result { + match fs::read(path) { + Ok(bytes) => Ok(FileSnapshot { + path: path.to_path_buf(), + bytes: Some(bytes), + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(FileSnapshot { + path: path.to_path_buf(), + bytes: None, + }), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } +} + +pub(super) fn restore_file_snapshot(snapshot: &FileSnapshot) -> Result<(), String> { + if let Some(bytes) = snapshot.bytes.as_deref() { + return atomic_write(&snapshot.path, bytes); + } + match fs::remove_file(&snapshot.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "failed to remove {}: {error}", + snapshot.path.display() + )), + } +} + +pub(super) fn start_sidecar(agent: CodingAgent, url: &str, runtime: &Path) -> Result<(), String> { + if healthz(url) { + return Ok(()); + } + let (_, port) = parse_loopback_url(url)?; + let bind = format!("127.0.0.1:{port}"); + let relay = relay_binary()?; + let log_path = runtime.join(format!("{}-sidecar.log", agent.as_arg())); + let log = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .map_err(|error| format!("failed to open {}: {error}", log_path.display()))?; + let err_log = log + .try_clone() + .map_err(|error| format!("failed to clone sidecar log handle: {error}"))?; + let mut child = Command::new(relay) + .arg("--bind") + .arg(bind) + .env("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", plugin_idle_timeout()) + .stdin(Stdio::null()) + .stdout(Stdio::from(log)) + .stderr(Stdio::from(err_log)) + .spawn() + .map_err(|error| format!("failed to spawn nemo-relay sidecar: {error}"))?; + let pid_path = runtime.join(format!("{}-sidecar.pid", agent.as_arg())); + let _ = fs::write(&pid_path, child.id().to_string()); + for _ in 0..50 { + if healthz(url) { + return Ok(()); + } + match child.try_wait() { + Ok(Some(status)) => { + let _ = fs::remove_file(&pid_path); + return Err(format!( + "nemo-relay sidecar exited before becoming ready at {url}: {status}" + )); + } + Ok(None) => {} + Err(error) => { + let _ = fs::remove_file(&pid_path); + return Err(format!( + "failed to inspect nemo-relay sidecar process: {error}" + )); + } + } + thread::sleep(Duration::from_millis(50)); + } + terminate_unready_sidecar(child, &pid_path, url) +} + +pub(super) fn terminate_unready_sidecar( + mut child: std::process::Child, + pid_path: &Path, + url: &str, +) -> Result<(), String> { + match child.try_wait() { + Ok(Some(status)) => { + let _ = fs::remove_file(pid_path); + return Err(format!( + "nemo-relay sidecar exited before becoming ready at {url}: {status}" + )); + } + Ok(None) => {} + Err(error) => { + let _ = fs::remove_file(pid_path); + return Err(format!( + "failed to inspect nemo-relay sidecar process: {error}" + )); + } + } + if let Err(error) = child.kill() { + let _ = fs::remove_file(pid_path); + return Err(format!( + "nemo-relay sidecar did not become ready at {url}; failed to terminate startup process: {error}" + )); + } + let _ = child.wait(); + let _ = fs::remove_file(pid_path); + Err(format!( + "nemo-relay sidecar did not become ready at {url}; terminated startup process" + )) +} + +pub(super) fn post_hook(agent: CodingAgent, url: &str, payload: &[u8]) -> Result, String> { + let hook_path = match agent { + CodingAgent::ClaudeCode => "/hooks/claude-code", + CodingAgent::Codex => "/hooks/codex", + _ => { + return Err(format!( + "plugin shim hook forwarding supports claude and codex, got {}", + agent.as_arg() + )); + } + }; + let (host, port) = parse_loopback_url(url)?; + let addrs = (host.as_str(), port) + .to_socket_addrs() + .map_err(|error| format!("hook forward failed: {error}"))?; + let mut stream = None; + for addr in addrs { + match TcpStream::connect_timeout(&addr, Duration::from_secs(2)) { + Ok(candidate) => { + stream = Some(candidate); + break; + } + Err(_) => continue, + } + } + let Some(mut stream) = stream else { + return Err("hook forward failed: connection timed out".into()); + }; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|error| format!("failed to set read timeout: {error}"))?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|error| format!("failed to set write timeout: {error}"))?; + let request = format!( + "POST {hook_path} HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len() + ); + stream + .write_all(request.as_bytes()) + .and_then(|_| stream.write_all(payload)) + .map_err(|error| format!("hook forward failed: {error}"))?; + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .map_err(|error| format!("hook forward failed: {error}"))?; + parse_http_response(&response) +} + +pub(super) fn parse_http_response(response: &[u8]) -> Result, String> { + let Some(split) = response.windows(4).position(|window| window == b"\r\n\r\n") else { + return Err("hook forward failed: malformed HTTP response".into()); + }; + let headers = &response[..split]; + let body = response[split + 4..].to_vec(); + let status_line = headers + .split(|byte| *byte == b'\n') + .next() + .and_then(|line| std::str::from_utf8(line).ok()) + .unwrap_or_default(); + let status_code = status_line + .split_whitespace() + .nth(1) + .and_then(|value| value.parse::().ok()); + if status_code.is_some_and(|code| (200..=299).contains(&code)) { + Ok(body) + } else { + Err(format!( + "nemo-relay hook forward failed with {}", + status_line.trim() + )) + } +} + +pub(super) fn healthz(url: &str) -> bool { + let Ok((host, port)) = parse_loopback_url(url) else { + return false; + }; + let Ok(addrs) = (host.as_str(), port).to_socket_addrs() else { + return false; + }; + let mut stream = None; + for addr in addrs { + match TcpStream::connect_timeout(&addr, HEALTHZ_TIMEOUT) { + Ok(candidate) => { + stream = Some(candidate); + break; + } + Err(_) => continue, + } + } + let Some(mut stream) = stream else { + return false; + }; + if stream.set_read_timeout(Some(HEALTHZ_TIMEOUT)).is_err() + || stream.set_write_timeout(Some(HEALTHZ_TIMEOUT)).is_err() + { + return false; + } + let request = + format!("GET /healthz HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"); + if stream.write_all(request.as_bytes()).is_err() { + return false; + } + let mut response = [0_u8; 32]; + stream + .read(&mut response) + .ok() + .is_some_and(|count| response[..count].starts_with(b"HTTP/1.1 200")) +} + +pub(super) fn parse_loopback_url(url: &str) -> Result<(String, u16), String> { + let without_scheme = url + .strip_prefix("http://") + .ok_or_else(|| format!("plugin shim only supports http loopback URLs: {url}"))?; + let authority = without_scheme.split('/').next().unwrap_or(without_scheme); + let (host, port) = authority + .rsplit_once(':') + .ok_or_else(|| format!("missing port in gateway URL: {url}"))?; + if host != "127.0.0.1" && host != "localhost" { + return Err(format!( + "plugin shim only supports loopback gateway URLs: {url}" + )); + } + let port = port + .parse::() + .map_err(|error| format!("invalid gateway port in {url}: {error}"))?; + Ok((host.to_string(), port)) +} + +pub(super) fn gateway_url(agent: CodingAgent, explicit: Option<&str>) -> String { + if let Some(url) = explicit { + return url.to_string(); + } + if matches!(agent, CodingAgent::ClaudeCode) + && let Ok(url) = env::var("NEMO_RELAY_GATEWAY_URL") + { + return url; + } + env::var("NEMO_RELAY_PLUGIN_GATEWAY_URL").unwrap_or_else(|_| DEFAULT_URL.into()) +} + +pub(super) fn relay_binary() -> Result { + if let Ok(path) = env::var("NEMO_RELAY_PLUGIN_BINARY") { + let path = PathBuf::from(path); + if path.exists() { + return Ok(path); + } + return Err(format!( + "NEMO_RELAY_PLUGIN_BINARY does not exist: {}", + path.display() + )); + } + current_exe() +} + +pub(super) fn current_exe() -> Result { + env::current_exe().map_err(|error| format!("failed to resolve current executable: {error}")) +} + +pub(super) fn runtime_dir() -> PathBuf { + env::var_os("XDG_RUNTIME_DIR") + .or_else(|| env::var_os("TMPDIR")) + .or_else(|| env::var_os("TEMP")) + .map(PathBuf::from) + .unwrap_or_else(env::temp_dir) + .join("nemo-relay-plugin") +} + +pub(super) fn plugin_idle_timeout() -> String { + env::var("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS").unwrap_or_else(|_| "300".into()) +} + +pub(super) fn fail_closed() -> bool { + env::var("NEMO_RELAY_FAIL_CLOSED").ok().as_deref() == Some("1") +} + +pub(super) trait ExecOrStatus { + fn exec_or_status(&mut self) -> std::io::Result; +} + +#[cfg(unix)] +impl ExecOrStatus for Command { + fn exec_or_status(&mut self) -> std::io::Result { + use std::os::unix::process::CommandExt; + let error = self.exec(); + Err(error) + } +} + +#[cfg(not(unix))] +impl ExecOrStatus for Command { + fn exec_or_status(&mut self) -> std::io::Result { + let status = self.status()?; + Ok(status + .code() + .and_then(|code| u8::try_from(code).ok()) + .map(ExitCode::from) + .unwrap_or(ExitCode::FAILURE)) + } +} diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index fef92e1d1..b2239bb57 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use std::time::Duration; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use axum::extract::State; use axum::http::HeaderMap; @@ -29,6 +30,7 @@ pub(crate) struct AppState { pub(crate) config: GatewayConfig, pub(crate) http: Client, pub(crate) sessions: SessionManager, + pub(crate) last_activity: Arc>, } /// Binds the configured address and serves until the process is stopped. @@ -69,16 +71,26 @@ pub(crate) async fn serve_listener( let plugin_activation = PluginActivation::initialize(config.plugin_config.clone()).await?; let state = AppState::new(config); let sessions = state.sessions.clone(); + let last_activity = state.last_activity.clone(); let app = router_with_state(state); - let serve_result = match shutdown { - Some(receiver) => { + let idle_shutdown = (shutdown.is_none()) + .then(plugin_idle_timeout) + .flatten() + .map(|timeout| idle_shutdown_future(last_activity, sessions.clone(), timeout)); + let serve_result = match (shutdown, idle_shutdown) { + (Some(receiver), _) => { axum::serve(listener, app) .with_graceful_shutdown(async { let _ = receiver.await; }) .await } - None => axum::serve(listener, app).await, + (None, Some(idle)) => { + axum::serve(listener, app) + .with_graceful_shutdown(idle) + .await + } + (None, None) => axum::serve(listener, app).await, }; let close_result = sessions.close_all("gateway_shutdown").await; let clear_result = plugin_activation.clear(); @@ -119,6 +131,13 @@ impl AppState { config, http, sessions, + last_activity: Arc::new(Mutex::new(Instant::now())), + } + } + + pub(crate) fn touch(&self) { + if let Ok(mut last_activity) = self.last_activity.lock() { + *last_activity = Instant::now(); } } } @@ -141,10 +160,37 @@ fn router_with_state(state: AppState) -> Router { .with_state(state) } -async fn healthz() -> Json { +async fn healthz(State(state): State) -> Json { + state.touch(); Json(serde_json::json!({ "status": "ok" })) } +fn plugin_idle_timeout() -> Option { + let raw = std::env::var("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS").ok()?; + let seconds = raw.parse::().ok()?; + (seconds > 0).then(|| Duration::from_secs(seconds)) +} + +async fn idle_shutdown_future( + last_activity: Arc>, + sessions: SessionManager, + timeout: Duration, +) { + let tick = timeout + .min(Duration::from_secs(5)) + .max(Duration::from_secs(1)); + loop { + tokio::time::sleep(tick).await; + let elapsed = last_activity + .lock() + .map(|last_activity| last_activity.elapsed()) + .unwrap_or(timeout); + if elapsed >= timeout && !sessions.has_open_sessions().await { + break; + } + } +} + struct PluginActivation { active: bool, } @@ -191,6 +237,7 @@ async fn codex_hook( headers: HeaderMap, Json(payload): Json, ) -> Result, CliError> { + state.touch(); let outcome = codex::adapt(payload, &headers); state .sessions @@ -206,6 +253,7 @@ async fn claude_code_hook( headers: HeaderMap, Json(payload): Json, ) -> Result, CliError> { + state.touch(); let outcome = claude_code::adapt(payload, &headers); state .sessions @@ -221,6 +269,7 @@ async fn cursor_hook( headers: HeaderMap, Json(payload): Json, ) -> Result, CliError> { + state.touch(); let outcome = cursor::adapt(payload, &headers); state .sessions @@ -236,6 +285,7 @@ async fn hermes_hook( headers: HeaderMap, Json(payload): Json, ) -> Result, CliError> { + state.touch(); let outcome = hermes::adapt(payload, &headers); state .sessions diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index 832080d6e..9b43b67c4 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -441,6 +441,19 @@ impl SessionManager { } } + /// Returns true while any session still owns active observable work. + /// + /// Codex plugin sessions can emit `SessionStart` without a matching `SessionEnd`, so metadata-only + /// sessions must not keep the hook-supervised sidecar alive forever. Open scopes and in-flight + /// tool, LLM, or gateway work still block plugin idle shutdown. + pub(crate) async fn has_open_sessions(&self) -> bool { + self.inner + .lock() + .await + .values() + .any(Session::blocks_plugin_idle_shutdown) + } + /// Legacy manual-lifecycle close paired with [`Self::start_llm`]. Production gateway traffic /// no longer needs this helper because managed execution emits the end event automatically. /// @@ -881,6 +894,17 @@ impl Session { && self.tools.is_empty() } + fn blocks_plugin_idle_shutdown(&self) -> bool { + self.agent_scope.is_some() + || self.turn_scope.is_some() + || !self.subagents.is_empty() + || !self.subagent_stacks.is_empty() + || !self.subagent_stack.is_empty() + || !self.llms.is_empty() + || !self.tools.is_empty() + || self.active_gateway_calls > 0 + } + fn touch_activity(&mut self) { self.last_activity = Instant::now(); } diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 3a67b9d44..45dbe5619 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -110,6 +110,75 @@ fn cli_help_lists_easy_path_agent_shortcuts() { } } +#[test] +fn cli_help_lists_plugin_install_commands() { + let output = Command::new(gateway_bin()).arg("--help").output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + + for command in ["install", "uninstall"] { + assert!( + stdout.contains(&format!(" {command}")), + "expected `--help` to list `{command}` subcommand, got:\n{stdout}" + ); + } +} + +#[test] +fn cli_install_dry_run_plans_local_codex_marketplace() { + let temp = tempfile::tempdir().unwrap(); + let output = Command::new(gateway_bin()) + .args([ + "install", + "codex", + "--dry-run", + "--skip-doctor", + "--install-dir", + ]) + .arg(temp.path()) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("codex-marketplace"), + "stdout was:\n{stdout}" + ); + assert!( + stdout.contains("codex plugin marketplace add"), + "stdout was:\n{stdout}" + ); + assert!( + stdout.contains("configure Codex provider and hook-supervised lazy startup"), + "stdout was:\n{stdout}" + ); +} + +#[test] +fn cli_doctor_plugin_help_accepts_plugin_flag() { + let output = Command::new(gateway_bin()) + .args(["doctor", "--help"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("--plugin"), "stdout was:\n{stdout}"); +} + +#[test] +fn cli_doctor_plugin_rejects_json_until_plugin_json_is_supported() { + let output = Command::new(gateway_bin()) + .args(["doctor", "--plugin", "codex", "--json"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--json"), "stderr was:\n{stderr}"); + assert!(stderr.contains("--plugin"), "stderr was:\n{stderr}"); +} + #[test] fn cli_easy_path_invokes_setup_when_no_config_found() { // When no config exists anywhere, the easy path fires setup. In a non-TTY test diff --git a/crates/cli/tests/coverage/gateway_tests.rs b/crates/cli/tests/coverage/gateway_tests.rs index 748b389dd..221de911e 100644 --- a/crates/cli/tests/coverage/gateway_tests.rs +++ b/crates/cli/tests/coverage/gateway_tests.rs @@ -726,6 +726,7 @@ async fn passthrough_rejects_unsupported_provider_path_directly() { config: config.clone(), http: test_http_client(), sessions: SessionManager::new(config), + last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), }; let request = Request::builder() .method(Method::POST) @@ -752,6 +753,7 @@ async fn models_rejects_non_get_requests_directly() { config: config.clone(), http: test_http_client(), sessions: SessionManager::new(config), + last_activity: std::sync::Arc::new(std::sync::Mutex::new(std::time::Instant::now())), }; let request = Request::builder() .method(Method::POST) diff --git a/crates/cli/tests/coverage/installer_tests.rs b/crates/cli/tests/coverage/installer_tests.rs index 8ca7fdae7..d181e8ad5 100644 --- a/crates/cli/tests/coverage/installer_tests.rs +++ b/crates/cli/tests/coverage/installer_tests.rs @@ -159,6 +159,8 @@ fn packaged_hook_configs_are_valid_json() { let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../integrations/coding-agents"); for path in [ + root.join("../../.agents/plugins/marketplace.json"), + root.join("../../.claude-plugin/marketplace.json"), root.join("claude-code/hooks/hooks.json"), root.join("codex/hooks/hooks.json"), root.join("cursor/.cursor/hooks.json"), @@ -170,3 +172,108 @@ fn packaged_hook_configs_are_valid_json() { .unwrap_or_else(|error| panic!("{} is invalid JSON: {error}", path.display())); } } + +#[test] +fn packaged_plugin_hooks_use_expected_shim_commands() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../integrations/coding-agents"); + let claude = serde_json::from_str::( + &std::fs::read_to_string(root.join("claude-code/hooks/hooks.json")).unwrap(), + ) + .unwrap(); + let codex = serde_json::from_str::( + &std::fs::read_to_string(root.join("codex/hooks/hooks.json")).unwrap(), + ) + .unwrap(); + + assert_eq!( + claude["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!("nemo-relay plugin-shim hook claude") + ); + assert_eq!( + codex["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!("nemo-relay plugin-shim hook codex") + ); + assert!( + claude["hooks"] + .as_object() + .unwrap() + .values() + .flat_map(|groups| groups.as_array().unwrap()) + .flat_map(|group| group["hooks"].as_array().unwrap()) + .all(|hook| hook["command"] + .as_str() + .is_some_and(|command| command.starts_with("nemo-relay "))) + ); + assert!( + codex["hooks"] + .as_object() + .unwrap() + .values() + .flat_map(|groups| groups.as_array().unwrap()) + .flat_map(|group| group["hooks"].as_array().unwrap()) + .all(|hook| hook["command"] + .as_str() + .is_some_and(|command| command.starts_with("nemo-relay "))) + ); +} + +#[test] +fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../integrations/coding-agents"); + let claude_path = root.join("claude-code/.claude-plugin/plugin.json"); + let claude = + serde_json::from_str::(&std::fs::read_to_string(&claude_path).unwrap()).unwrap(); + assert_eq!(claude["name"], json!("nemo-relay-plugin")); + assert_eq!(claude["version"], json!(env!("CARGO_PKG_VERSION"))); + assert!(claude.get("hooks").is_none()); + + let codex_path = root.join("codex/.codex-plugin/plugin.json"); + let codex = + serde_json::from_str::(&std::fs::read_to_string(&codex_path).unwrap()).unwrap(); + assert_eq!(codex["name"], json!("nemo-relay-plugin")); + assert_eq!(codex["version"], json!(env!("CARGO_PKG_VERSION"))); + + let codex_marketplace_path = root.join("../../.agents/plugins/marketplace.json"); + let codex_marketplace = + serde_json::from_str::(&std::fs::read_to_string(&codex_marketplace_path).unwrap()) + .unwrap(); + assert_eq!(codex_marketplace["name"], json!("nemo-relay")); + assert_eq!( + codex_marketplace["plugins"][0]["name"], + json!("nemo-relay-plugin") + ); + assert_eq!( + codex_marketplace["plugins"][0]["source"]["path"], + json!("./integrations/coding-agents/codex") + ); + + let claude_marketplace_path = root.join("../../.claude-plugin/marketplace.json"); + let claude_marketplace = + serde_json::from_str::(&std::fs::read_to_string(&claude_marketplace_path).unwrap()) + .unwrap(); + assert_eq!(claude_marketplace["name"], json!("nemo-relay")); + assert_eq!( + claude_marketplace["plugins"][0]["name"], + json!("nemo-relay-plugin") + ); + assert_eq!( + claude_marketplace["plugins"][0]["source"], + json!("./integrations/coding-agents/claude-code") + ); +} + +#[test] +fn packaged_plugin_helpers_are_present() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../integrations/coding-agents"); + for path in [ + root.join("claude-code/hooks/hooks.json"), + root.join("codex/hooks/hooks.json"), + ] { + let metadata = std::fs::metadata(&path) + .unwrap_or_else(|error| panic!("{} missing: {error}", path.display())); + assert!(metadata.is_file(), "{} is not a file", path.display()); + } +} diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs new file mode 100644 index 000000000..c8dddba55 --- /dev/null +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -0,0 +1,837 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use serde_json::json; +use tempfile::tempdir; + +use super::*; + +#[derive(Default)] +struct MockRunner { + executables: HashMap, + commands: RefCell>, + quiet_commands: RefCell>, + failing_suffix: Option, + failing_suffixes: Vec, + failing_quiet_suffix: Option, +} + +impl MockRunner { + fn with_executable(mut self, name: &str, path: &str) -> Self { + self.executables.insert(name.into(), PathBuf::from(path)); + self + } + + fn commands(&self) -> Vec { + self.commands.borrow().clone() + } + + fn quiet_commands(&self) -> Vec { + self.quiet_commands.borrow().clone() + } +} + +impl CommandRunner for MockRunner { + fn resolve_executable(&self, command: &str) -> Result, String> { + Ok(self.executables.get(command).cloned()) + } + + fn run(&self, program: &Path, args: &[String]) -> Result { + let rendered = format!( + "{} {}", + program.display(), + args.iter() + .map(String::as_str) + .collect::>() + .join(" ") + ); + self.commands.borrow_mut().push(rendered.clone()); + Ok( + if command_matches_suffix(&rendered, self.failing_suffix.as_deref()) + || self + .failing_suffixes + .iter() + .any(|suffix| rendered.ends_with(suffix)) + { + 1 + } else { + 0 + }, + ) + } + + fn run_quiet(&self, program: &Path, args: &[String]) -> Result { + let rendered = format!( + "{} {}", + program.display(), + args.iter() + .map(String::as_str) + .collect::>() + .join(" ") + ); + self.quiet_commands.borrow_mut().push(rendered.clone()); + Ok( + if command_matches_suffix(&rendered, self.failing_quiet_suffix.as_deref()) { + 1 + } else { + 0 + }, + ) + } +} + +fn command_matches_suffix(command: &str, suffix: Option<&str>) -> bool { + suffix.is_some_and(|suffix| command.ends_with(suffix)) +} + +#[derive(Default)] +struct MockSetupRunner { + calls: RefCell>, + failing_call: Option, +} + +impl MockSetupRunner { + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } +} + +impl PluginSetupRunner for MockSetupRunner { + fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + self.record(format!("setup {} {gateway_url}", host_arg(host))) + } + + fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + self.record(format!("uninstall {} {gateway_url}", host_arg(host))) + } + + fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { + self.record(format!("doctor {} {gateway_url}", host_arg(host))) + } +} + +impl MockSetupRunner { + fn record(&self, call: String) -> Result<(), String> { + self.calls.borrow_mut().push(call.clone()); + if self.failing_call.as_deref() == Some(call.as_str()) { + Err(format!("{call} failed")) + } else { + Ok(()) + } + } +} + +fn options(dir: &Path) -> PluginInstallOptions { + PluginInstallOptions { + install_dir: dir.to_path_buf(), + force: false, + dry_run: false, + skip_doctor: true, + } +} + +fn relay_validation_command() -> String { + "/bin/nemo-relay plugin-shim hook --help".into() +} + +fn write_installed_state(host: PluginHost, dir: &Path) { + let layout = PluginLayout::new(host, dir); + write_state(&layout, &options(dir)).unwrap(); + mark_plugin_setup_installed(host, &layout, &options(dir)).unwrap(); +} + +#[test] +fn default_install_dir_follows_platform_conventions() { + assert_eq!( + default_install_dir_for("macos", Some("/Users/example".into()), None, None, None), + PathBuf::from("/Users/example/Library/Application Support/nemo-relay/plugins") + ); + assert_eq!( + default_install_dir_for("linux", Some("/home/example".into()), None, None, None), + PathBuf::from("/home/example/.local/share/nemo-relay/plugins") + ); + assert_eq!( + default_install_dir_for( + "linux", + Some("/home/example".into()), + None, + None, + Some("/data".into()) + ), + PathBuf::from("/data/nemo-relay/plugins") + ); + assert_eq!( + default_install_dir_for( + "windows", + None, + Some(r"C:\Users\example".into()), + Some(r"C:\Users\example\AppData\Local".into()), + None + ), + PathBuf::from(r"C:\Users\example\AppData\Local") + .join("nemo-relay") + .join("plugins") + ); +} + +#[test] +fn plugin_manifests_and_hooks_use_path_based_relay_command() { + assert_eq!( + marketplace_manifest(PluginHost::Codex)["name"], + json!(MARKETPLACE_NAME) + ); + assert_eq!( + marketplace_manifest(PluginHost::ClaudeCode)["plugins"][0]["source"], + json!("./plugins/nemo-relay-plugin") + ); + assert_eq!( + plugin_manifest(PluginHost::Codex)["name"], + json!(PLUGIN_NAME) + ); + assert_eq!( + plugin_hooks(PluginHost::Codex)["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!("nemo-relay plugin-shim hook codex") + ); + assert_eq!( + plugin_hooks(PluginHost::ClaudeCode)["hooks"]["SessionStart"][0]["hooks"][0]["command"], + json!("nemo-relay plugin-shim hook claude") + ); +} + +#[test] +fn select_all_uses_operation_specific_inputs() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default().with_executable("codex", "/bin/codex"); + let selected = select_hosts( + PluginHost::All, + HostSelectionMode::Install, + &options(dir.path()), + &runner, + ) + .unwrap(); + assert_eq!(selected, vec![PluginHost::Codex]); + + std::fs::write( + state_path(PluginHost::ClaudeCode, dir.path()), + r#"{"marketplaceRoot":"/tmp/m","pluginRoot":"/tmp/p"}"#, + ) + .unwrap(); + let selected = select_hosts( + PluginHost::All, + HostSelectionMode::Install, + &options(dir.path()), + &runner, + ) + .unwrap(); + assert_eq!(selected, vec![PluginHost::Codex]); + + let selected = select_hosts( + PluginHost::All, + HostSelectionMode::InstalledState, + &options(dir.path()), + &runner, + ) + .unwrap(); + assert_eq!(selected, vec![PluginHost::ClaudeCode]); +} + +#[test] +fn install_codex_generates_marketplace_and_runs_setup() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + + install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + assert!( + !layout.hooks_path.exists(), + "generated Codex marketplace must not also install plugin hook templates" + ); + assert_eq!( + runner.commands(), + vec![ + format!( + "/bin/codex plugin marketplace add {}", + layout.marketplace_root.display() + ), + "/bin/codex plugin add nemo-relay-plugin@nemo-relay-local".into(), + ] + ); + assert_eq!(runner.quiet_commands(), vec![relay_validation_command()]); + assert_eq!( + setup_runner.calls(), + vec![format!("setup codex {DEFAULT_GATEWAY_URL}")] + ); +} + +#[test] +fn install_prunes_stale_managed_plugin_root() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + let stale = layout.plugin_root.join("bin").join("nemo-relay"); + std::fs::create_dir_all(stale.parent().unwrap()).unwrap(); + std::fs::write(&stale, "stale").unwrap(); + + install_host( + PluginHost::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(!stale.exists()); + assert!(layout.plugin_manifest.exists()); +} + +#[test] +fn force_install_unregisters_existing_host_before_reinstall() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + + install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + + let commands = runner.commands(); + let remove_index = commands + .iter() + .position(|command| { + command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" + }) + .unwrap(); + let add_index = commands + .iter() + .position(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) + .unwrap(); + assert!(remove_index < add_index); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); +} + +#[test] +fn force_install_without_state_unregisters_host_before_reinstall() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + force: true, + ..options(dir.path()) + }; + + install_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + + let commands = runner.commands(); + let remove_index = commands + .iter() + .position(|command| { + command == "/bin/codex plugin remove nemo-relay-plugin@nemo-relay-local" + }) + .unwrap(); + let add_index = commands + .iter() + .position(|command| command.ends_with("plugin add nemo-relay-plugin@nemo-relay-local")) + .unwrap(); + assert!(remove_index < add_index); +} + +#[test] +fn install_claude_enables_provider_routing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + let setup_runner = MockSetupRunner::default(); + + install_host( + PluginHost::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + let layout = PluginLayout::new(PluginHost::ClaudeCode, dir.path()); + assert_eq!( + runner.commands(), + vec![ + format!( + "/bin/claude plugin marketplace add {}", + layout.marketplace_root.display() + ), + "/bin/claude plugin install nemo-relay-plugin@nemo-relay-local --scope user".into(), + ] + ); + assert_eq!(runner.quiet_commands(), vec![relay_validation_command()]); + assert_eq!( + setup_runner.calls(), + vec![format!("setup claude-code {DEFAULT_GATEWAY_URL}")] + ); +} + +#[test] +fn missing_relay_path_fails_before_generating_plugin() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default().with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("nemo-relay")); + assert!( + !PluginLayout::new(PluginHost::Codex, dir.path()) + .marketplace_root + .exists() + ); +} + +#[test] +fn unsupported_relay_path_fails_before_generating_plugin() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_quiet_suffix = Some("plugin-shim hook --help".into()); + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin-shim hook")); + assert!( + !PluginLayout::new(PluginHost::Codex, dir.path()) + .marketplace_root + .exists() + ); +} + +#[test] +fn setup_failure_rolls_back_generated_files_and_registration() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("setup claude-code {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + + let error = install_host( + PluginHost::ClaudeCode, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("setup claude-code")); + assert!( + !PluginLayout::new(PluginHost::ClaudeCode, dir.path()) + .marketplace_root + .exists() + ); + assert!( + runner + .commands() + .iter() + .any(|command| command == "/bin/claude plugin uninstall nemo-relay-plugin") + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall claude-code {DEFAULT_GATEWAY_URL}")) + ); +} + +#[test] +fn doctor_failure_fails_install_and_rolls_back() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("doctor claude-code {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + let options = PluginInstallOptions { + skip_doctor: false, + ..options(dir.path()) + }; + + let error = install_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("doctor claude-code")); + assert!( + !PluginLayout::new(PluginHost::ClaudeCode, dir.path()) + .marketplace_root + .exists() + ); +} + +#[test] +fn registration_failure_does_not_restore_plugin_setup_that_never_ran() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude"); + runner.failing_suffix = Some("claude-code-marketplace".into()); + let setup_runner = MockSetupRunner::default(); + let install_dir = dir.path().join("failure"); + + let error = install_host( + PluginHost::ClaudeCode, + &options(&install_dir), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin marketplace add")); + assert!( + setup_runner.calls().is_empty(), + "setup rollback should not run before setup was attempted" + ); + assert!( + !PluginLayout::new(PluginHost::ClaudeCode, &install_dir) + .marketplace_root + .exists() + ); +} + +#[test] +fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some("plugin add nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin add nemo-relay-plugin")); + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!( + runner + .commands() + .iter() + .any(|command| command.ends_with("plugin marketplace remove nemo-relay-local")) + ); + assert!( + runner + .commands() + .iter() + .all(|command| !command.contains("plugin remove nemo-relay-plugin")) + ); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffixes = vec![ + "plugin add nemo-relay-plugin@nemo-relay-local".into(), + "plugin marketplace remove nemo-relay-local".into(), + ]; + let setup_runner = MockSetupRunner::default(); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("additionally failed to roll back install")); + let state = read_state(PluginHost::Codex, dir.path()).unwrap(); + assert!(state.host_plugin_removed); + assert!(!state.host_marketplace_removed); + assert!(!state.plugin_setup_installed); + + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!( + setup_runner.calls().is_empty(), + "retry cleanup must not restore provider/hooks setup that install never reached" + ); +} + +#[test] +fn retry_after_setup_attempted_rollback_restores_setup() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some("plugin marketplace remove nemo-relay-local".into()); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("setup codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("additionally failed to roll back install")); + let state = read_state(PluginHost::Codex, dir.path()).unwrap(); + assert!(state.host_plugin_removed); + assert!(!state.host_marketplace_removed); + assert!(state.plugin_setup_installed); + + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); +} + +#[test] +fn uninstall_uses_installed_state_and_removes_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + assert!(layout.marketplace_root.exists()); + + uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); +} + +#[test] +fn uninstall_host_failure_does_not_restore_plugin_setup() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + + let error = uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin remove")); + assert!( + setup_runner.calls().is_empty(), + "provider/hook setup should not be restored until host unregister succeeds" + ); +} + +#[test] +fn uninstall_records_host_removal_phases_before_plugin_restore() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner { + failing_call: Some(format!("uninstall codex {DEFAULT_GATEWAY_URL}")), + ..MockSetupRunner::default() + }; + write_installed_state(PluginHost::Codex, dir.path()); + + let error = uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("uninstall codex")); + let state = read_state(PluginHost::Codex, dir.path()).unwrap(); + assert!(state.host_plugin_removed); + assert!(state.host_marketplace_removed); +} + +#[test] +fn uninstall_retry_skips_host_removal_after_prior_success() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default().with_executable("nemo-relay", "/bin/nemo-relay"); + runner.failing_suffix = Some("plugin remove nemo-relay-plugin@nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_state_for_host( + PluginHost::Codex, + &PluginState { + marketplace_root: layout.marketplace_root.clone(), + plugin_root: layout.plugin_root.clone(), + host_plugin_removed: true, + host_marketplace_removed: true, + plugin_setup_installed: true, + }, + dir.path(), + &options(dir.path()), + ) + .unwrap(); + + uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!( + runner + .commands() + .iter() + .all(|command| !command.contains("plugin remove nemo-relay-plugin")) + ); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); + assert!(!layout.state_path.exists()); +} + +#[test] +fn uninstall_retry_skips_plugin_removal_after_marketplace_failure() { + let dir = tempdir().unwrap(); + let mut runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + runner.failing_suffix = Some("plugin marketplace remove nemo-relay-local".into()); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_state(&layout, &options(dir.path())).unwrap(); + + let error = uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("plugin marketplace remove")); + let state = read_state(PluginHost::Codex, dir.path()).unwrap(); + assert!(state.host_plugin_removed); + assert!(!state.host_marketplace_removed); + + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!( + runner + .commands() + .iter() + .all(|command| !command.contains("plugin remove nemo-relay-plugin")) + ); + assert!(!layout.state_path.exists()); +} diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_shim_tests.rs new file mode 100644 index 000000000..e0ce69036 --- /dev/null +++ b/crates/cli/tests/coverage/plugin_shim_tests.rs @@ -0,0 +1,1309 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::io::Write; +use std::net::TcpListener; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tempfile::tempdir; + +use super::*; + +fn home_env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +struct HomeScope<'a> { + _guard: std::sync::MutexGuard<'a, ()>, + prev_home: Option, + prev_userprofile: Option, +} + +impl<'a> HomeScope<'a> { + fn enter(path: &std::path::Path) -> Self { + let guard = home_env_lock() + .lock() + .unwrap_or_else(|error| error.into_inner()); + let prev_home = std::env::var_os("HOME"); + let prev_userprofile = std::env::var_os("USERPROFILE"); + // SAFETY: This test holds a process-wide mutex for the lifetime of the env override. + unsafe { + std::env::set_var("HOME", path); + std::env::remove_var("USERPROFILE"); + } + Self { + _guard: guard, + prev_home, + prev_userprofile, + } + } +} + +impl<'a> Drop for HomeScope<'a> { + fn drop(&mut self) { + // SAFETY: This restores the process environment while the mutex is still held. + unsafe { + match self.prev_home.take() { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match self.prev_userprofile.take() { + Some(value) => std::env::set_var("USERPROFILE", value), + None => std::env::remove_var("USERPROFILE"), + } + } + } +} + +struct EnvVarGuard { + key: &'static str, + previous: Option, +} + +impl EnvVarGuard { + fn set_path(key: &'static str, value: &std::path::Path) -> Self { + let previous = std::env::var_os(key); + // SAFETY: Callers hold the process-wide environment mutex through HomeScope. + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + // SAFETY: This restores the process environment while HomeScope still holds the mutex. + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} + +#[test] +fn backup_preserves_first_snapshot() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + + backup(&path).unwrap(); + fs::write(&path, "model_provider = \"nemo-relay-openai\"\n").unwrap(); + backup(&path).unwrap(); + + assert_eq!( + fs::read_to_string(backup_path(&path)).unwrap(), + "model_provider = \"openai\"\n" + ); +} + +#[test] +fn atomic_write_replaces_existing_destination() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "old\n").unwrap(); + + atomic_write(&path, b"new\n").unwrap(); + + assert_eq!(fs::read_to_string(&path).unwrap(), "new\n"); +} + +#[test] +fn repeated_codex_install_does_not_overwrite_original_backup() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + + assert_eq!( + fs::read_to_string(backup_path(&path)).unwrap(), + "model_provider = \"openai\"\n" + ); +} + +#[test] +fn codex_install_backs_up_when_relay_provider_table_is_not_active() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "openai" + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + + assert!( + fs::read_to_string(backup_path(&path)) + .unwrap() + .contains("model_provider = \"openai\"") + ); +} + +#[test] +fn codex_install_backs_up_when_hooks_flag_changes_even_with_managed_provider() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = false + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + + let backup = fs::read_to_string(backup_path(&path)).unwrap(); + assert!(backup.contains("hooks = false")); + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + assert!(updated.contains("hooks = false")); +} + +#[test] +fn codex_provider_installed_requires_active_managed_provider() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let path = codex_dir.join("config.toml"); + fs::write( + &path, + r#" +model_provider = "openai" + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + assert!(!codex_provider_installed(DEFAULT_URL)); + install_codex_config(&path, DEFAULT_URL).unwrap(); + assert!(codex_provider_installed(DEFAULT_URL)); + assert!(!codex_provider_installed("http://127.0.0.1:47633")); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = false + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + assert!(!codex_provider_installed(DEFAULT_URL)); +} + +#[test] +fn codex_hooks_installed_requires_generated_plugin_local_groups() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let path = codex_dir.join("hooks.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "nemo-relay plugin-shim hook codex --gateway-url http://127.0.0.1:47632", + "timeout": 30 + } + ] + } + ] + } + })) + .unwrap(), + ) + .unwrap(); + + assert!(!codex_hooks_installed(DEFAULT_URL).unwrap()); + install_codex_hooks(&path, DEFAULT_URL).unwrap(); + assert!(codex_hooks_installed(DEFAULT_URL).unwrap()); + assert!(!codex_hooks_installed("http://127.0.0.1:47633").unwrap()); +} + +#[test] +fn codex_doctor_allows_stopped_lazy_sidecar_when_static_setup_is_valid() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + install_codex_config(&codex_dir.join("config.toml"), DEFAULT_URL).unwrap(); + install_codex_hooks(&codex_dir.join("hooks.json"), DEFAULT_URL).unwrap(); + + let status = doctor(PluginShimDoctorCommand { + agent: CodingAgent::Codex, + gateway_url: DEFAULT_URL.into(), + }) + .unwrap(); + + assert_eq!(status, std::process::ExitCode::SUCCESS); +} + +#[test] +fn codex_doctor_requires_enabled_hooks_feature() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = false + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + install_codex_hooks(&codex_dir.join("hooks.json"), DEFAULT_URL).unwrap(); + + let status = doctor(PluginShimDoctorCommand { + agent: CodingAgent::Codex, + gateway_url: DEFAULT_URL.into(), + }) + .unwrap(); + + assert_eq!(status, std::process::ExitCode::FAILURE); +} + +#[test] +fn codex_setup_persists_path_based_launcher_when_sidecar_binary_override_is_set() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + let sidecar_override = dir.path().join("sidecar").join("nemo-relay"); + fs::create_dir_all(sidecar_override.parent().unwrap()).unwrap(); + fs::write(&sidecar_override, b"sidecar override").unwrap(); + let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &sidecar_override); + + install_codex(DEFAULT_URL).unwrap(); + + let hooks_path = codex_dir.join("hooks.json"); + let hooks: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); + let launcher_command = codex_hook_command(DEFAULT_URL); + let sidecar_command = codex_hook_command_for_platform(&sidecar_override, DEFAULT_URL, false); + assert!(event_contains_command( + &hooks, + "SessionStart", + &launcher_command + )); + assert!(!event_contains_command( + &hooks, + "SessionStart", + &sidecar_command + )); + assert!(codex_hooks_installed(DEFAULT_URL).unwrap()); + assert_eq!( + doctor(PluginShimDoctorCommand { + agent: CodingAgent::Codex, + gateway_url: DEFAULT_URL.into(), + }) + .unwrap(), + std::process::ExitCode::SUCCESS + ); + + uninstall_codex(DEFAULT_URL).unwrap(); + let hooks: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); + assert!(!event_contains_command( + &hooks, + "SessionStart", + &launcher_command + )); +} + +#[test] +fn relay_binary_prefers_sidecar_binary_override() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let sidecar_override = dir.path().join("sidecar").join("nemo-relay"); + fs::create_dir_all(sidecar_override.parent().unwrap()).unwrap(); + fs::write(&sidecar_override, b"sidecar override").unwrap(); + let _binary_override = EnvVarGuard::set_path("NEMO_RELAY_PLUGIN_BINARY", &sidecar_override); + + assert_eq!(relay_binary().unwrap(), sidecar_override); +} + +#[test] +fn codex_uninstall_without_backup_removes_managed_hooks_flag() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(!updated.contains("model_provider")); + assert!(!updated.contains("nemo-relay-openai")); + assert!(!updated.contains("hooks = true")); +} + +#[test] +fn codex_uninstall_with_backup_preserves_user_changed_model_provider() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + fs::write( + &path, + r#" +model_provider = "local" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("model_provider = \"local\"")); + assert!(!updated.contains("nemo-relay-openai")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_uninstall_with_backup_preserves_user_changed_provider_table() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "Custom Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("model_provider = \"nemo-relay-openai\"")); + assert!(updated.contains("name = \"Custom Relay\"")); + assert!(updated.contains("nemo-relay-openai")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_uninstall_preserves_user_changed_provider_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:49999" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("model_provider = \"nemo-relay-openai\"")); + assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_uninstall_without_backup_preserves_user_changed_provider_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "nemo-relay-openai" + +[features] +hooks = true + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:49999" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("model_provider = \"nemo-relay-openai\"")); + assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); +} + +#[test] +fn codex_uninstall_preserves_hooks_feature_when_user_hooks_remain() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + r#" +model_provider = "openai" + +[features] +hooks = false +"#, + ) + .unwrap(); + + install_codex(DEFAULT_URL).unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + let mut hooks: Value = serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); + hooks["hooks"]["SessionStart"] + .as_array_mut() + .unwrap() + .push(json!({ + "hooks": [ + { + "type": "command", + "command": "custom-hook", + "timeout": 30 + } + ] + })); + fs::write(&hooks_path, serde_json::to_vec_pretty(&hooks).unwrap()).unwrap(); + + uninstall_codex(DEFAULT_URL).unwrap(); + + let updated_config = fs::read_to_string(codex_dir.join("config.toml")).unwrap(); + assert!(updated_config.contains("hooks = true")); + let updated_hooks: Value = + serde_json::from_str(&fs::read_to_string(&hooks_path).unwrap()).unwrap(); + assert!(event_contains_command( + &updated_hooks, + "SessionStart", + "custom-hook" + )); + assert!( + !serde_json::to_string(&updated_hooks) + .unwrap() + .contains("plugin-shim hook codex") + ); +} + +#[test] +fn codex_reinstall_uses_fresh_backup_after_prior_uninstall() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + + install_codex_config(&path, DEFAULT_URL).unwrap(); + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + assert!(!backup_path(&path).exists()); + + fs::write(&path, "model_provider = \"local\"\n").unwrap(); + install_codex_config(&path, DEFAULT_URL).unwrap(); + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + + assert_eq!( + fs::read_to_string(&path).unwrap(), + "model_provider = \"local\"\n" + ); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn claude_restore_without_backup_preserves_matching_user_relay_url() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": DEFAULT_URL, + "OTHER": "kept" + } + })) + .unwrap(), + ) + .unwrap(); + + claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some(DEFAULT_URL) + ); + assert_eq!(json_env_string(&updated, "OTHER"), Some("kept")); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn claude_enable_rolls_back_backup_when_settings_write_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .unwrap(), + ) + .unwrap(); + fs::create_dir(settings.with_extension("json.tmp")).unwrap(); + + let error = claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap_err(); + + assert!(error.contains("failed to write")); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn claude_enable_does_not_back_up_when_env_shape_is_invalid() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": "invalid" + })) + .unwrap(), + ) + .unwrap(); + + let error = claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap_err(); + + assert!(error.contains("non-object env field")); + assert!(!backup_path(&settings).exists()); + let unchanged: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!(unchanged["env"], json!("invalid")); +} + +#[test] +fn claude_restore_with_backup_preserves_user_settings_added_after_install() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com", + "ORIGINAL": "kept" + } + })) + .unwrap(), + ) + .unwrap(); + claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": DEFAULT_URL, + "ORIGINAL": "updated", + "ADDED": "kept" + }, + "theme": "dark" + })) + .unwrap(), + ) + .unwrap(); + + claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some("https://api.anthropic.com") + ); + assert_eq!(json_env_string(&updated, "ORIGINAL"), Some("updated")); + assert_eq!(json_env_string(&updated, "ADDED"), Some("kept")); + assert_eq!(updated["theme"], json!("dark")); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn claude_restore_with_backup_preserves_user_changed_provider_url() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .unwrap(), + ) + .unwrap(); + claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:49999" + } + })) + .unwrap(), + ) + .unwrap(); + + claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some("http://127.0.0.1:49999") + ); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn claude_reinstall_uses_fresh_backup_after_prior_restore() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .unwrap(), + ) + .unwrap(); + + claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + assert!(!backup_path(&settings).exists()); + + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://custom.example" + } + })) + .unwrap(), + ) + .unwrap(); + + claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some("https://custom.example") + ); + assert!(!backup_path(&settings).exists()); +} + +#[test] +fn stale_lock_is_repaired_after_grace_period_even_when_pid_file_exists() { + let dir = tempdir().unwrap(); + let lock = dir.path().join("codex-sidecar.lock"); + fs::create_dir(&lock).unwrap(); + fs::write( + dir.path().join("codex-sidecar.pid"), + std::process::id().to_string(), + ) + .unwrap(); + + assert!(repair_stale_lock_after(&lock, Duration::ZERO)); + assert!(!lock.exists()); +} + +#[test] +fn codex_hook_command_uses_cmd_quoting_for_windows_paths() { + let relay = std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"); + let command = codex_hook_command_for_platform(&relay, DEFAULT_URL, true); + + assert_eq!( + command, + r#""C:\Program Files\NeMo 100%%\bin\nemo-relay.exe" plugin-shim hook codex --gateway-url http://127.0.0.1:47632"# + ); + assert_eq!( + shell_quote_arg_for_platform("foo&bar", true), + r#""foo^&bar""# + ); +} + +#[test] +fn codex_hook_command_uses_posix_single_quote_escaping() { + let relay = std::path::PathBuf::from("/tmp/NeMo $Relay`test'/bin/nemo-relay"); + let command = codex_hook_command_for_platform(&relay, DEFAULT_URL, false); + + assert_eq!( + command, + "'/tmp/NeMo $Relay`test'\\''/bin/nemo-relay' plugin-shim hook codex --gateway-url http://127.0.0.1:47632" + ); + assert_eq!(shell_quote_arg_for_platform("", false), "''"); + assert_eq!( + shell_quote_arg_for_platform(r"/tmp/path\with-backslash", false), + r#"'/tmp/path\with-backslash'"# + ); +} + +#[test] +fn hook_forward_connect_attempt_is_bounded() { + let error = post_hook(CodingAgent::Codex, "http://127.0.0.1:9", b"{}").unwrap_err(); + + assert!(error.contains("hook forward failed")); +} + +#[test] +fn hook_http_response_requires_numeric_2xx_status() { + assert_eq!( + parse_http_response(b"HTTP/1.1 204 No Content\r\n\r\npayload").unwrap(), + b"payload" + ); + assert!( + parse_http_response(b"HTTP/1.1 500 upstream 2 bad\r\n\r\npayload") + .unwrap_err() + .contains("HTTP/1.1 500 upstream 2 bad") + ); + assert!( + parse_http_response(b"HTTP/1.1 OK 2\r\n\r\npayload") + .unwrap_err() + .contains("HTTP/1.1 OK 2") + ); +} + +#[test] +fn unready_sidecar_child_is_terminated_and_pid_removed() { + let dir = tempdir().unwrap(); + let pid_path = dir.path().join("codex-sidecar.pid"); + let mut command = long_lived_command(); + let child = command.spawn().unwrap(); + fs::write(&pid_path, child.id().to_string()).unwrap(); + + let error = terminate_unready_sidecar(child, &pid_path, DEFAULT_URL).unwrap_err(); + + assert!(error.contains("terminated startup process")); + assert!(!pid_path.exists()); +} + +#[test] +fn codex_uninstall_removes_only_exact_generated_hook_groups() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let command = codex_hook_command("http://127.0.0.1:47633"); + let generated = generated_hooks(CodingAgent::Codex, &command); + let user_command = "custom-user-codex-hook"; + let config = json!({ + "hooks": { + "SessionStart": [ + generated["hooks"]["SessionStart"][0].clone(), + { + "hooks": [ + { + "type": "command", + "command": user_command, + "timeout": 30 + } + ] + } + ] + } + }); + fs::write(&path, serde_json::to_vec_pretty(&config).unwrap()).unwrap(); + + uninstall_codex_hooks(&path, "http://127.0.0.1:47633").unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(event_contains_command( + &updated, + "SessionStart", + user_command + )); + assert!(!generated_event_contains_group( + &updated, + "SessionStart", + &generated["hooks"]["SessionStart"][0] + )); +} + +#[test] +fn codex_install_hooks_removes_prior_non_default_generated_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let old_command = codex_hook_command("http://127.0.0.1:47633"); + let new_command = codex_hook_command("http://127.0.0.1:47634"); + + install_codex_hooks(&path, "http://127.0.0.1:47633").unwrap(); + install_codex_hooks(&path, "http://127.0.0.1:47634").unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + &old_command + )); + assert!(event_contains_command( + &updated, + "SessionStart", + &new_command + )); +} + +#[test] +fn codex_uninstall_hooks_removes_all_generated_url_variants_for_launcher() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let old_command = codex_hook_command("http://127.0.0.1:47633"); + let new_command = codex_hook_command("http://127.0.0.1:47634"); + let mut old_generated = generated_hooks(CodingAgent::Codex, &old_command); + let new_generated = generated_hooks(CodingAgent::Codex, &new_command); + old_generated["hooks"]["SessionStart"] + .as_array_mut() + .unwrap() + .push(new_generated["hooks"]["SessionStart"][0].clone()); + fs::write(&path, serde_json::to_vec_pretty(&old_generated).unwrap()).unwrap(); + + uninstall_codex_hooks(&path, "http://127.0.0.1:47634").unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + &old_command + )); + assert!(!event_contains_command( + &updated, + "SessionStart", + &new_command + )); +} + +#[cfg(windows)] +fn long_lived_command() -> std::process::Command { + let mut command = std::process::Command::new("cmd"); + command.args(["/C", "ping -n 60 127.0.0.1 >NUL"]); + command +} + +#[cfg(not(windows))] +fn long_lived_command() -> std::process::Command { + let mut command = std::process::Command::new("sh"); + command.args(["-c", "sleep 60"]); + command +} + +#[test] +fn codex_install_hooks_persist_custom_gateway_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + + install_codex_hooks(&path, "http://127.0.0.1:47633").unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let command = updated["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + + assert!(command.contains("plugin-shim hook codex")); + assert!(command.contains("--gateway-url http://127.0.0.1:47633")); +} + +#[test] +fn codex_install_hooks_replaces_legacy_generated_command() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let relay = current_exe().unwrap(); + let legacy_command = legacy_codex_hook_command(&relay); + let legacy = generated_hooks(CodingAgent::Codex, &legacy_command); + fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + + install_codex_hooks(&path, DEFAULT_URL).unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + &legacy_command + )); + assert!(event_contains_command( + &updated, + "SessionStart", + &codex_hook_command(DEFAULT_URL) + )); +} + +#[test] +fn codex_install_does_not_write_provider_config_when_hooks_are_invalid() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + "model_provider = \"openai\"\n", + ) + .unwrap(); + fs::write(codex_dir.join("hooks.json"), "{ invalid json").unwrap(); + + let error = install_codex(DEFAULT_URL).unwrap_err(); + assert!(error.contains("invalid JSON")); + + assert_eq!( + fs::read_to_string(codex_dir.join("config.toml")).unwrap(), + "model_provider = \"openai\"\n" + ); + assert!(!backup_path(&codex_dir.join("config.toml")).exists()); +} + +#[test] +fn codex_install_does_not_write_hooks_when_config_is_invalid() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write(codex_dir.join("config.toml"), "model_provider = [").unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + let original_hooks = serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "custom-hook", + "timeout": 30 + } + ] + } + ] + } + })) + .unwrap(); + fs::write(&hooks_path, &original_hooks).unwrap(); + + let error = install_codex(DEFAULT_URL).unwrap_err(); + assert!(error.contains("invalid TOML")); + + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert!(!backup_path(&hooks_path).exists()); +} + +#[test] +fn codex_install_does_not_write_hooks_when_config_is_not_readable() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::create_dir(codex_dir.join("config.toml")).unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + let original_hooks = serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "custom-hook", + "timeout": 30 + } + ] + } + ] + } + })) + .unwrap(); + fs::write(&hooks_path, &original_hooks).unwrap(); + + let error = install_codex(DEFAULT_URL).unwrap_err(); + assert!(error.contains("failed to read")); + + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert!(!backup_path(&hooks_path).exists()); +} + +#[test] +fn codex_install_config_rolls_back_backup_when_write_fails() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "model_provider = \"openai\"\n").unwrap(); + fs::create_dir(path.with_extension("toml.tmp")).unwrap(); + + let error = install_codex_config(&path, DEFAULT_URL).unwrap_err(); + + assert!(error.contains("failed to write")); + assert!(!backup_path(&path).exists()); +} + +#[test] +fn codex_install_rolls_back_hooks_backup_when_hook_merge_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + "model_provider = \"openai\"\n", + ) + .unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + let original_hooks = serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": "invalid" + } + })) + .unwrap(); + fs::write(&hooks_path, &original_hooks).unwrap(); + + let error = install_codex(DEFAULT_URL).unwrap_err(); + + assert!(error.contains("SessionStart hooks must be an array")); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert!(!backup_path(&hooks_path).exists()); + assert_eq!( + fs::read_to_string(codex_dir.join("config.toml")).unwrap(), + "model_provider = \"openai\"\n" + ); +} + +#[test] +fn codex_uninstall_rolls_back_hooks_when_provider_config_is_invalid() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write(codex_dir.join("config.toml"), "model_provider = [").unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + install_codex_hooks(&hooks_path, DEFAULT_URL).unwrap(); + let original_hooks = fs::read(&hooks_path).unwrap(); + + let error = uninstall_codex(DEFAULT_URL).unwrap_err(); + + assert!(error.contains("invalid TOML")); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); +} + +#[test] +fn codex_install_rolls_back_hooks_when_provider_config_write_fails() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).unwrap(); + fs::write( + codex_dir.join("config.toml"), + "model_provider = \"openai\"\n", + ) + .unwrap(); + fs::create_dir(codex_dir.join("config.toml.tmp")).unwrap(); + let hooks_path = codex_dir.join("hooks.json"); + let original_hooks = serde_json::to_vec_pretty(&json!({ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "custom-hook", + "timeout": 30 + } + ] + } + ] + } + })) + .unwrap(); + fs::write(&hooks_path, &original_hooks).unwrap(); + + let error = install_codex(DEFAULT_URL).unwrap_err(); + + assert!(error.contains("failed to write")); + assert_eq!(fs::read(&hooks_path).unwrap(), original_hooks); + assert!(!backup_path(&hooks_path).exists()); + assert_eq!( + fs::read_to_string(codex_dir.join("config.toml")).unwrap(), + "model_provider = \"openai\"\n" + ); +} + +#[test] +fn codex_uninstall_hooks_removes_legacy_generated_command() { + let dir = tempdir().unwrap(); + let path = dir.path().join("hooks.json"); + let relay = current_exe().unwrap(); + let legacy_command = legacy_codex_hook_command(&relay); + let legacy = generated_hooks(CodingAgent::Codex, &legacy_command); + fs::write(&path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + + uninstall_codex_hooks(&path, DEFAULT_URL).unwrap(); + let updated: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + + assert!(!event_contains_command( + &updated, + "SessionStart", + &legacy_command + )); +} + +#[test] +fn codex_provider_gateway_url_reads_managed_provider_url() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +[model_providers.nemo-relay-openai] +base_url = "http://127.0.0.1:47633" +"#, + ) + .unwrap(); + + assert_eq!( + codex_provider_gateway_url(&path).as_deref(), + Some("http://127.0.0.1:47633") + ); +} + +#[test] +fn healthz_times_out_for_bad_port_occupant() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + thread::sleep(Duration::from_secs(2)); + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\n\r\n"); + }); + + let started = Instant::now(); + assert!(!healthz(&format!("http://127.0.0.1:{port}"))); + assert!(started.elapsed() < Duration::from_secs(2)); + handle.join().unwrap(); +} + +fn event_contains_command(config: &Value, event: &str, command: &str) -> bool { + config + .get("hooks") + .and_then(Value::as_object) + .and_then(|hooks| hooks.get(event)) + .and_then(Value::as_array) + .is_some_and(|groups| { + groups.iter().any(|group| { + group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|hooks| { + hooks.iter().any(|hook| { + hook.get("command").and_then(Value::as_str) == Some(command) + }) + }) + }) + }) +} diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 677a38e79..783049792 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -35,6 +35,33 @@ const GENERIC_TEST_PLUGIN_KIND: &str = "cli-test-generic-plugin"; static GENERIC_TEST_PLUGIN_REGISTRATIONS: AtomicUsize = AtomicUsize::new(0); static GENERIC_TEST_PLUGIN_DEREGISTRATIONS: AtomicUsize = AtomicUsize::new(0); +struct EnvVarGuard { + key: &'static str, + old: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let old = std::env::var(key).ok(); + unsafe { + std::env::set_var(key, value); + } + Self { key, old } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + if let Some(old) = self.old.take() { + std::env::set_var(self.key, old); + } else { + std::env::remove_var(self.key); + } + } + } +} + struct ToolGuardrailCleanup(&'static str); impl Drop for ToolGuardrailCleanup { @@ -153,6 +180,112 @@ async fn healthz_returns_ok() { assert_eq!(body, json!({ "status": "ok" })); } +#[tokio::test] +async fn serve_listener_honors_plugin_idle_timeout_env() { + let _guard = PLUGIN_TEST_LOCK.lock().await; + let _env = EnvVarGuard::set("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "1"); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let handle = tokio::spawn(async move { serve_listener(listener, test_config(), None).await }); + + wait_for_gateway(&url).await; + let result = tokio::time::timeout(std::time::Duration::from_secs(3), handle) + .await + .expect("plugin idle timeout should stop the sidecar") + .unwrap(); + result.unwrap(); +} + +#[tokio::test] +async fn serve_listener_waits_for_active_turn_before_plugin_idle_shutdown() { + let _guard = PLUGIN_TEST_LOCK.lock().await; + let _env = EnvVarGuard::set("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "1"); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let handle = tokio::spawn(async move { serve_listener(listener, test_config(), None).await }); + let client = test_http_client(); + + wait_for_gateway(&url).await; + let response = client + .post(format!("{url}/hooks/codex")) + .json(&json!({ + "session_id": "plugin-idle-open-session", + "hook_event_name": "sessionStart" + })) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + let response = client + .post(format!("{url}/hooks/codex")) + .json(&json!({ + "session_id": "plugin-idle-open-session", + "hook_event_name": "UserPromptSubmit" + })) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + assert!( + !handle.is_finished(), + "plugin sidecar exited before the active turn ended" + ); + + let response = client + .post(format!("{url}/hooks/codex")) + .json(&json!({ + "session_id": "plugin-idle-open-session", + "hook_event_name": "Stop" + })) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + let result = tokio::time::timeout(std::time::Duration::from_secs(3), handle) + .await + .expect("plugin idle timeout should stop after Stop closes the turn") + .unwrap(); + result.unwrap(); +} + +#[tokio::test] +async fn serve_listener_exits_after_codex_stop_without_session_end() { + let _guard = PLUGIN_TEST_LOCK.lock().await; + let _env = EnvVarGuard::set("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS", "1"); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let url = format!("http://{address}"); + let handle = tokio::spawn(async move { serve_listener(listener, test_config(), None).await }); + let client = test_http_client(); + + wait_for_gateway(&url).await; + for hook_event_name in ["sessionStart", "Stop"] { + let response = client + .post(format!("{url}/hooks/codex")) + .json(&json!({ + "session_id": "plugin-idle-metadata-only-session", + "hook_event_name": hook_event_name + })) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + } + + let result = tokio::time::timeout(std::time::Duration::from_secs(3), handle) + .await + .expect("plugin idle timeout should stop without a Codex SessionEnd") + .unwrap(); + result.unwrap(); +} + #[tokio::test] async fn serve_listener_activates_plugin_config_and_clears_on_shutdown() { let _guard = PLUGIN_TEST_LOCK.lock().await; diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 4b3466870..15b52ae0e 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -22,11 +22,14 @@ environment variables, or shared TOML config. ## Packages -- `claude-code/` installs Claude Code hook entries targeting - `POST /hooks/claude-code`. -- `codex/` installs Codex hook entries targeting `POST /hooks/codex` and enables - `features.hooks = true`. Use `nemo-relay run` or a gateway provider alias - for Codex LLM gateway routing. +- `claude-code/` is a Claude Code plugin package. It installs hook entries + targeting `POST /hooks/claude-code` through `nemo-relay` on `PATH`. +- `codex/` is a Codex plugin package. `nemo-relay install codex` creates the + marketplace, installs the plugin, enables `features.hooks = true`, and + configures a local `nemo-relay-openai` provider alias. Codex plugin delivery + uses hook-supervised lazy sidecar startup only: no wrapper, user-level daemon, + login item, launchd agent, systemd user service, scheduled task, or persistent + supervisor. - `cursor/` installs a Cursor `.cursor/hooks.json` bundle targeting `POST /hooks/cursor`. - Hermes does not require a static bundle in this directory. The setup wizard @@ -65,6 +68,62 @@ nemo-relay doctor hermes --json The command is read-only: it reports missing ATIF directories, hook files, and agent commands instead of creating or patching them. +## Plugin Installation + +The Claude Code and Codex plugins are installed by the `nemo-relay` CLI. The +CLI must already be installed and discoverable on `$PATH` or `%PATH%`; no +separate npm installer, release bundle download, or plugin-local Relay binary is +required. + +Claude Code can start the sidecar from plugin hooks or helper commands and route +model traffic by setting `ANTHROPIC_BASE_URL` to the sidecar URL. + +Codex does not use a daemon in plugin mode. Installed Codex hooks call the +`nemo-relay plugin-shim hook codex` command. The shim checks `/healthz`, starts +the local `nemo-relay` sidecar if needed, waits briefly for readiness, and then +forwards the hook payload. Codex model traffic is routed through the stable +provider alias at `http://127.0.0.1:47632`. + +End users install local host marketplaces with: + +```bash +nemo-relay install claude-code +nemo-relay install codex +nemo-relay install all +``` + +`nemo-relay install` writes local marketplace files, registers the selected host +plugin, and performs the required host provider and hook setup. Use +`nemo-relay uninstall ` to roll back and `nemo-relay doctor --plugin +` to check an installed plugin. + +Codex users can also add this repository as a marketplace for source/dev +discovery: + +```bash +codex plugin marketplace add NVIDIA/NeMo-Relay +codex plugin add nemo-relay-plugin@nemo-relay +``` + +That path relies on `nemo-relay` being available on `PATH`; source plugin hooks +invoke `nemo-relay plugin-shim hook codex` directly. + +Use the source marketplace path for discovery or manifest validation. Remove +the source-installed Codex plugin before running `nemo-relay install codex`; +keeping both active can forward the same Codex hook twice. + +Claude Code users can add this repository as a marketplace the same way: + +```bash +claude plugin marketplace add NVIDIA/NeMo-Relay \ + --sparse .claude-plugin integrations/coding-agents/claude-code +claude plugin install nemo-relay-plugin@nemo-relay --scope user +``` + +That path reads `.claude-plugin/marketplace.json` from the repository. Source +plugin hooks invoke `nemo-relay plugin-shim hook claude` directly. Use +`nemo-relay install claude-code` for the complete provider-routing setup. + Hermes transparent runs export the dynamic `NEMO_RELAY_GATEWAY_URL`, but Hermes hooks must already be present in `.hermes/config.yaml` before they can call the gateway. The setup wizard (`nemo-relay config`) writes that file for you when @@ -105,14 +164,20 @@ endpoint = "http://127.0.0.1:4318/v1/traces" ## Hook Forwarding -Hooks call `nemo-relay hook-forward ` with the canonical hook payload on -stdin. The wrapper injects `NEMO_RELAY_GATEWAY_URL` so the same hook command -reaches the ephemeral per-run gateway; hermes hooks fall back to an embedded -`--gateway-url` when running outside the wrapper. +Transparent wrapper hooks call `nemo-relay hook-forward ` with the +canonical hook payload on stdin. The wrapper injects `NEMO_RELAY_GATEWAY_URL` so +the same hook command reaches the ephemeral per-run gateway; hermes hooks fall +back to an embedded `--gateway-url` when running outside the wrapper. + +Claude Code and Codex plugin hooks call `nemo-relay plugin-shim hook `. +The plugin shim ensures the local sidecar is reachable, then forwards the hook +payload to the plugin sidecar endpoint. -`hook-forward` prints the vendor-specific response and fails open by default -(observability outages do not block the coding agent). Add `--fail-closed` to -generated hook commands when policy requires hook delivery to block the agent. +Hook forwarding fails open by default, so observability outages do not block the +coding agent. For wrapper-generated `hook-forward` commands, add +`--fail-closed` when policy requires hook delivery to block the agent. For +plugin shim hooks, set `NEMO_RELAY_FAIL_CLOSED=1` in the hook execution +environment. Useful wrapper options: diff --git a/integrations/coding-agents/claude-code/.claude-plugin/plugin.json b/integrations/coding-agents/claude-code/.claude-plugin/plugin.json index 236e60abf..2d29f06c7 100644 --- a/integrations/coding-agents/claude-code/.claude-plugin/plugin.json +++ b/integrations/coding-agents/claude-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "nemo-relay-claude-code-observability", - "version": "0.1.0", - "description": "Claude Code hooks that forward canonical lifecycle payloads to nemo-relay-sidecar.", + "name": "nemo-relay-plugin", + "version": "0.4.0", + "description": "Claude Code hooks that forward canonical lifecycle payloads to nemo-relay.", "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" @@ -14,6 +14,5 @@ "claude-code", "hooks", "observability" - ], - "hooks": "../hooks/hooks.json" + ] } diff --git a/integrations/coding-agents/claude-code/README.md b/integrations/coding-agents/claude-code/README.md index 114782b55..e14f0d869 100644 --- a/integrations/coding-agents/claude-code/README.md +++ b/integrations/coding-agents/claude-code/README.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All SPDX-License-Identifier: Apache-2.0 --> -# NeMo Relay Claude Code Observability +# NeMo Relay Plugin This package contains Claude Code hook entries that forward canonical Claude Code hook JSON to `nemo-relay` at `/hooks/claude-code`. @@ -16,15 +16,14 @@ same local hook and gateway controls as Claude Code. - `.claude-plugin/plugin.json` describes the Claude Code hook package. - `hooks/hooks.json` contains hook entries that run - `nemo-relay hook-forward claude`. + `nemo-relay plugin-shim hook claude`. ## Captured Events -The bundle forwards `SessionStart`, `SessionEnd`, `SubagentStart`, -`SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, -`Notification`, and `PreCompact` as scope, tool, or mark events. -`UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, and `Stop` -provide private LLM correlation hints for gateway requests. +The bundle forwards `SessionStart`, `SessionEnd`, `UserPromptSubmit`, +`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, +`SubagentStart`, `SubagentStop`, `Notification`, `Stop`, `PreCompact`, and +`PostCompact` as scope, tool, mark, or private LLM correlation events. Claude Code observability is turn-oriented. A multi-turn session can produce one root `claude-code-turn` span or ATIF trajectory per user turn. That is expected @@ -105,8 +104,9 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:4040 claude ``` -Hook events (tool calls, session markers) are only captured when running -through the wrapper, which injects ephemeral hooks per-run. +In manual gateway-only mode without the plugin installed, hook events such as +tool calls and session markers are only captured when running through the +wrapper, which injects ephemeral hooks per run. ## Verify @@ -152,3 +152,135 @@ unknown subagent end arrives during an active turn, Relay may emit a Hook events are only available when Claude Code loads this plugin. A standalone gateway observes Anthropic LLM traffic, but it cannot recover missing prompt, tool, compaction, notification, or subagent hooks. +## Standalone Plugin Installation + +Preferred release install: + +```bash +nemo-relay install claude-code +``` + +`nemo-relay install claude-code` writes a local Claude Code marketplace, +installs `nemo-relay-plugin` at user scope, and enables Claude Code provider +routing through NeMo Relay. + +No separate provider-routing command is required when installing through +`nemo-relay install`. + +The install command requires `nemo-relay` to be available on `PATH`. It does not +require launching Claude Code through the `nemo-relay` wrapper. + +Package or unpack the plugin so the plugin root contains: + +```text +nemo-relay-plugin/ + .claude-plugin/plugin.json + hooks/hooks.json +``` + +The hook shim starts the sidecar lazily if no gateway is already reachable. + +Repo marketplace discovery is also supported: + +```bash +claude plugin marketplace add NVIDIA/NeMo-Relay \ + --sparse .claude-plugin integrations/coding-agents/claude-code +claude plugin install nemo-relay-plugin@nemo-relay --scope user +``` + +That path reads `.claude-plugin/marketplace.json` from the repository and +installs this Claude Code plugin from `integrations/coding-agents/claude-code`. +Source hooks invoke `nemo-relay plugin-shim hook claude` directly. Use +`nemo-relay install claude-code` for the complete provider-routing setup. + +Create a local Claude Code marketplace and copy the plugin under that +marketplace root: + +```bash +MARKETPLACE_ROOT="$HOME/.local/share/nemo-relay/claude-code-marketplace" +PLUGIN_ROOT="$MARKETPLACE_ROOT/plugins/nemo-relay-plugin" +mkdir -p "$MARKETPLACE_ROOT/.claude-plugin" "$MARKETPLACE_ROOT/plugins" +cp -R /path/to/nemo-relay-plugin "$PLUGIN_ROOT" +``` + +Create `$MARKETPLACE_ROOT/.claude-plugin/marketplace.json`: + +```json +{ + "name": "nemo-relay-local", + "metadata": { + "description": "Local NeMo Relay plugins for Claude Code." + }, + "owner": { + "name": "NVIDIA Corporation and Affiliates", + "email": "noreply@nvidia.com" + }, + "plugins": [ + { + "name": "nemo-relay-plugin", + "description": "Forward Claude Code lifecycle hooks to a local NeMo Relay sidecar.", + "source": "./plugins/nemo-relay-plugin", + "category": "development" + } + ] +} +``` + +Validate the marketplace, add it to Claude Code, and install the plugin: + +```bash +claude plugin validate "$MARKETPLACE_ROOT" +claude plugin marketplace add "$MARKETPLACE_ROOT" +claude plugin install nemo-relay-plugin@nemo-relay-local --scope user +``` + +For a one-session development smoke test without persistent plugin +installation, launch Claude Code with the plugin directory: + +```bash +claude --plugin-dir "$PLUGIN_ROOT" +``` + +Hook commands in the source `hooks/hooks.json` template use +`nemo-relay plugin-shim hook claude`, so source marketplace installs rely on +the same `nemo-relay` executable available on `PATH`. + +If you set up the marketplace manually for development, use the top-level +installer commands for provider routing and rollback: + +```bash +nemo-relay install claude-code +nemo-relay uninstall claude-code +``` + +Run read-only plugin checks: + +```bash +nemo-relay doctor --plugin claude-code +``` + +Start a normal Claude Code session: + +```bash +claude +``` + +The installed hooks start the Relay sidecar lazily, and provider traffic is +routed through `ANTHROPIC_BASE_URL=http://127.0.0.1:47632`. + +To upgrade manually, replace the plugin directory contents with the new package, +keep the same `MARKETPLACE_ROOT`, update the marketplace, and rerun the +top-level installer: + +```bash +claude plugin marketplace update nemo-relay-local +claude plugin update nemo-relay-plugin +nemo-relay install claude-code +``` + +To uninstall, restore Claude Code provider settings, uninstall the plugin, remove +the marketplace registration, and remove the generated marketplace directory: + +```bash +nemo-relay uninstall claude-code +``` diff --git a/integrations/coding-agents/claude-code/hooks/hooks.json b/integrations/coding-agents/claude-code/hooks/hooks.json index e8ec49335..02df67346 100644 --- a/integrations/coding-agents/claude-code/hooks/hooks.json +++ b/integrations/coding-agents/claude-code/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -40,7 +40,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -52,7 +52,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -64,7 +64,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -75,7 +75,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -86,7 +86,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -97,7 +97,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -108,7 +108,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -119,7 +119,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -130,7 +130,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] @@ -141,7 +141,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward claude", + "command": "nemo-relay plugin-shim hook claude", "timeout": 30 } ] diff --git a/integrations/coding-agents/codex/.codex-plugin/plugin.json b/integrations/coding-agents/codex/.codex-plugin/plugin.json index fefc0f801..793a502b6 100644 --- a/integrations/coding-agents/codex/.codex-plugin/plugin.json +++ b/integrations/coding-agents/codex/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { - "name": "nemo-relay-codex-observability", - "version": "0.1.0", - "description": "Codex hooks that forward canonical lifecycle payloads to nemo-relay-sidecar.", + "name": "nemo-relay-plugin", + "version": "0.4.0", + "description": "Codex hooks that forward canonical lifecycle payloads to nemo-relay.", "author": { "name": "NVIDIA Corporation and Affiliates", "url": "https://github.com/NVIDIA/NeMo-Relay" @@ -15,16 +15,18 @@ "hooks", "observability" ], - "hooks": "../hooks/hooks.json", "interface": { - "displayName": "NeMo Relay Codex Observability", + "displayName": "NeMo Relay Plugin", "shortDescription": "Forward Codex lifecycle hooks to a local NeMo Relay sidecar.", - "longDescription": "Installs command hooks that preserve Codex hook payloads and forward them to nemo-relay-sidecar for agent, subagent, tool, and lifecycle observability. Full LLM capture also requires sidecar provider routing.", + "longDescription": "Installs command hooks that preserve Codex hook payloads and forward them to nemo-relay for agent, subagent, tool, and lifecycle observability. Full LLM capture also requires sidecar provider routing.", "developerName": "NVIDIA", "category": "Coding", "capabilities": [ "Read" ], + "defaultPrompt": [ + "Capture this Codex session with NeMo Relay observability." + ], "websiteURL": "https://github.com/NVIDIA/NeMo-Relay", "brandColor": "#76B900" } diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 21d62cac5..0beb5750f 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -3,7 +3,7 @@ SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All SPDX-License-Identifier: Apache-2.0 --> -# NeMo Relay Codex Observability +# NeMo Relay Plugin This package contains Codex hook entries that forward canonical Codex hook JSON to `nemo-relay` at `/hooks/codex`. @@ -19,20 +19,34 @@ provider alias surface the gateway relies on). ## Files - `.codex-plugin/plugin.json` describes the Codex plugin package. -- `hooks/hooks.json` contains hook entries that run - `nemo-relay hook-forward codex`. +- `hooks/hooks.json` contains Codex hook entries that run + `nemo-relay plugin-shim hook codex`. +- `nemo-relay install codex` creates the local marketplace, installs the plugin, + and persists Codex hook and provider configuration using `nemo-relay` from + `PATH`. ## Captured Events -The bundle forwards `SessionStart`, `SessionEnd`, `SubagentStart`, -`SubagentStop`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, -`Notification`, and `PreCompact` as scope, tool, or mark events. -`UserPromptSubmit`, `AfterAgentResponse`, `AfterAgentThought`, and `Stop` -provide private LLM correlation hints for gateway requests. +With `codex-cli >= 0.129.0`, the minimum supported installed hooks are +`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, +`PermissionRequest`, `Stop`, `PreCompact`, and `PostCompact`. -Transparent setup injects these hooks with CLI config overrides. Persistent -setup writes `features.hooks = true` in `.codex/config.toml` and merges the -hook entries into `.codex/hooks.json`. +The hook template also documents events used by newer or broader host hook +surfaces, including `SessionEnd`, `PostToolUseFailure`, `SubagentStart`, +`SubagentStop`, and `Notification`. Relay forwards any delivered supported hook +as scope, tool, mark, or private LLM correlation events, but v1 does not depend +on Codex exposing those broader events. + +Transparent setup injects these hooks with CLI config overrides. Plugin setup +does not install hooks from the package template directly. It writes +`features.hooks = true` in `.codex/config.toml`, configures the +`nemo-relay-openai` provider alias, and merges hook shim entries into +`.codex/hooks.json`. + +Codex plugin mode uses hook-supervised lazy startup only. It does not install a +user-level daemon, launchd agent, systemd user service, scheduled task, login +item, wrapper, or persistent supervisor. The sidecar starts only when a Codex +hook invokes `nemo-relay plugin-shim hook codex`. ## Transparent Setup @@ -149,3 +163,148 @@ If LLM spans are present but attached to the top-level agent instead of a subagent, include `x-nemo-relay-subagent-id` on gateway requests or share `conversation_id`, `generation_id`, or `request_id` values between hook payloads and provider requests. + +## Standalone Plugin Installation + +Preferred release install: + +```bash +nemo-relay install codex +``` + +`nemo-relay install codex` writes a local Codex marketplace, registers +`nemo-relay-plugin`, enables Codex hooks, and configures the +`nemo-relay-openai` provider alias. Codex sidecar lifecycle remains +hook-supervised lazy startup only; the installer does not create a wrapper or +daemon. + +The install command requires `nemo-relay` to be available on `PATH`. It does not +require launching Codex through the `nemo-relay` wrapper and does not install a +user-level daemon. + +Repo marketplace discovery is also supported: + +```bash +codex plugin marketplace add NVIDIA/NeMo-Relay +codex plugin add nemo-relay-plugin@nemo-relay +``` + +That path reads `.agents/plugins/marketplace.json` from the repository and +installs this Codex plugin from `integrations/coding-agents/codex`. Source hooks +invoke `nemo-relay plugin-shim hook codex` directly. + +Treat the source marketplace path as discovery or manifest validation. For the +complete provider and generated-hook setup, remove the source-installed plugin +first and then run `nemo-relay install codex`. Keeping both the source plugin +and the generated install active can forward the same Codex hook twice. + +Package or unpack the plugin so the plugin root contains: + +```text +nemo-relay-plugin/ + .codex-plugin/plugin.json + hooks/hooks.json +``` + +Create a local Codex marketplace and copy the plugin under that marketplace +root: + +```bash +MARKETPLACE_ROOT="$HOME/.local/share/nemo-relay/codex-marketplace" +PLUGIN_ROOT="$MARKETPLACE_ROOT/plugins/nemo-relay-plugin" +mkdir -p "$MARKETPLACE_ROOT/.agents/plugins" "$MARKETPLACE_ROOT/plugins" +cp -R /path/to/nemo-relay-plugin "$PLUGIN_ROOT" +``` + +Create `$MARKETPLACE_ROOT/.agents/plugins/marketplace.json`: + +```json +{ + "name": "nemo-relay-local", + "interface": { + "displayName": "NeMo Relay Local" + }, + "plugins": [ + { + "name": "nemo-relay-plugin", + "source": { + "source": "local", + "path": "./plugins/nemo-relay-plugin" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Coding" + } + ] +} +``` + +Registering the local marketplace with Codex is useful for development and +manifest validation: + +```bash +codex plugin marketplace add "$MARKETPLACE_ROOT" +codex plugin add nemo-relay-plugin@nemo-relay-local +``` + +For end-to-end installation, prefer `nemo-relay install codex`; it performs the +marketplace registration and the persistent Codex provider/hook setup together. +If you used the manual source marketplace commands above, remove that plugin +before running the full installer so source hook templates and generated +persistent hooks do not both forward the same event. + +The installer writes a provider alias like: + +```toml +model_provider = "nemo-relay-openai" + +[model_providers.nemo-relay-openai] +name = "NeMo Relay" +base_url = "http://127.0.0.1:47632" +wire_api = "responses" +requires_openai_auth = true +supports_websockets = false +``` + +Run read-only plugin checks: + +```bash +nemo-relay doctor --plugin codex +``` + +Start a normal Codex session: + +```bash +codex +``` + +The installed hooks start the Relay sidecar lazily on +`http://127.0.0.1:47632`, and the Codex provider alias routes model traffic +through that sidecar. No launchd agent, systemd user service, scheduled task, +login item, wrapper, or persistent supervisor is installed. + +To upgrade, replace the plugin directory contents with the new package for the +same host, keep the same `MARKETPLACE_ROOT`, refresh the local marketplace +registration, and rerun the top-level installer: + +```bash +codex plugin remove nemo-relay-plugin@nemo-relay-local +codex plugin marketplace remove nemo-relay-local +codex plugin marketplace add "$MARKETPLACE_ROOT" +codex plugin add nemo-relay-plugin@nemo-relay-local +nemo-relay install codex +``` + +To uninstall, remove NeMo Relay's Codex config and hook entries, remove the +marketplace registration, and remove the generated marketplace directory: + +```bash +nemo-relay uninstall codex +``` + +Full first-request LLM capture depends on Codex firing one of the installed +hooks, especially `SessionStart` or `UserPromptSubmit`, before its first model +provider request. If a Codex version sends the provider request first, the first +request may fail or may not be captured until the next hook starts Relay. diff --git a/integrations/coding-agents/codex/hooks/hooks.json b/integrations/coding-agents/codex/hooks/hooks.json index 7f6e9b400..43a79c2be 100644 --- a/integrations/coding-agents/codex/hooks/hooks.json +++ b/integrations/coding-agents/codex/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -29,7 +29,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -53,7 +53,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -65,7 +65,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -76,7 +76,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -87,7 +87,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -98,7 +98,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -109,7 +109,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -120,7 +120,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -131,7 +131,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] @@ -142,7 +142,7 @@ "hooks": [ { "type": "command", - "command": "nemo-relay hook-forward codex", + "command": "nemo-relay plugin-shim hook codex", "timeout": 30 } ] diff --git a/justfile b/justfile index bb7b3affa..6cfb8d392 100644 --- a/justfile +++ b/justfile @@ -388,6 +388,33 @@ try { NODE } +set_coding_agent_plugin_versions() { + local version="$1" + + node - "$version" <<'NODE' +const fs = require('fs'); +const version = process.argv[2]; +const manifests = [ + 'integrations/coding-agents/claude-code/.claude-plugin/plugin.json', + 'integrations/coding-agents/codex/.codex-plugin/plugin.json', +]; + +for (const manifestPath of manifests) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (!manifest.version) { + throw new Error(`${manifestPath} missing version field`); + } + if (manifest.version === version) { + console.log(`${manifestPath} already set to ${version}`); + continue; + } + manifest.version = version; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); + console.log(`${manifestPath} version updated to ${version}`); +} +NODE +} + read_workspace_version() { local python_executable="" python_executable="$(uv_python_executable)" @@ -527,6 +554,7 @@ set_project_version() { local version="$1" set_cargo_workspace_version "$version" set_node_package_versions "$version" + set_coding_agent_plugin_versions "$version" } set_python_package_version() { From e05a3da15d68b2b246224ed2adfeb4dbe6301b6c Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 9 Jun 2026 09:23:18 -0500 Subject: [PATCH 2/7] docs: document coding agent plugin installation Signed-off-by: Will Killian --- docs/getting-started/installation.mdx | 11 ++ docs/nemo-relay-cli/about.mdx | 4 + docs/nemo-relay-cli/basic-usage.mdx | 31 +++++ docs/nemo-relay-cli/claude-code.mdx | 26 +++- docs/nemo-relay-cli/codex.mdx | 32 ++++- docs/nemo-relay-cli/cursor.mdx | 2 +- docs/nemo-relay-cli/hermes.mdx | 2 +- docs/nemo-relay-cli/plugin-installation.mdx | 136 ++++++++++++++++++++ 8 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 docs/nemo-relay-cli/plugin-installation.mdx diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 9ce156ae4..1994f611f 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -23,6 +23,17 @@ coding-agent hook and LLM gateway observability. cargo install nemo-relay-cli@0.4.0 ``` +After installation, `nemo-relay` can also install persistent Claude Code and +Codex host plugins: + +```bash +nemo-relay install claude-code +nemo-relay install codex +``` + +See [Plugin Installation](/nemo-relay-cli/plugin-installation) for the host +plugin workflow. + ## Python Install the Python package when your application uses NeMo Relay through the diff --git a/docs/nemo-relay-cli/about.mdx b/docs/nemo-relay-cli/about.mdx index 9462c0004..bd61bdb61 100644 --- a/docs/nemo-relay-cli/about.mdx +++ b/docs/nemo-relay-cli/about.mdx @@ -24,6 +24,8 @@ Use these guides when you need to: - Observe Claude Code, Codex, Cursor, or Hermes Agent sessions locally. - Configure coding-agent hooks for NeMo Relay lifecycle events. - Route model-provider traffic through the local NeMo Relay gateway. +- Install persistent Claude Code or Codex host plugins without wrapping the + agent command. - Validate and install model-pricing catalog sources for local cost estimates. - Export local sessions to Agent Trajectory Interchange Format (ATIF), Agent Trajectory Observability Format (ATOF) JSONL, OpenTelemetry, or @@ -53,6 +55,8 @@ Use these guide links to move from CLI setup into agent-specific instructions. - [Basic Usage](/nemo-relay-cli/basic-usage) explains gateway routes, transparent runs, shared configuration, hook forwarding, and runtime mapping. +- [Plugin Installation](/nemo-relay-cli/plugin-installation) covers persistent + Claude Code and Codex plugin setup through host marketplaces. - [Claude Code](/nemo-relay-cli/claude-code) covers transparent Claude Code runs, Anthropic gateway routing, ATIF verification, and unsupported Claude application modes. diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index b421a6049..760acfbe4 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -93,6 +93,32 @@ shell hooks still need to be installed or otherwise approved in Hermes config. Use `--dry-run --print` to inspect the generated hook config, gateway environment, gateway URL, and final command without launching the agent. +## Persistent Host Plugins + +Use persistent plugin installation when Claude Code or Codex should load NeMo +Relay through the host's plugin system instead of through a wrapper command: + +```bash +nemo-relay install claude-code +nemo-relay install codex +nemo-relay install all +``` + +Plugin installation writes a local marketplace, installs the generated +`nemo-relay-plugin`, and configures the host-specific hook and provider routing +needed for full observability. It still relies on the same local `nemo-relay` +binary on `PATH`. + +Use plugin doctor and uninstall for the installed host state: + +```bash +nemo-relay doctor --plugin codex +nemo-relay uninstall codex +``` + +See [Plugin Installation](/nemo-relay-cli/plugin-installation) for install +directories, host-specific behavior, and Codex lazy sidecar constraints. + ## Shared Configuration Shared TOML config is optional. The gateway loads defaults, then system config, @@ -321,6 +347,10 @@ the gateway URL comes from `NEMO_RELAY_GATEWAY_URL` injected on every run; outside the wrapper (Hermes standalone, IDE-launched Claude/Codex) the hook command falls back to its embedded `--gateway-url`. +Hooks installed by Claude Code and Codex plugins use the plugin shim path +managed by `nemo-relay install`. That path starts or reuses the local plugin +sidecar before forwarding the hook payload. + `hook-forward` reads the canonical hook payload from standard input, sends it to the matching endpoint, and prints the endpoint response. It fails open by default so observability outages do not block the coding agent. Add @@ -340,6 +370,7 @@ application-mode caveats. - [Claude Code](/nemo-relay-cli/claude-code) - [Codex](/nemo-relay-cli/codex) +- [Plugin Installation](/nemo-relay-cli/plugin-installation) - [Cursor](/nemo-relay-cli/cursor) - [Hermes Agent](/nemo-relay-cli/hermes) diff --git a/docs/nemo-relay-cli/claude-code.mdx b/docs/nemo-relay-cli/claude-code.mdx index a274a6522..9d5e86838 100644 --- a/docs/nemo-relay-cli/claude-code.mdx +++ b/docs/nemo-relay-cli/claude-code.mdx @@ -1,7 +1,7 @@ --- title: "Claude Code" description: "" -position: 3 +position: 4 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -40,6 +40,30 @@ nemo-relay run \ -- claude ``` +## Persistent Plugin Install + +Use persistent plugin installation when Claude Code should load NeMo Relay from +its normal plugin marketplace instead of through `nemo-relay claude`: + +```bash +nemo-relay install claude-code +``` + +The installer creates a local marketplace, installs +`nemo-relay-plugin@nemo-relay-local` at user scope, and enables Claude Code +provider routing through the local NeMo Relay sidecar. It uses the existing +`nemo-relay` binary on `PATH`; it does not install a plugin-local Relay binary. + +Check or remove the installed plugin with: + +```bash +nemo-relay doctor --plugin claude-code +nemo-relay uninstall claude-code +``` + +See [Plugin Installation](/nemo-relay-cli/plugin-installation) for install +directories, rollback behavior, and source marketplace notes. + ## Shared Config Create `.nemo-relay/config.toml` for project defaults or diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index e80074c16..81a76ea00 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -1,7 +1,7 @@ --- title: "Codex" description: "" -position: 4 +position: 5 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -57,6 +57,36 @@ nemo-relay run \ -- codex ``` +## Persistent Plugin Install + +Use persistent plugin installation when Codex should start normally and load +NeMo Relay from its plugin marketplace: + +```bash +nemo-relay install codex +``` + +The installer creates a local marketplace, installs +`nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, and configures the +`nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. It uses the +existing `nemo-relay` binary on `PATH`; it does not install a plugin-local Relay +binary. + +Codex plugin mode uses hook-supervised lazy startup only. It does not install a +wrapper, user-level daemon, launchd agent, systemd user service, scheduled +task, login item, or persistent supervisor. + +Check or remove the installed plugin with: + +```bash +nemo-relay doctor --plugin codex +nemo-relay uninstall codex +``` + +See [Plugin Installation](/nemo-relay-cli/plugin-installation) for install +directories, first-request capture limits, rollback behavior, and source +marketplace notes. + ## Shared Config Create `.nemo-relay/config.toml` for project defaults or diff --git a/docs/nemo-relay-cli/cursor.mdx b/docs/nemo-relay-cli/cursor.mdx index b1b195dff..381893f12 100644 --- a/docs/nemo-relay-cli/cursor.mdx +++ b/docs/nemo-relay-cli/cursor.mdx @@ -1,7 +1,7 @@ --- title: "Cursor" description: "" -position: 5 +position: 6 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/nemo-relay-cli/hermes.mdx b/docs/nemo-relay-cli/hermes.mdx index ad4e4c433..443f9fed2 100644 --- a/docs/nemo-relay-cli/hermes.mdx +++ b/docs/nemo-relay-cli/hermes.mdx @@ -1,7 +1,7 @@ --- title: "Hermes Agent" description: "" -position: 6 +position: 7 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx new file mode 100644 index 000000000..b60aa9d30 --- /dev/null +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -0,0 +1,136 @@ +--- +title: "Plugin Installation" +description: "" +position: 3 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + + +Use plugin installation when you want Claude Code or Codex to load NeMo Relay +through the host's normal plugin system instead of launching the agent through a +`nemo-relay` wrapper command. + +The installed plugin keeps the same observability model as transparent runs: +host hooks provide agent, subagent, tool, prompt, compaction, and stop +lifecycle signals, while model-provider routing sends LLM traffic through the +local NeMo Relay gateway. Hooks alone cannot produce complete LLM request and +response spans. + +## Requirements + +Install `nemo-relay` first and make sure it is available on `PATH` or `%PATH%`. +The plugin installer does not download a second Relay binary, install a daemon, +or require a plugin-local executable. + +The selected host CLI must also be available: + +- `claude` for Claude Code plugin installation. +- `codex` for Codex plugin installation. + +## Install + +Install one host plugin: + +```bash +nemo-relay install claude-code +nemo-relay install codex +``` + +Install every supported host detected on the machine: + +```bash +nemo-relay install all +``` + +`install all` selects only hosts whose CLI is present. It fails if neither +Claude Code nor Codex is detected. + +Use `--dry-run` to inspect the generated marketplace paths and host commands +without writing files or changing host configuration: + +```bash +nemo-relay install codex --dry-run +``` + +Use `--install-dir` when you need a non-default marketplace location. The +default directory is platform-specific: + +| Platform | Default plugin install directory | +| --- | --- | +| macOS | `~/Library/Application Support/nemo-relay/plugins` | +| Linux | `${XDG_DATA_HOME:-~/.local/share}/nemo-relay/plugins` | +| Windows | `%LOCALAPPDATA%\nemo-relay\plugins` | + +## What Install Changes + +`nemo-relay install` writes a local marketplace named `nemo-relay-local`, then +registers the generated `nemo-relay-plugin` package with the selected host. + +For Claude Code, install registers the local Claude marketplace, installs +`nemo-relay-plugin@nemo-relay-local` at user scope, and enables provider routing +through the local NeMo Relay sidecar. Existing Claude auth and model settings +are preserved unless they must be backed up to add the Relay provider route. + +For Codex, install registers the local Codex marketplace, installs +`nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, merges generated hook +entries, and configures the `nemo-relay-openai` provider alias at +`http://127.0.0.1:47632`. + +Codex plugin mode is hook-supervised lazy startup only. It does not install a +Codex wrapper, user-level daemon, launchd agent, systemd user service, +scheduled task, login item, or persistent supervisor. The sidecar starts when an +installed Codex hook runs, reuses an already healthy sidecar when one exists, +and exits after its idle timeout. + + +Complete first-request capture in Codex depends on Codex firing an installed +hook before the first provider request. If a Codex version calls the provider +before any hook, the first request cannot be guaranteed under hook-only lazy +startup. + + +## Diagnose + +Run plugin doctor after installation: + +```bash +nemo-relay doctor --plugin claude-code +nemo-relay doctor --plugin codex +nemo-relay doctor --plugin all +``` + +Plugin doctor checks host registration, generated marketplace state, provider +routing, hook setup, and sidecar readiness assumptions. It is separate from +`nemo-relay doctor codex`, which diagnoses the regular transparent-run +configuration for an agent. + +## Uninstall + +Remove an installed plugin and restore host configuration: + +```bash +nemo-relay uninstall claude-code +nemo-relay uninstall codex +nemo-relay uninstall all +``` + +Uninstall removes the generated host plugin registration and marketplace entry, +restores Claude Code provider routing from the Relay backup, and removes +generated Codex hook/provider configuration while preserving unrelated user +configuration. + +## Source Marketplace Discovery + +This repository also contains source marketplace manifests for development and +validation: + +- `.claude-plugin/marketplace.json` +- `.agents/plugins/marketplace.json` + +Those manifests are useful when validating host plugin metadata from a source +checkout. For end-user setup, prefer `nemo-relay install ` because it +generates the local marketplace, registers the host plugin, and performs the +required provider and hook setup together. Avoid keeping both a source-installed +plugin and a generated install active for the same host because both can forward +the same hook payload. From 10ffdebd54da08055f059797c69980ede532926a Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 9 Jun 2026 12:41:24 -0500 Subject: [PATCH 3/7] fix: harden coding agent plugin install cleanup Signed-off-by: Will Killian --- crates/cli/src/config.rs | 2 +- crates/cli/src/plugin_install/mod.rs | 102 ++++++++++++++++-- crates/cli/src/plugin_install/setup.rs | 19 ++++ crates/cli/src/plugin_install/state.rs | 6 +- crates/cli/src/plugin_shim/claude.rs | 1 - crates/cli/src/plugin_shim/codex.rs | 2 +- crates/cli/src/plugin_shim/mod.rs | 48 +++++++++ crates/cli/src/plugin_shim/shared.rs | 72 +++++++++++-- crates/cli/tests/cli_tests.rs | 12 ++- .../tests/coverage/plugin_install_tests.rs | 85 +++++++++++++++ .../cli/tests/coverage/plugin_shim_tests.rs | 72 ++++++++++++- integrations/coding-agents/README.md | 5 +- 12 files changed, 403 insertions(+), 23 deletions(-) diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 141557e22..a7a9fb119 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -117,7 +117,7 @@ pub(crate) struct DoctorCommand { pub(crate) install_dir: Option, /// Emit machine-readable JSON instead of the formatted human report. Versioned via /// `schema_version`; stable shape for CI / evaluation harness consumption. - #[arg(long, conflicts_with = "plugin")] + #[arg(long)] pub(crate) json: bool, } diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index 9dabdf887..b23c55d3c 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -11,6 +11,8 @@ mod state; use std::path::{Path, PathBuf}; use std::process::ExitCode; +use serde_json::{Value, json}; + use crate::config::{InstallCommand, PluginHost, UninstallCommand}; use crate::error::CliError; @@ -21,8 +23,8 @@ use host::{ }; use marketplace::write_plugin_marketplace; use setup::{ - PluginSetupRunner, RealPluginSetupRunner, run_plugin_doctor, run_plugin_setup, - run_plugin_uninstall, + PluginSetupRunner, RealPluginSetupRunner, run_plugin_doctor, run_plugin_doctor_json, + run_plugin_setup, run_plugin_uninstall, }; use state::{ CanonicalizeOrSelf, HostRegistrationProgress, HostSelectionMode, PluginInstallOptions, @@ -44,6 +46,7 @@ pub(crate) fn install(command: InstallCommand) -> Result { force: command.force, dry_run: command.dry_run, skip_doctor: command.skip_doctor, + json: false, }; run_for_hosts( command.host, @@ -62,6 +65,7 @@ pub(crate) fn uninstall(command: UninstallCommand) -> Result force: false, dry_run: command.dry_run, skip_doctor: true, + json: false, }; run_for_hosts( command.host, @@ -74,7 +78,7 @@ pub(crate) fn uninstall(command: UninstallCommand) -> Result pub(crate) fn doctor( host: PluginHost, install_dir: Option, - _json: bool, + json: bool, ) -> Result { let options = PluginInstallOptions { install_dir: install_dir @@ -83,7 +87,11 @@ pub(crate) fn doctor( force: false, dry_run: false, skip_doctor: true, + json, }; + if json { + return doctor_json(host, &options); + } run_for_hosts( host, HostSelectionMode::InstalledState, @@ -128,6 +136,35 @@ where Ok(ExitCode::SUCCESS) } +fn doctor_json(host: PluginHost, options: &PluginInstallOptions) -> Result { + let runner = RealCommandRunner; + let setup_runner = RealPluginSetupRunner; + let hosts = select_hosts(host, HostSelectionMode::InstalledState, options, &runner)?; + if hosts.is_empty() { + return Err(CliError::Install(match host { + PluginHost::All => "no installed Claude Code or Codex plugin state was found".into(), + _ => "no supported plugin host selected".into(), + })); + } + let reports = hosts + .into_iter() + .map(|host| doctor_host_json_value(host, options, &runner, &setup_runner)) + .collect::, _>>() + .map_err(CliError::Install)?; + if matches!(host, PluginHost::All) { + print_json(&json!({ + "schema_version": 1, + "plugins": reports + })) + } else { + print_json(&with_schema( + reports.into_iter().next().expect("hosts is not empty"), + )) + } + .map_err(CliError::Install)?; + Ok(ExitCode::SUCCESS) +} + fn select_hosts( host: PluginHost, mode: HostSelectionMode, @@ -169,7 +206,15 @@ fn install_host( force_cleanup_existing_install(host, &layout, options, runner, setup_runner)?; } write_plugin_marketplace(host, &layout, options)?; - write_state(&layout, options)?; + if let Err(error) = write_state(&layout, options) { + if let Err(cleanup_error) = remove_path(&layout.marketplace_root, options) { + return Err(format!( + "{error}; additionally failed to remove generated marketplace {}: {cleanup_error}", + layout.marketplace_root.display() + )); + } + return Err(error); + } let mut registration = HostRegistrationProgress::default(); let mut setup_attempted = false; let result = (|| { @@ -235,8 +280,11 @@ fn uninstall_host_with_setup_override( plugin_setup_installed: true, } }); - let relay = require_relay(options, runner)?; - validate_relay_plugin_shim(&relay, options, runner)?; + if let Err(error) = require_relay(options, runner) + .and_then(|relay| validate_relay_plugin_shim(&relay, options, runner)) + { + eprintln!("warning: skipping nemo-relay validation during uninstall: {error}"); + } let mut state = state; if force_plugin_setup_uninstall && !state.plugin_setup_installed { state.plugin_setup_installed = true; @@ -264,6 +312,15 @@ fn doctor_host( validate_relay_plugin_shim(&relay, options, runner)?; let state = read_state(host, &options.install_dir) .ok_or_else(|| format!("no installed {} plugin state found", host_label(host)))?; + if options.json { + print_json(&with_schema(doctor_host_json_value( + host, + options, + runner, + setup_runner, + )?))?; + return Ok(()); + } println!("nemo-relay: {}", relay.display()); println!("host: {}", host_arg(host)); println!("marketplace: {}", state.marketplace_root.display()); @@ -271,6 +328,26 @@ fn doctor_host( run_plugin_doctor(host, options, setup_runner) } +fn doctor_host_json_value( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, + setup_runner: &dyn PluginSetupRunner, +) -> Result { + let relay = require_relay(options, runner)?; + validate_relay_plugin_shim(&relay, options, runner)?; + let state = read_state(host, &options.install_dir) + .ok_or_else(|| format!("no installed {} plugin state found", host_label(host)))?; + let plugin = run_plugin_doctor_json(host, setup_runner)?; + Ok(json!({ + "host": host_arg(host), + "nemo_relay": relay, + "marketplace": state.marketplace_root, + "plugin": state.plugin_root, + "checks": plugin + })) +} + fn force_cleanup_existing_install( host: PluginHost, layout: &PluginLayout, @@ -370,6 +447,19 @@ fn host_cli(host: PluginHost) -> &'static str { } } +fn print_json(value: &Value) -> Result<(), String> { + let rendered = serde_json::to_string_pretty(value).map_err(|error| error.to_string())?; + println!("{rendered}"); + Ok(()) +} + +fn with_schema(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + object.insert("schema_version".into(), json!(1)); + } + value +} + #[cfg(test)] use marketplace::*; #[cfg(test)] diff --git a/crates/cli/src/plugin_install/setup.rs b/crates/cli/src/plugin_install/setup.rs index 448784cfc..b52d36ff0 100644 --- a/crates/cli/src/plugin_install/setup.rs +++ b/crates/cli/src/plugin_install/setup.rs @@ -5,6 +5,7 @@ use crate::config::{CodingAgent, PluginHost}; use crate::plugin_shim; +use serde_json::Value; use super::DEFAULT_GATEWAY_URL; use super::state::PluginInstallOptions; @@ -45,6 +46,13 @@ pub(super) fn run_plugin_doctor( setup_runner.doctor(host, DEFAULT_GATEWAY_URL) } +pub(super) fn run_plugin_doctor_json( + host: PluginHost, + setup_runner: &dyn PluginSetupRunner, +) -> Result { + setup_runner.doctor_json(host, DEFAULT_GATEWAY_URL) +} + pub(super) fn setup_action_description(host: PluginHost, action: &str) -> String { match (host, action) { (PluginHost::Codex, "configure") => { @@ -70,6 +78,7 @@ pub(super) trait PluginSetupRunner { fn setup(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; fn uninstall(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String>; + fn doctor_json(&self, host: PluginHost, gateway_url: &str) -> Result; } pub(super) struct RealPluginSetupRunner; @@ -100,4 +109,14 @@ impl PluginSetupRunner for RealPluginSetupRunner { PluginHost::All => unreachable!("all is expanded before plugin doctor"), } } + + fn doctor_json(&self, host: PluginHost, gateway_url: &str) -> Result { + match host { + PluginHost::Codex => plugin_shim::doctor_plugin_json(CodingAgent::Codex, gateway_url), + PluginHost::ClaudeCode => { + plugin_shim::doctor_plugin_json(CodingAgent::ClaudeCode, gateway_url) + } + PluginHost::All => unreachable!("all is expanded before plugin doctor"), + } + } } diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/plugin_install/state.rs index 25fcac847..37be2035d 100644 --- a/crates/cli/src/plugin_install/state.rs +++ b/crates/cli/src/plugin_install/state.rs @@ -20,6 +20,7 @@ pub(super) struct PluginInstallOptions { pub(super) force: bool, pub(super) dry_run: bool, pub(super) skip_doctor: bool, + pub(super) json: bool, } #[derive(Debug, Clone, Copy)] @@ -156,7 +157,10 @@ pub(super) fn write_state( host_marketplace_removed: false, plugin_setup_installed: false, }, - layout.state_path.parent().unwrap_or_else(|| Path::new(".")), + layout + .state_path + .parent() + .expect("state_path should have a parent directory"), options, ) } diff --git a/crates/cli/src/plugin_shim/claude.rs b/crates/cli/src/plugin_shim/claude.rs index b7bb78218..f5f256469 100644 --- a/crates/cli/src/plugin_shim/claude.rs +++ b/crates/cli/src/plugin_shim/claude.rs @@ -66,7 +66,6 @@ pub(super) fn claude_provider( backup.display() ); } else { - remove_backup(&path)?; println!( "current Claude provider routing is not managed by Relay; left {} unchanged", path.display() diff --git a/crates/cli/src/plugin_shim/codex.rs b/crates/cli/src/plugin_shim/codex.rs index 8f2c7e9b6..873cd58e4 100644 --- a/crates/cli/src/plugin_shim/codex.rs +++ b/crates/cli/src/plugin_shim/codex.rs @@ -180,7 +180,7 @@ pub(super) fn uninstall_codex_config( { providers.remove("nemo-relay-openai"); } - if !preserve_hooks { + if provider_is_managed && !preserve_hooks { remove_table_item_if_bool(&mut doc, "features", "hooks", true); } } diff --git a/crates/cli/src/plugin_shim/mod.rs b/crates/cli/src/plugin_shim/mod.rs index fc969e391..fa9c7dbb2 100644 --- a/crates/cli/src/plugin_shim/mod.rs +++ b/crates/cli/src/plugin_shim/mod.rs @@ -14,6 +14,8 @@ use std::env; use std::io::{Read, Write}; use std::process::{Command, ExitCode}; +use serde_json::{Value, json}; + use claude::{claude_provider, claude_settings_base_url}; use codex::{codex_hooks_installed, codex_provider_installed, install_codex, uninstall_codex}; use command::{ @@ -145,6 +147,52 @@ pub(crate) fn doctor_plugin(agent: CodingAgent, gateway_url: &str) -> Result<(), } } +pub(crate) fn doctor_plugin_json(agent: CodingAgent, gateway_url: &str) -> Result { + let plugin_binary = current_exe().ok().is_some_and(|path| path.exists()); + let sidecar_running = healthz(gateway_url); + let (checks, ok) = match agent { + CodingAgent::ClaudeCode => { + let provider = claude_settings_base_url().as_deref() == Some(gateway_url); + ( + json!({ + "plugin_binary": plugin_binary, + "sidecar_running": sidecar_running, + "claude_provider_routing": provider + }), + plugin_binary && provider, + ) + } + CodingAgent::Codex => { + let provider = codex_provider_installed(gateway_url); + let hooks = codex_hooks_installed(gateway_url)?; + ( + json!({ + "plugin_binary": plugin_binary, + "sidecar_running": sidecar_running, + "codex_provider_alias": provider, + "codex_hooks": hooks + }), + plugin_binary && provider && hooks, + ) + } + other => { + return Err(format!( + "plugin doctor supports claude and codex, got {}", + other.as_arg() + )); + } + }; + Ok(json!({ + "ok": ok, + "sidecar_health": if sidecar_running { + "running" + } else { + "not_running_lazy_start" + }, + "checks": checks + })) +} + fn doctor_ok(agent: CodingAgent, gateway_url: &str) -> Result { let mut ok = true; ok &= print_check( diff --git a/crates/cli/src/plugin_shim/shared.rs b/crates/cli/src/plugin_shim/shared.rs index fd236b7c9..70cae8d49 100644 --- a/crates/cli/src/plugin_shim/shared.rs +++ b/crates/cli/src/plugin_shim/shared.rs @@ -12,6 +12,7 @@ use std::process::{Command, ExitCode, Stdio}; use std::thread; use std::time::Duration; +use reqwest::Url; use serde_json::{Value, json}; use toml_edit::{DocumentMut, Item, Table}; @@ -25,7 +26,7 @@ pub(super) fn ensure_sidecar(agent: CodingAgent, url: &str) { } let runtime = runtime_dir(); let _ = fs::create_dir_all(&runtime); - let lock = runtime.join(format!("{}-sidecar.lock", agent.as_arg())); + let lock = runtime.join(format!("{}-sidecar.lock", sidecar_lock_name(url))); let mut acquired = false; for _ in 0..40 { match fs::create_dir(&lock) { @@ -514,14 +515,73 @@ pub(super) fn current_exe() -> Result { } pub(super) fn runtime_dir() -> PathBuf { - env::var_os("XDG_RUNTIME_DIR") - .or_else(|| env::var_os("TMPDIR")) - .or_else(|| env::var_os("TEMP")) - .map(PathBuf::from) - .unwrap_or_else(env::temp_dir) + runtime_dir_for( + env::var_os("XDG_RUNTIME_DIR"), + env::var_os("TMPDIR"), + env::var_os("TEMP"), + env::temp_dir(), + env::var_os("USER"), + env::var_os("USERNAME"), + ) +} + +pub(super) fn runtime_dir_for( + xdg_runtime_dir: Option, + tmpdir: Option, + temp: Option, + temp_dir: PathBuf, + user: Option, + username: Option, +) -> PathBuf { + if let Some(base) = xdg_runtime_dir.or(tmpdir).or(temp) { + return PathBuf::from(base).join("nemo-relay-plugin"); + } + temp_dir + .join(runtime_user_segment(user, username)) .join("nemo-relay-plugin") } +pub(super) fn sidecar_lock_name(url: &str) -> String { + let raw = Url::parse(url) + .ok() + .and_then(|parsed| { + let host = parsed.host_str()?; + let port = parsed.port_or_known_default()?; + Some(format!("{host}-{port}")) + }) + .unwrap_or_else(|| url.to_string()); + sanitize_filesystem_segment(&raw) +} + +fn runtime_user_segment( + user: Option, + username: Option, +) -> String { + let raw = user + .or(username) + .and_then(|value| value.into_string().ok()) + .unwrap_or_else(|| "unknown-user".into()); + sanitize_filesystem_segment(&raw) +} + +fn sanitize_filesystem_segment(raw: &str) -> String { + let sanitized: String = raw + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { + character + } else { + '_' + } + }) + .collect(); + if sanitized.is_empty() { + "unknown".into() + } else { + sanitized + } +} + pub(super) fn plugin_idle_timeout() -> String { env::var("NEMO_RELAY_PLUGIN_IDLE_TIMEOUT_SECS").unwrap_or_else(|_| "300".into()) } diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index c3acd993c..354371fa3 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -379,16 +379,20 @@ fn cli_doctor_plugin_help_accepts_plugin_flag() { } #[test] -fn cli_doctor_plugin_rejects_json_until_plugin_json_is_supported() { +fn cli_doctor_plugin_accepts_json_flag() { + let temp = tempfile::tempdir().unwrap(); let output = Command::new(gateway_bin()) - .args(["doctor", "--plugin", "codex", "--json"]) + .args(["doctor", "--plugin", "codex", "--json", "--install-dir"]) + .arg(temp.path()) .output() .unwrap(); assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("--json"), "stderr was:\n{stderr}"); - assert!(stderr.contains("--plugin"), "stderr was:\n{stderr}"); + assert!( + !stderr.contains("cannot be used with"), + "stderr was:\n{stderr}" + ); } #[test] diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index c8dddba55..be44e46f8 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -112,6 +112,18 @@ impl PluginSetupRunner for MockSetupRunner { fn doctor(&self, host: PluginHost, gateway_url: &str) -> Result<(), String> { self.record(format!("doctor {} {gateway_url}", host_arg(host))) } + + fn doctor_json( + &self, + host: PluginHost, + gateway_url: &str, + ) -> Result { + self.record(format!("doctor-json {} {gateway_url}", host_arg(host)))?; + Ok(json!({ + "ok": true, + "checks": {} + })) + } } impl MockSetupRunner { @@ -131,6 +143,7 @@ fn options(dir: &Path) -> PluginInstallOptions { force: false, dry_run: false, skip_doctor: true, + json: false, } } @@ -575,6 +588,31 @@ fn plugin_registration_failure_rolls_back_marketplace_without_plugin_removal() { assert!(setup_runner.calls().is_empty()); } +#[test] +fn state_write_failure_removes_generated_marketplace() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + std::fs::create_dir_all(&layout.state_path).unwrap(); + + let error = install_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap_err(); + + assert!(error.contains("failed to write")); + assert!(!layout.marketplace_root.exists()); + assert!(layout.state_path.exists()); + assert!(runner.commands().is_empty()); + assert!(setup_runner.calls().is_empty()); +} + #[test] fn retry_after_partial_registration_rollback_does_not_restore_uninstalled_setup() { let dir = tempdir().unwrap(); @@ -698,6 +736,53 @@ fn uninstall_uses_installed_state_and_removes_marketplace() { ); } +#[test] +fn uninstall_continues_when_relay_is_missing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default().with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let layout = PluginLayout::new(PluginHost::Codex, dir.path()); + write_installed_state(PluginHost::Codex, dir.path()); + + uninstall_host( + PluginHost::Codex, + &options(dir.path()), + &runner, + &setup_runner, + ) + .unwrap(); + + assert!(!layout.marketplace_root.exists()); + assert!(!layout.state_path.exists()); + assert!( + setup_runner + .calls() + .iter() + .any(|call| call == &format!("uninstall codex {DEFAULT_GATEWAY_URL}")) + ); +} + +#[test] +fn doctor_json_uses_quiet_plugin_report() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("codex", "/bin/codex"); + let setup_runner = MockSetupRunner::default(); + let options = PluginInstallOptions { + json: true, + ..options(dir.path()) + }; + write_installed_state(PluginHost::Codex, dir.path()); + + doctor_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + + assert_eq!( + setup_runner.calls(), + vec![format!("doctor-json codex {DEFAULT_GATEWAY_URL}")] + ); +} + #[test] fn uninstall_host_failure_does_not_restore_plugin_setup() { let dir = tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_shim_tests.rs index e0ce69036..8e6c9e284 100644 --- a/crates/cli/tests/coverage/plugin_shim_tests.rs +++ b/crates/cli/tests/coverage/plugin_shim_tests.rs @@ -535,6 +535,27 @@ supports_websockets = false assert!(updated.contains("base_url = \"http://127.0.0.1:49999\"")); } +#[test] +fn codex_uninstall_without_backup_preserves_user_hooks_when_provider_is_not_managed() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + r#" +model_provider = "openai" + +[features] +hooks = true +"#, + ) + .unwrap(); + + uninstall_codex_config(&path, DEFAULT_URL, false).unwrap(); + let updated = fs::read_to_string(&path).unwrap(); + + assert!(updated.contains("hooks = true")); +} + #[test] fn codex_uninstall_preserves_hooks_feature_when_user_hooks_remain() { let dir = tempdir().unwrap(); @@ -764,7 +785,7 @@ fn claude_restore_with_backup_preserves_user_changed_provider_url() { json_env_string(&updated, "ANTHROPIC_BASE_URL"), Some("http://127.0.0.1:49999") ); - assert!(!backup_path(&settings).exists()); + assert!(backup_path(&settings).exists()); } #[test] @@ -825,6 +846,55 @@ fn stale_lock_is_repaired_after_grace_period_even_when_pid_file_exists() { assert!(!lock.exists()); } +#[test] +fn sidecar_lock_name_uses_gateway_host_and_port() { + assert_eq!( + sidecar_lock_name("http://127.0.0.1:47632/hooks"), + "127.0.0.1-47632" + ); + assert_eq!(sidecar_lock_name("http://localhost"), "localhost-80"); + assert_eq!( + sidecar_lock_name("not a url/with spaces"), + "not_a_url_with_spaces" + ); +} + +#[test] +fn runtime_dir_fallback_is_user_scoped() { + let runtime = runtime_dir_for( + None, + None, + None, + std::path::PathBuf::from("/tmp"), + Some("alice/example".into()), + None, + ); + + assert_eq!( + runtime, + std::path::PathBuf::from("/tmp") + .join("alice_example") + .join("nemo-relay-plugin") + ); +} + +#[test] +fn runtime_dir_prefers_explicit_runtime_base_without_user_segment() { + let runtime = runtime_dir_for( + Some("/run/user/1000".into()), + None, + None, + std::path::PathBuf::from("/tmp"), + Some("alice".into()), + None, + ); + + assert_eq!( + runtime, + std::path::PathBuf::from("/run/user/1000").join("nemo-relay-plugin") + ); +} + #[test] fn codex_hook_command_uses_cmd_quoting_for_windows_paths() { let relay = std::path::PathBuf::from(r"C:\Program Files\NeMo 100%\bin\nemo-relay.exe"); diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index 15b52ae0e..ebd13a371 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -22,8 +22,9 @@ environment variables, or shared TOML config. ## Packages -- `claude-code/` is a Claude Code plugin package. It installs hook entries - targeting `POST /hooks/claude-code` through `nemo-relay` on `PATH`. +- `claude-code/` is a Claude Code plugin package. The + `nemo-relay install claude-code` command installs hook entries targeting + `POST /hooks/claude-code` through `nemo-relay` on `PATH`. - `codex/` is a Codex plugin package. `nemo-relay install codex` creates the marketplace, installs the plugin, enables `features.hooks = true`, and configures a local `nemo-relay-openai` provider alias. Codex plugin delivery From da90376daaa694f8ef7b19247cb390453ec4c92a Mon Sep 17 00:00:00 2001 From: Will Killian Date: Tue, 9 Jun 2026 13:01:56 -0500 Subject: [PATCH 4/7] fix: remove dead plugin doctor json branch Signed-off-by: Will Killian --- crates/cli/src/plugin_install/mod.rs | 12 ------------ crates/cli/src/plugin_install/state.rs | 1 - crates/cli/tests/coverage/plugin_install_tests.rs | 10 ++++------ 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index b23c55d3c..914ef76bf 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -46,7 +46,6 @@ pub(crate) fn install(command: InstallCommand) -> Result { force: command.force, dry_run: command.dry_run, skip_doctor: command.skip_doctor, - json: false, }; run_for_hosts( command.host, @@ -65,7 +64,6 @@ pub(crate) fn uninstall(command: UninstallCommand) -> Result force: false, dry_run: command.dry_run, skip_doctor: true, - json: false, }; run_for_hosts( command.host, @@ -87,7 +85,6 @@ pub(crate) fn doctor( force: false, dry_run: false, skip_doctor: true, - json, }; if json { return doctor_json(host, &options); @@ -312,15 +309,6 @@ fn doctor_host( validate_relay_plugin_shim(&relay, options, runner)?; let state = read_state(host, &options.install_dir) .ok_or_else(|| format!("no installed {} plugin state found", host_label(host)))?; - if options.json { - print_json(&with_schema(doctor_host_json_value( - host, - options, - runner, - setup_runner, - )?))?; - return Ok(()); - } println!("nemo-relay: {}", relay.display()); println!("host: {}", host_arg(host)); println!("marketplace: {}", state.marketplace_root.display()); diff --git a/crates/cli/src/plugin_install/state.rs b/crates/cli/src/plugin_install/state.rs index 37be2035d..2ceaed749 100644 --- a/crates/cli/src/plugin_install/state.rs +++ b/crates/cli/src/plugin_install/state.rs @@ -20,7 +20,6 @@ pub(super) struct PluginInstallOptions { pub(super) force: bool, pub(super) dry_run: bool, pub(super) skip_doctor: bool, - pub(super) json: bool, } #[derive(Debug, Clone, Copy)] diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index be44e46f8..ccc979a27 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -143,7 +143,6 @@ fn options(dir: &Path) -> PluginInstallOptions { force: false, dry_run: false, skip_doctor: true, - json: false, } } @@ -769,18 +768,17 @@ fn doctor_json_uses_quiet_plugin_report() { .with_executable("nemo-relay", "/bin/nemo-relay") .with_executable("codex", "/bin/codex"); let setup_runner = MockSetupRunner::default(); - let options = PluginInstallOptions { - json: true, - ..options(dir.path()) - }; + let options = options(dir.path()); write_installed_state(PluginHost::Codex, dir.path()); - doctor_host(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); + let report = + doctor_host_json_value(PluginHost::Codex, &options, &runner, &setup_runner).unwrap(); assert_eq!( setup_runner.calls(), vec![format!("doctor-json codex {DEFAULT_GATEWAY_URL}")] ); + assert_eq!(report["host"], json!("codex")); } #[test] From af66400c63d24ff99da79074a0ca45cef287e680 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Wed, 10 Jun 2026 16:15:27 -0400 Subject: [PATCH 5/7] fix: harden claude plugin install checks Signed-off-by: Will Killian --- crates/cli/src/alignment/claude_code.rs | 6 +- crates/cli/src/alignment/mod.rs | 14 + crates/cli/src/plugin_install/host.rs | 240 ++++++++++++++++++ crates/cli/src/plugin_install/mod.rs | 11 +- crates/cli/src/plugin_shim/claude.rs | 19 +- crates/cli/src/session.rs | 26 +- .../coverage/alignment_claude_code_tests.rs | 2 +- .../tests/coverage/plugin_install_tests.rs | 144 ++++++++++- .../cli/tests/coverage/plugin_shim_tests.rs | 49 ++++ crates/cli/tests/coverage/server_tests.rs | 168 ++++++++++++ crates/cli/tests/coverage/session_tests.rs | 112 ++++++++ 11 files changed, 776 insertions(+), 15 deletions(-) diff --git a/crates/cli/src/alignment/claude_code.rs b/crates/cli/src/alignment/claude_code.rs index 268715229..50880790e 100644 --- a/crates/cli/src/alignment/claude_code.rs +++ b/crates/cli/src/alignment/claude_code.rs @@ -30,8 +30,7 @@ pub(crate) fn session_id_from_headers(headers: &HeaderMap) -> Option { // Claude Code can issue a tiny pre-user startup probe through the Anthropic gateway before the // first UserPromptSubmit hook. Treating it as normal LLM work pollutes traces with an unparented -// `user: test` span, so alignment classifies only this native-header plus exact-body harness probe -// for suppression. +// `user: test` span, so alignment classifies only this exact-body harness probe for suppression. pub(crate) fn is_startup_probe( provider: &str, model_name: Option<&str>, @@ -40,9 +39,6 @@ pub(crate) fn is_startup_probe( if provider != "anthropic.messages" { return false; } - if !request.headers.contains_key("x-claude-code-session-id") { - return false; - } let model = model_name .or_else(|| request.content.get("model").and_then(Value::as_str)) .unwrap_or_default(); diff --git a/crates/cli/src/alignment/mod.rs b/crates/cli/src/alignment/mod.rs index 3d32a46ad..bf7213c4b 100644 --- a/crates/cli/src/alignment/mod.rs +++ b/crates/cli/src/alignment/mod.rs @@ -569,6 +569,20 @@ pub(crate) fn request_affinity_key(request: &LlmRequest) -> Option { .then(|| truncate_affinity_text(&normalized, REQUEST_AFFINITY_KEY_MAX_CHARS)) } +// Builds a non-null turn input when a direct gateway request arrives before the prompt hook. This +// is intentionally limited to Claude-owned Anthropic Messages requests because Claude installed +// mode is the path where the provider request can race the `UserPromptSubmit` hook. +pub(crate) fn gateway_turn_input( + agent_kind: AgentKind, + provider: &str, + request: &LlmRequest, +) -> Option { + if agent_kind != AgentKind::ClaudeCode || provider != "anthropic.messages" { + return None; + } + request_user_task_text(&request.content).map(|prompt| json!({ "prompt": prompt })) +} + // Detects tool results that imply a subagent completed. Claude Code reports this through the // `Agent` tool today; keeping the check here avoids leaking that tool shape into session teardown. pub(crate) fn completed_subagent_from_tool(event: &ToolEvent) -> Option { diff --git a/crates/cli/src/plugin_install/host.rs b/crates/cli/src/plugin_install/host.rs index 58cf44216..a6f5a87e9 100644 --- a/crates/cli/src/plugin_install/host.rs +++ b/crates/cli/src/plugin_install/host.rs @@ -7,6 +7,8 @@ use std::env; use std::path::{Path, PathBuf}; use std::process::Command; +use serde_json::{Value, json}; + use crate::config::PluginHost; use super::state::{PluginInstallOptions, PluginLayout}; @@ -108,6 +110,168 @@ pub(super) fn run_host_marketplace_removal( ) } +#[derive(Debug, Clone)] +pub(super) struct HostRegistrationReport { + pub(super) host_plugin_registered: bool, + pub(super) host_marketplace_registered: bool, +} + +impl HostRegistrationReport { + pub(super) fn ok(&self) -> bool { + self.host_plugin_registered && self.host_marketplace_registered + } + + pub(super) fn to_json(&self) -> Value { + json!({ + "ok": self.ok(), + "host_plugin_registered": self.host_plugin_registered, + "host_marketplace_registered": self.host_marketplace_registered + }) + } +} + +pub(super) fn validate_host_registration( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let report = host_registration_report(host, options, runner)?; + if report.ok() { + Ok(report) + } else { + let mut missing = Vec::new(); + if !report.host_plugin_registered { + missing.push(format!("{PLUGIN_NAME}@{MARKETPLACE_NAME} host plugin")); + } + if !report.host_marketplace_registered { + missing.push(format!("{MARKETPLACE_NAME} host marketplace")); + } + Err(format!( + "{} plugin host registration is incomplete: missing {}", + host_cli(host), + missing.join(", ") + )) + } +} + +pub(super) fn host_registration_report( + host: PluginHost, + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + if options.dry_run { + return Ok(HostRegistrationReport { + host_plugin_registered: true, + host_marketplace_registered: true, + }); + } + require_host_cli(host, options, runner)?; + Ok(match host { + PluginHost::ClaudeCode => HostRegistrationReport { + host_plugin_registered: claude_plugin_registered(options, runner)?, + host_marketplace_registered: claude_marketplace_registered(options, runner)?, + }, + PluginHost::Codex => HostRegistrationReport { + host_plugin_registered: codex_plugin_registered(options, runner)?, + host_marketplace_registered: codex_marketplace_registered(options, runner)?, + }, + PluginHost::All => unreachable!("all is expanded before host registration checks"), + }) +} + +fn claude_plugin_registered( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let output = run_capture_command( + "claude", + &["plugin".into(), "list".into(), "--json".into()], + options, + runner, + )?; + let plugins = parse_json_command_output("claude plugin list --json", output)?; + Ok(plugins + .as_array() + .is_some_and(|plugins| plugins.iter().any(plugin_entry_matches))) +} + +fn claude_marketplace_registered( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let output = run_capture_command( + "claude", + &[ + "plugin".into(), + "marketplace".into(), + "list".into(), + "--json".into(), + ], + options, + runner, + )?; + let marketplaces = parse_json_command_output("claude plugin marketplace list --json", output)?; + Ok(marketplaces + .as_array() + .is_some_and(|marketplaces| marketplaces.iter().any(marketplace_entry_matches))) +} + +fn codex_plugin_registered( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let output = run_capture_command( + "codex", + &["plugin".into(), "list".into(), "--json".into()], + options, + runner, + )?; + let plugins = parse_json_command_output("codex plugin list --json", output)?; + Ok(plugins + .get("installed") + .and_then(Value::as_array) + .is_some_and(|plugins| plugins.iter().any(plugin_entry_matches))) +} + +fn codex_marketplace_registered( + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + let output = run_capture_command( + "codex", + &["plugin".into(), "marketplace".into(), "list".into()], + options, + runner, + )?; + Ok(output + .stdout + .lines() + .filter_map(|line| line.split_whitespace().next()) + .any(|name| name == MARKETPLACE_NAME)) +} + +fn plugin_entry_matches(entry: &Value) -> bool { + let plugin_id = format!("{PLUGIN_NAME}@{MARKETPLACE_NAME}"); + string_field(entry, "id") == Some(plugin_id.as_str()) + || string_field(entry, "pluginId") == Some(plugin_id.as_str()) + || (string_field(entry, "name") == Some(PLUGIN_NAME) + && string_field(entry, "marketplaceName") == Some(MARKETPLACE_NAME)) +} + +fn marketplace_entry_matches(entry: &Value) -> bool { + string_field(entry, "name") == Some(MARKETPLACE_NAME) + || string_field(entry, "id") == Some(MARKETPLACE_NAME) +} + +fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn parse_json_command_output(command: &str, output: CommandOutput) -> Result { + serde_json::from_str::(&output.stdout) + .map_err(|error| format!("failed to parse `{command}` output as JSON: {error}")) +} + pub(super) fn require_relay( options: &PluginInstallOptions, runner: &dyn CommandRunner, @@ -192,6 +356,38 @@ pub(super) fn run_path_command( } } +pub(super) fn run_capture_command( + program: &str, + args: &[String], + options: &PluginInstallOptions, + runner: &dyn CommandRunner, +) -> Result { + if options.dry_run { + println!("{}", format_command(program, args)); + // Keep dry-run capture output syntactically valid for future callers that parse stdout. + return Ok(CommandOutput::success("null\n".into())); + } + let resolved = runner + .resolve_executable(program)? + .ok_or_else(|| format!("required `{program}` executable was not found on PATH"))?; + let output = runner.run_capture(&resolved, args)?; + if output.status == 0 { + Ok(output) + } else { + let stderr = output.stderr.trim(); + let detail = if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + }; + Err(format!( + "{} failed with exit code {}{detail}", + format_command(&resolved.display().to_string(), args), + output.status + )) + } +} + pub(super) fn format_command(program: &str, args: &[String]) -> String { let mut parts = vec![program.to_string()]; parts.extend(args.iter().cloned()); @@ -223,10 +419,28 @@ fn shell_quote(raw: &str) -> String { } } +#[derive(Debug, Clone)] +pub(super) struct CommandOutput { + pub(super) status: i32, + pub(super) stdout: String, + pub(super) stderr: String, +} + +impl CommandOutput { + pub(super) fn success(stdout: String) -> Self { + Self { + status: 0, + stdout, + stderr: String::new(), + } + } +} + pub(super) trait CommandRunner { fn resolve_executable(&self, command: &str) -> Result, String>; fn run(&self, program: &Path, args: &[String]) -> Result; fn run_quiet(&self, program: &Path, args: &[String]) -> Result; + fn run_capture(&self, program: &Path, args: &[String]) -> Result; } pub(super) struct RealCommandRunner; @@ -277,6 +491,32 @@ impl CommandRunner for RealCommandRunner { .map_err(|error| format!("failed to run {}: {error}", program.display()))?; Ok(status.code().unwrap_or(1)) } + + fn run_capture(&self, program: &Path, args: &[String]) -> Result { + #[cfg(windows)] + if is_windows_command_script(program) { + let output = Command::new(env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())) + .args(["/d", "/s", "/c"]) + .arg(windows_command_line(program, args)) + .output() + .map_err(|error| format!("failed to run {}: {error}", program.display()))?; + return Ok(command_output(output)); + } + + let output = Command::new(program) + .args(args) + .output() + .map_err(|error| format!("failed to run {}: {error}", program.display()))?; + Ok(command_output(output)) + } +} + +fn command_output(output: std::process::Output) -> CommandOutput { + CommandOutput { + status: output.status.code().unwrap_or(1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + } } fn find_executable(command: &str) -> Option { diff --git a/crates/cli/src/plugin_install/mod.rs b/crates/cli/src/plugin_install/mod.rs index 914ef76bf..56b69cbf3 100644 --- a/crates/cli/src/plugin_install/mod.rs +++ b/crates/cli/src/plugin_install/mod.rs @@ -17,9 +17,9 @@ use crate::config::{InstallCommand, PluginHost, UninstallCommand}; use crate::error::CliError; use host::{ - CommandRunner, RealCommandRunner, require_host_cli, require_relay, + CommandRunner, RealCommandRunner, host_registration_report, require_host_cli, require_relay, run_host_marketplace_registration, run_host_marketplace_removal, run_host_plugin_registration, - run_host_plugin_removal, validate_relay_plugin_shim, + run_host_plugin_removal, validate_host_registration, validate_relay_plugin_shim, }; use marketplace::write_plugin_marketplace; use setup::{ @@ -313,6 +313,9 @@ fn doctor_host( println!("host: {}", host_arg(host)); println!("marketplace: {}", state.marketplace_root.display()); println!("plugin: {}", state.plugin_root.display()); + validate_host_registration(host, options, runner)?; + println!("host plugin registration: ok"); + println!("host marketplace registration: ok"); run_plugin_doctor(host, options, setup_runner) } @@ -326,12 +329,16 @@ fn doctor_host_json_value( validate_relay_plugin_shim(&relay, options, runner)?; let state = read_state(host, &options.install_dir) .ok_or_else(|| format!("no installed {} plugin state found", host_label(host)))?; + let host_registration = host_registration_report(host, options, runner)?; let plugin = run_plugin_doctor_json(host, setup_runner)?; + let ok = host_registration.ok() && plugin.get("ok").and_then(Value::as_bool).unwrap_or(false); Ok(json!({ + "ok": ok, "host": host_arg(host), "nemo_relay": relay, "marketplace": state.marketplace_root, "plugin": state.plugin_root, + "host_registration": host_registration.to_json(), "checks": plugin })) } diff --git a/crates/cli/src/plugin_shim/claude.rs b/crates/cli/src/plugin_shim/claude.rs index f5f256469..24751fadb 100644 --- a/crates/cli/src/plugin_shim/claude.rs +++ b/crates/cli/src/plugin_shim/claude.rs @@ -27,8 +27,11 @@ pub(super) fn claude_provider( return Err(format!("{} has a non-object env field", path.display())); } let backup_snapshot = snapshot_optional_file(&backup_path(&path))?; - if json_env_string(&settings, "ANTHROPIC_BASE_URL") != Some(gateway_url) { - backup_claude_settings(&path)?; + let managed_provider = + json_env_string(&settings, "ANTHROPIC_BASE_URL") == Some(gateway_url); + if !managed_provider && let Err(error) = backup_claude_settings(&path, true) { + restore_file_snapshot(&backup_snapshot)?; + return Err(error); } let env = settings .as_object_mut() @@ -135,12 +138,20 @@ pub(super) fn restore_json_env_value( Ok(()) } -pub(super) fn backup_claude_settings(path: &Path) -> Result<(), String> { +pub(super) fn backup_claude_settings(path: &Path, replace_existing: bool) -> Result<(), String> { let backup_file = backup_path(path); - if backup_file.exists() { + if backup_file.exists() && !replace_existing { return Ok(()); } if path.exists() { + if replace_existing && backup_file.exists() { + fs::remove_file(&backup_file).map_err(|error| { + format!( + "failed to remove stale backup {}: {error}", + backup_file.display() + ) + })?; + } backup(path) } else { if let Some(parent) = backup_file.parent() { diff --git a/crates/cli/src/session.rs b/crates/cli/src/session.rs index 9b43b67c4..b944cd559 100644 --- a/crates/cli/src/session.rs +++ b/crates/cli/src/session.rs @@ -104,6 +104,7 @@ struct Session { session_metadata: Value, agent_scope: Option, turn_scope: Option, + gateway_request_turn_open: bool, turn_index: u64, last_turn_llm_output: Option, subagents: HashMap, @@ -858,6 +859,7 @@ impl Session { session_metadata: Value::Null, agent_scope: None, turn_scope: None, + gateway_request_turn_open: false, turn_index: 0, last_turn_llm_output: None, subagents: HashMap::new(), @@ -962,7 +964,7 @@ impl Session { let stack = self.scope_stack.clone(); TASK_SCOPE_STACK .scope(stack.clone(), async move { - self.ensure_turn_started(Value::Null)?; + self.ensure_turn_started_for_gateway(&start)?; let mut attributes = LlmAttributes::empty(); if start.streaming { attributes |= LlmAttributes::STREAMING; @@ -1020,7 +1022,7 @@ impl Session { .scope(stack.clone(), async { let policy = self.gateway_management_policy(&start); if !policy.bypasses_managed_pipeline() { - self.ensure_turn_started(Value::Null)?; + self.ensure_turn_started_for_gateway(&start)?; } let mut attributes = LlmAttributes::empty(); if start.streaming { @@ -1111,6 +1113,10 @@ impl Session { return self.mark("prompt_submitted", event); } if self.turn_scope.is_some() { + if self.gateway_request_turn_open { + self.gateway_request_turn_open = false; + return self.mark("prompt_submitted", event); + } self.close_turn_for_reason("superseded_by_next_turn") .await?; } @@ -1127,6 +1133,20 @@ impl Session { self.open_turn(event_metadata, Value::Null, "implicit") } + fn ensure_turn_started_for_gateway(&mut self, start: &LlmGatewayStart) -> Result<(), CliError> { + if self.turn_scope.is_some() { + return Ok(()); + } + if let Some(input) = + alignment::gateway_turn_input(self.agent_kind, &start.provider, &start.request) + { + self.open_turn(start.metadata.clone(), input, "gateway_request")?; + self.gateway_request_turn_open = true; + return Ok(()); + } + self.open_turn(Value::Null, Value::Null, "implicit") + } + fn gateway_management_policy(&self, start: &LlmGatewayStart) -> GatewayManagementPolicy { if self.turn_scope.is_some() { return GatewayManagementPolicy::Managed; @@ -1166,6 +1186,7 @@ impl Session { .build(), )?; self.turn_scope = Some(scope); + self.gateway_request_turn_open = false; self.last_turn_llm_output = None; Ok(()) } @@ -1327,6 +1348,7 @@ impl Session { let Some(scope) = self.turn_scope.take() else { return Ok(()); }; + self.gateway_request_turn_open = false; pop_scope( PopScopeParams::builder() .handle_uuid(&scope.uuid) diff --git a/crates/cli/tests/coverage/alignment_claude_code_tests.rs b/crates/cli/tests/coverage/alignment_claude_code_tests.rs index 032453ece..aee9ffa66 100644 --- a/crates/cli/tests/coverage/alignment_claude_code_tests.rs +++ b/crates/cli/tests/coverage/alignment_claude_code_tests.rs @@ -83,7 +83,7 @@ fn startup_probe_matches_only_claude_code_preflight_shape() { headers: Default::default(), content: request.content.clone(), }; - assert!(!is_startup_probe( + assert!(is_startup_probe( "anthropic.messages", Some("claude-sonnet-4-5"), &missing_claude_header diff --git a/crates/cli/tests/coverage/plugin_install_tests.rs b/crates/cli/tests/coverage/plugin_install_tests.rs index ccc979a27..e6b27b185 100644 --- a/crates/cli/tests/coverage/plugin_install_tests.rs +++ b/crates/cli/tests/coverage/plugin_install_tests.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use serde_json::json; use tempfile::tempdir; +use super::host::CommandOutput; use super::*; #[derive(Default)] @@ -15,6 +16,8 @@ struct MockRunner { executables: HashMap, commands: RefCell>, quiet_commands: RefCell>, + capture_commands: RefCell>, + capture_outputs: HashMap, failing_suffix: Option, failing_suffixes: Vec, failing_quiet_suffix: Option, @@ -26,6 +29,12 @@ impl MockRunner { self } + fn with_capture_output(mut self, command: &str, stdout: impl Into) -> Self { + self.capture_outputs + .insert(command.into(), CommandOutput::success(stdout.into())); + self + } + fn commands(&self) -> Vec { self.commands.borrow().clone() } @@ -33,6 +42,10 @@ impl MockRunner { fn quiet_commands(&self) -> Vec { self.quiet_commands.borrow().clone() } + + fn capture_commands(&self) -> Vec { + self.capture_commands.borrow().clone() + } } impl CommandRunner for MockRunner { @@ -82,6 +95,23 @@ impl CommandRunner for MockRunner { }, ) } + + fn run_capture(&self, program: &Path, args: &[String]) -> Result { + let rendered = format!( + "{} {}", + program.display(), + args.iter() + .map(String::as_str) + .collect::>() + .join(" ") + ); + self.capture_commands.borrow_mut().push(rendered.clone()); + Ok(self + .capture_outputs + .get(&rendered) + .cloned() + .unwrap_or_else(|| CommandOutput::success(String::new()))) + } } fn command_matches_suffix(command: &str, suffix: Option<&str>) -> bool { @@ -766,7 +796,20 @@ fn doctor_json_uses_quiet_plugin_report() { let dir = tempdir().unwrap(); let runner = MockRunner::default() .with_executable("nemo-relay", "/bin/nemo-relay") - .with_executable("codex", "/bin/codex"); + .with_executable("codex", "/bin/codex") + .with_capture_output( + "/bin/codex plugin list --json", + json!({ + "installed": [ + { "pluginId": "nemo-relay-plugin@nemo-relay-local" } + ] + }) + .to_string(), + ) + .with_capture_output( + "/bin/codex plugin marketplace list", + "MARKETPLACE ROOT\nnemo-relay-local /tmp/nemo-relay-local\n", + ); let setup_runner = MockSetupRunner::default(); let options = options(dir.path()); write_installed_state(PluginHost::Codex, dir.path()); @@ -779,6 +822,105 @@ fn doctor_json_uses_quiet_plugin_report() { vec![format!("doctor-json codex {DEFAULT_GATEWAY_URL}")] ); assert_eq!(report["host"], json!("codex")); + assert_eq!(report["ok"], json!(true)); + assert_eq!(report["host_registration"]["ok"], json!(true)); + assert_eq!( + runner.capture_commands(), + vec![ + "/bin/codex plugin list --json", + "/bin/codex plugin marketplace list" + ] + ); +} + +#[test] +fn doctor_validates_claude_host_registration_before_setup_doctor() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_capture_output( + "/bin/claude plugin list --json", + json!([ + { "id": "nemo-relay-plugin@nemo-relay-local" } + ]) + .to_string(), + ) + .with_capture_output( + "/bin/claude plugin marketplace list --json", + json!([ + { "name": "nemo-relay-local" } + ]) + .to_string(), + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::ClaudeCode, dir.path()); + + doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap(); + + assert_eq!( + setup_runner.calls(), + vec![format!("doctor claude-code {DEFAULT_GATEWAY_URL}")] + ); + assert_eq!( + runner.capture_commands(), + vec![ + "/bin/claude plugin list --json", + "/bin/claude plugin marketplace list --json" + ] + ); +} + +#[test] +fn doctor_fails_when_claude_host_plugin_is_missing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_capture_output("/bin/claude plugin list --json", json!([]).to_string()) + .with_capture_output( + "/bin/claude plugin marketplace list --json", + json!([ + { "name": "nemo-relay-local" } + ]) + .to_string(), + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::ClaudeCode, dir.path()); + + let error = doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("nemo-relay-plugin@nemo-relay-local")); + assert!(setup_runner.calls().is_empty()); +} + +#[test] +fn doctor_fails_when_claude_host_marketplace_is_missing() { + let dir = tempdir().unwrap(); + let runner = MockRunner::default() + .with_executable("nemo-relay", "/bin/nemo-relay") + .with_executable("claude", "/bin/claude") + .with_capture_output( + "/bin/claude plugin list --json", + json!([ + { "id": "nemo-relay-plugin@nemo-relay-local" } + ]) + .to_string(), + ) + .with_capture_output( + "/bin/claude plugin marketplace list --json", + json!([]).to_string(), + ); + let setup_runner = MockSetupRunner::default(); + let options = options(dir.path()); + write_installed_state(PluginHost::ClaudeCode, dir.path()); + + let error = doctor_host(PluginHost::ClaudeCode, &options, &runner, &setup_runner).unwrap_err(); + + assert!(error.contains("nemo-relay-local host marketplace")); + assert!(setup_runner.calls().is_empty()); } #[test] diff --git a/crates/cli/tests/coverage/plugin_shim_tests.rs b/crates/cli/tests/coverage/plugin_shim_tests.rs index 8e6c9e284..57c5035f0 100644 --- a/crates/cli/tests/coverage/plugin_shim_tests.rs +++ b/crates/cli/tests/coverage/plugin_shim_tests.rs @@ -788,6 +788,55 @@ fn claude_restore_with_backup_preserves_user_changed_provider_url() { assert!(backup_path(&settings).exists()); } +#[test] +fn claude_reinstall_refreshes_backup_after_user_owned_restore() { + let dir = tempdir().unwrap(); + let _home = HomeScope::enter(dir.path()); + let settings = dir.path().join(".claude").join("settings.json"); + fs::create_dir_all(settings.parent().unwrap()).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.anthropic.com" + } + })) + .unwrap(), + ) + .unwrap(); + + claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + fs::write( + &settings, + serde_json::to_vec_pretty(&json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://custom.example" + } + })) + .unwrap(), + ) + .unwrap(); + claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + assert!(backup_path(&settings).exists()); + + claude_provider(PluginShimProviderAction::Enable, DEFAULT_URL).unwrap(); + let refreshed_backup: Value = + serde_json::from_str(&fs::read_to_string(backup_path(&settings)).unwrap()).unwrap(); + assert_eq!( + json_env_string(&refreshed_backup, "ANTHROPIC_BASE_URL"), + Some("https://custom.example") + ); + + claude_provider(PluginShimProviderAction::Restore, DEFAULT_URL).unwrap(); + + let updated: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + json_env_string(&updated, "ANTHROPIC_BASE_URL"), + Some("https://custom.example") + ); + assert!(!backup_path(&settings).exists()); +} + #[test] fn claude_reinstall_uses_fresh_backup_after_prior_restore() { let dir = tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index 2367fefb2..d706b68a6 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -1999,6 +1999,174 @@ async fn gateway_forwards_claude_startup_probe_without_llm_observability() { ); } +#[tokio::test] +async fn gateway_suppresses_claude_startup_probe_without_native_session_header() { + let subscriber_name = "server-claude-startup-probe-no-native-header-test"; + let _ = deregister_subscriber(subscriber_name); + let captured_events = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = captured_events.clone(); + register_subscriber( + subscriber_name, + Arc::new(move |event| { + let session_id = event + .metadata() + .and_then(|metadata| metadata.get("session_id")) + .and_then(Value::as_str); + let is_probe_llm = event.scope_category() == Some(ScopeCategory::Start) + && event.name() == "anthropic.messages" + && event + .input() + .and_then(|input| input.get("model")) + .and_then(Value::as_str) + == Some("claude-opus-4-8[1m]"); + let is_probe_turn = event.scope_category() == Some(ScopeCategory::Start) + && event.name() == "claude-code-turn" + && session_id == Some("claude-probe-no-native-header"); + if is_probe_llm || is_probe_turn { + captured.lock().unwrap().push(json!({ + "name": event.name(), + "input": event.input().cloned().unwrap_or(Value::Null), + "metadata": event.metadata().cloned().unwrap_or(Value::Null) + })); + } + }), + ) + .unwrap(); + let _subscriber_cleanup = SubscriberCleanup(subscriber_name); + + let upstream = spawn_anthropic_upstream().await; + let mut config = test_config(); + config.anthropic_base_url = upstream.url(); + let app = router(config); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/messages") + .header("content-type", "application/json") + .header("x-api-key", "sk-ant-test") + .header("x-nemo-relay-session-id", "claude-probe-no-native-header") + .body(Body::from( + json!({ + "model": "claude-opus-4-8[1m]", + "max_tokens": 1, + "messages": [ + { + "role": "user", + "content": "test" + } + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + flush_subscribers().unwrap(); + assert!( + captured_events.lock().unwrap().is_empty(), + "no-native-header Claude startup probe must not emit managed LLM or null-input turn events" + ); +} + +#[tokio::test] +async fn direct_claude_gateway_request_before_prompt_hook_uses_request_turn_input() { + let subscriber_name = "server-claude-direct-gateway-turn-input-test"; + let _ = deregister_subscriber(subscriber_name); + let captured_turn_starts = Arc::new(std::sync::Mutex::new(Vec::::new())); + let captured = captured_turn_starts.clone(); + register_subscriber( + subscriber_name, + Arc::new(move |event| { + if event.scope_category() == Some(ScopeCategory::Start) + && event.name() == "claude-code-turn" + && event + .metadata() + .and_then(|metadata| metadata.get("session_id")) + .and_then(Value::as_str) + == Some("claude-direct-installed") + { + captured.lock().unwrap().push(json!({ + "input": event.input().cloned().unwrap_or(Value::Null), + "metadata": event.metadata().cloned().unwrap_or(Value::Null) + })); + } + }), + ) + .unwrap(); + let _subscriber_cleanup = SubscriberCleanup(subscriber_name); + + let upstream = spawn_anthropic_upstream().await; + let mut config = test_config(); + config.anthropic_base_url = upstream.url(); + let app = router(config); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/messages") + .header("content-type", "application/json") + .header("x-api-key", "sk-ant-test") + .header("x-claude-code-session-id", "claude-direct-installed") + .body(Body::from( + json!({ + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "user", + "content": "inspect direct installed mode" + } + ] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/hooks/claude-code") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "session_id": "claude-direct-installed", + "hook_event_name": "UserPromptSubmit", + "prompt": "inspect direct installed mode" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + flush_subscribers().unwrap(); + let starts = captured_turn_starts.lock().unwrap().clone(); + assert_eq!( + starts.len(), + 1, + "later UserPromptSubmit must not open a duplicate Claude turn: {starts:#?}" + ); + assert_eq!( + starts[0]["input"], + json!({ "prompt": "inspect direct installed mode" }) + ); + assert_eq!( + starts[0]["metadata"]["turn_source"], + json!("gateway_request") + ); +} + async fn wait_for_gateway(url: &str) { let client = test_http_client(); for _ in 0..50 { diff --git a/crates/cli/tests/coverage/session_tests.rs b/crates/cli/tests/coverage/session_tests.rs index a14d49a3e..aea67e1c0 100644 --- a/crates/cli/tests/coverage/session_tests.rs +++ b/crates/cli/tests/coverage/session_tests.rs @@ -3788,6 +3788,118 @@ async fn claude_startup_probe_only_session_is_pruned_after_finish() { ); } +#[tokio::test] +async fn claude_direct_gateway_request_seeds_turn_input_before_prompt_hook() { + let subscriber_name = "cli-claude-direct-gateway-turn-input-test"; + let _ = deregister_subscriber(subscriber_name); + let captured_events = Arc::new(StdMutex::new(Vec::::new())); + let captured = captured_events.clone(); + register_subscriber( + subscriber_name, + Arc::new(move |event| { + let event_session_id = event + .metadata() + .and_then(|metadata| metadata.get("session_id")) + .and_then(Value::as_str); + if event.name() == "prompt_submitted" + && event.data().cloned().unwrap_or(Value::Null) + == json!({ "prompt": "inspect direct mode" }) + { + captured.lock().unwrap().push(json!({ + "kind": "prompt_mark", + "data": event.data().cloned().unwrap_or(Value::Null), + "metadata": event.metadata().cloned().unwrap_or(Value::Null) + })); + return; + } + if event_session_id != Some("claude-direct-race") { + return; + } + if event.scope_category() == Some(ScopeCategory::Start) + && event.name() == "claude-code-turn" + { + captured.lock().unwrap().push(json!({ + "kind": "turn_start", + "input": event.input().cloned().unwrap_or(Value::Null), + "metadata": event.metadata().cloned().unwrap_or(Value::Null) + })); + } + }), + ) + .unwrap(); + + let manager = SessionManager::new(session_test_config()); + let prep = manager + .prepare_gateway_call( + &HeaderMap::new(), + LlmGatewayStart { + session_id: Some("claude-direct-race".into()), + provider: "anthropic.messages".into(), + model_name: Some("claude-sonnet-4-5".into()), + request: LlmRequest { + headers: Map::new(), + content: json!({ + "model": "claude-sonnet-4-5", + "messages": [ + { + "role": "user", + "content": "inspect direct mode" + } + ] + }), + }, + metadata: json!({ "gateway_path": "/v1/messages" }), + ..llm_start() + }, + ) + .await + .unwrap(); + assert!(!prep.bypass_managed_pipeline); + manager + .finish_gateway_call(&prep.session_id, prep.prune_empty_session_on_finish) + .await; + + manager + .apply_events( + &HeaderMap::new(), + vec![NormalizedEvent::PromptSubmitted(SessionEvent { + session_id: "claude-direct-race".into(), + agent_kind: AgentKind::ClaudeCode, + event_name: "UserPromptSubmit".into(), + payload: json!({ "prompt": "inspect direct mode" }), + metadata: json!({}), + })], + ) + .await + .unwrap(); + + flush_subscribers().unwrap(); + let events = captured_events.lock().unwrap().clone(); + let turn_starts = events + .iter() + .filter(|event| event["kind"] == json!("turn_start")) + .collect::>(); + assert_eq!( + turn_starts.len(), + 1, + "later UserPromptSubmit must not create a duplicate turn: {events:#?}" + ); + assert_eq!( + turn_starts[0]["input"], + json!({ "prompt": "inspect direct mode" }) + ); + assert_eq!( + turn_starts[0]["metadata"]["turn_source"], + json!("gateway_request") + ); + assert!(events.iter().any(|event| { + event["kind"] == json!("prompt_mark") + && event["data"] == json!({ "prompt": "inspect direct mode" }) + })); + + deregister_subscriber(subscriber_name).unwrap(); +} + #[tokio::test] async fn claude_orphan_subagent_stop_after_closed_turn_does_not_open_null_turn() { let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; From b00f248b0c4225a43813831448467d581ff97c3a Mon Sep 17 00:00:00 2001 From: lvojtku Date: Wed, 10 Jun 2026 13:50:30 -0700 Subject: [PATCH 6/7] Apply suggestions from code review Co-authored-by: lvojtku Signed-off-by: lvojtku --- docs/nemo-relay-cli/codex.mdx | 8 +++---- docs/nemo-relay-cli/plugin-installation.mdx | 24 ++++++++++----------- integrations/coding-agents/README.md | 16 +++++++------- integrations/coding-agents/codex/README.md | 2 +- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 81a76ea00..0afef04f0 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -68,12 +68,12 @@ nemo-relay install codex The installer creates a local marketplace, installs `nemo-relay-plugin@nemo-relay-local`, enables Codex hooks, and configures the -`nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. It uses the -existing `nemo-relay` binary on `PATH`; it does not install a plugin-local Relay +`nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. It then uses the +existing `nemo-relay` binary on `PATH` but does not install a plugin-local Relay binary. Codex plugin mode uses hook-supervised lazy startup only. It does not install a -wrapper, user-level daemon, launchd agent, systemd user service, scheduled +wrapper, user-level daemon, launch agent, system user service, scheduled task, login item, or persistent supervisor. Check or remove the installed plugin with: @@ -83,7 +83,7 @@ nemo-relay doctor --plugin codex nemo-relay uninstall codex ``` -See [Plugin Installation](/nemo-relay-cli/plugin-installation) for install +Refer to [Plugin Installation](/nemo-relay-cli/plugin-installation) for install directories, first-request capture limits, rollback behavior, and source marketplace notes. diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index b60aa9d30..60f7a9a67 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -7,11 +7,11 @@ position: 3 SPDX-License-Identifier: Apache-2.0 */} -Use plugin installation when you want Claude Code or Codex to load NeMo Relay +Use the plugin installation when you want Claude Code or Codex to load NeMo Relay through the host's normal plugin system instead of launching the agent through a `nemo-relay` wrapper command. -The installed plugin keeps the same observability model as transparent runs: +The installed plugin keeps the same observability model as transparent runs. The host hooks provide agent, subagent, tool, prompt, compaction, and stop lifecycle signals, while model-provider routing sends LLM traffic through the local NeMo Relay gateway. Hooks alone cannot produce complete LLM request and @@ -19,7 +19,7 @@ response spans. ## Requirements -Install `nemo-relay` first and make sure it is available on `PATH` or `%PATH%`. +To begin, install `nemo-relay` to make sure it is available on `PATH` or `%PATH%`. The plugin installer does not download a second Relay binary, install a daemon, or require a plugin-local executable. @@ -28,9 +28,9 @@ The selected host CLI must also be available: - `claude` for Claude Code plugin installation. - `codex` for Codex plugin installation. -## Install +## Install Host Plugin -Install one host plugin: +To install one host plugin, enter: ```bash nemo-relay install claude-code @@ -56,7 +56,7 @@ nemo-relay install codex --dry-run Use `--install-dir` when you need a non-default marketplace location. The default directory is platform-specific: -| Platform | Default plugin install directory | +| Platform | Default Plugin Install Directory | | --- | --- | | macOS | `~/Library/Application Support/nemo-relay/plugins` | | Linux | `${XDG_DATA_HOME:-~/.local/share}/nemo-relay/plugins` | @@ -78,13 +78,13 @@ entries, and configures the `nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. Codex plugin mode is hook-supervised lazy startup only. It does not install a -Codex wrapper, user-level daemon, launchd agent, systemd user service, +Codex wrapper, user-level daemon, launch agent, system user service, scheduled task, login item, or persistent supervisor. The sidecar starts when an installed Codex hook runs, reuses an already healthy sidecar when one exists, and exits after its idle timeout. -Complete first-request capture in Codex depends on Codex firing an installed +A complete first-request capture in Codex depends on Codex firing an installed hook before the first provider request. If a Codex version calls the provider before any hook, the first request cannot be guaranteed under hook-only lazy startup. @@ -92,7 +92,7 @@ startup. ## Diagnose -Run plugin doctor after installation: +Run the plugin doctor after installation: ```bash nemo-relay doctor --plugin claude-code @@ -100,7 +100,7 @@ nemo-relay doctor --plugin codex nemo-relay doctor --plugin all ``` -Plugin doctor checks host registration, generated marketplace state, provider +The plugin doctor checks host registration, generated marketplace state, provider routing, hook setup, and sidecar readiness assumptions. It is separate from `nemo-relay doctor codex`, which diagnoses the regular transparent-run configuration for an agent. @@ -117,7 +117,7 @@ nemo-relay uninstall all Uninstall removes the generated host plugin registration and marketplace entry, restores Claude Code provider routing from the Relay backup, and removes -generated Codex hook/provider configuration while preserving unrelated user +generated Codex hook/provider configuration, while preserving unrelated user configuration. ## Source Marketplace Discovery @@ -129,7 +129,7 @@ validation: - `.agents/plugins/marketplace.json` Those manifests are useful when validating host plugin metadata from a source -checkout. For end-user setup, prefer `nemo-relay install ` because it +checkout. For end-user setup, we recommend using `nemo-relay install ` because it generates the local marketplace, registers the host plugin, and performs the required provider and hook setup together. Avoid keeping both a source-installed plugin and a generated install active for the same host because both can forward diff --git a/integrations/coding-agents/README.md b/integrations/coding-agents/README.md index ebd13a371..afce830f5 100644 --- a/integrations/coding-agents/README.md +++ b/integrations/coding-agents/README.md @@ -28,7 +28,7 @@ environment variables, or shared TOML config. - `codex/` is a Codex plugin package. `nemo-relay install codex` creates the marketplace, installs the plugin, enables `features.hooks = true`, and configures a local `nemo-relay-openai` provider alias. Codex plugin delivery - uses hook-supervised lazy sidecar startup only: no wrapper, user-level daemon, + uses hook-supervised lazy sidecar startup only, with no wrapper, user-level daemon, login item, launchd agent, systemd user service, scheduled task, or persistent supervisor. - `cursor/` installs a Cursor `.cursor/hooks.json` bundle targeting @@ -79,13 +79,13 @@ required. Claude Code can start the sidecar from plugin hooks or helper commands and route model traffic by setting `ANTHROPIC_BASE_URL` to the sidecar URL. -Codex does not use a daemon in plugin mode. Installed Codex hooks call the -`nemo-relay plugin-shim hook codex` command. The shim checks `/healthz`, starts -the local `nemo-relay` sidecar if needed, waits briefly for readiness, and then +Codex does not use a daemon in plugin mode. The installed Codex hooks call the +`nemo-relay plugin-shim hook codex` command. The shim then checks `/healthz`, starts +the local `nemo-relay` sidecar, if needed, waits briefly for readiness, and then forwards the hook payload. Codex model traffic is routed through the stable provider alias at `http://127.0.0.1:47632`. -End users install local host marketplaces with: +Install the local host marketplaces with: ```bash nemo-relay install claude-code @@ -98,7 +98,7 @@ plugin, and performs the required host provider and hook setup. Use `nemo-relay uninstall ` to roll back and `nemo-relay doctor --plugin ` to check an installed plugin. -Codex users can also add this repository as a marketplace for source/dev +If you are using Codex, add this repository as a marketplace for source/dev discovery: ```bash @@ -165,7 +165,7 @@ endpoint = "http://127.0.0.1:4318/v1/traces" ## Hook Forwarding -Transparent wrapper hooks call `nemo-relay hook-forward ` with the +The transparent wrapper hooks call `nemo-relay hook-forward ` with the canonical hook payload on stdin. The wrapper injects `NEMO_RELAY_GATEWAY_URL` so the same hook command reaches the ephemeral per-run gateway; hermes hooks fall back to an embedded `--gateway-url` when running outside the wrapper. @@ -174,7 +174,7 @@ Claude Code and Codex plugin hooks call `nemo-relay plugin-shim hook `. The plugin shim ensures the local sidecar is reachable, then forwards the hook payload to the plugin sidecar endpoint. -Hook forwarding fails open by default, so observability outages do not block the +Since hook forwarding fails open by default, observability outages do not block the coding agent. For wrapper-generated `hook-forward` commands, add `--fail-closed` when policy requires hook delivery to block the agent. For plugin shim hooks, set `NEMO_RELAY_FAIL_CLOSED=1` in the hook execution diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index 0beb5750f..bb49416dc 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -249,7 +249,7 @@ codex plugin marketplace add "$MARKETPLACE_ROOT" codex plugin add nemo-relay-plugin@nemo-relay-local ``` -For end-to-end installation, prefer `nemo-relay install codex`; it performs the +For end-to-end installation, we recommend using`nemo-relay install codex`; it performs the marketplace registration and the persistent Codex provider/hook setup together. If you used the manual source marketplace commands above, remove that plugin before running the full installer so source hook templates and generated From 999f85e1a0e96ea1e1fe2a0d52cf9e2789af5018 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Wed, 10 Jun 2026 17:04:12 -0400 Subject: [PATCH 7/7] docs: address codex plugin review feedback Signed-off-by: Will Killian --- docs/nemo-relay-cli/codex.mdx | 11 ++++++++--- docs/nemo-relay-cli/plugin-installation.mdx | 2 +- integrations/coding-agents/codex/README.md | 8 ++++---- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/nemo-relay-cli/codex.mdx b/docs/nemo-relay-cli/codex.mdx index 0afef04f0..740bda6cd 100644 --- a/docs/nemo-relay-cli/codex.mdx +++ b/docs/nemo-relay-cli/codex.mdx @@ -59,7 +59,7 @@ nemo-relay run \ ## Persistent Plugin Install -Use persistent plugin installation when Codex should start normally and load +Install the persistent plugin for Codex. It should start normally and load NeMo Relay from its plugin marketplace: ```bash @@ -72,14 +72,19 @@ The installer creates a local marketplace, installs existing `nemo-relay` binary on `PATH` but does not install a plugin-local Relay binary. -Codex plugin mode uses hook-supervised lazy startup only. It does not install a +Codex plugin mode uses hook-supervised on-demand startup only. It does not install a wrapper, user-level daemon, launch agent, system user service, scheduled task, login item, or persistent supervisor. -Check or remove the installed plugin with: +To check for the installed plugin, enter: ```bash nemo-relay doctor --plugin codex +``` + +To remove the plugin, enter: + +```bash nemo-relay uninstall codex ``` diff --git a/docs/nemo-relay-cli/plugin-installation.mdx b/docs/nemo-relay-cli/plugin-installation.mdx index 60f7a9a67..d88f54299 100644 --- a/docs/nemo-relay-cli/plugin-installation.mdx +++ b/docs/nemo-relay-cli/plugin-installation.mdx @@ -77,7 +77,7 @@ For Codex, install registers the local Codex marketplace, installs entries, and configures the `nemo-relay-openai` provider alias at `http://127.0.0.1:47632`. -Codex plugin mode is hook-supervised lazy startup only. It does not install a +Codex plugin mode is hook-supervised on-demand startup only. It does not install a Codex wrapper, user-level daemon, launch agent, system user service, scheduled task, login item, or persistent supervisor. The sidecar starts when an installed Codex hook runs, reuses an already healthy sidecar when one exists, diff --git a/integrations/coding-agents/codex/README.md b/integrations/coding-agents/codex/README.md index bb49416dc..028608207 100644 --- a/integrations/coding-agents/codex/README.md +++ b/integrations/coding-agents/codex/README.md @@ -34,8 +34,8 @@ With `codex-cli >= 0.129.0`, the minimum supported installed hooks are The hook template also documents events used by newer or broader host hook surfaces, including `SessionEnd`, `PostToolUseFailure`, `SubagentStart`, `SubagentStop`, and `Notification`. Relay forwards any delivered supported hook -as scope, tool, mark, or private LLM correlation events, but v1 does not depend -on Codex exposing those broader events. +as scope, tool, mark, or private LLM correlation events, but the v1 plugin +manifest does not depend on Codex exposing those broader events. Transparent setup injects these hooks with CLI config overrides. Plugin setup does not install hooks from the package template directly. It writes @@ -43,7 +43,7 @@ does not install hooks from the package template directly. It writes `nemo-relay-openai` provider alias, and merges hook shim entries into `.codex/hooks.json`. -Codex plugin mode uses hook-supervised lazy startup only. It does not install a +Codex plugin mode uses hook-supervised on-demand startup only. It does not install a user-level daemon, launchd agent, systemd user service, scheduled task, login item, wrapper, or persistent supervisor. The sidecar starts only when a Codex hook invokes `nemo-relay plugin-shim hook codex`. @@ -175,7 +175,7 @@ nemo-relay install codex `nemo-relay install codex` writes a local Codex marketplace, registers `nemo-relay-plugin`, enables Codex hooks, and configures the `nemo-relay-openai` provider alias. Codex sidecar lifecycle remains -hook-supervised lazy startup only; the installer does not create a wrapper or +hook-supervised on-demand startup only; the installer does not create a wrapper or daemon. The install command requires `nemo-relay` to be available on `PATH`. It does not