From 793162f57c195466b74c1972f1ae295819528eb0 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Thu, 4 Jun 2026 14:21:08 -0400 Subject: [PATCH] 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 00000000..a608fc21 --- /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 00000000..a276b945 --- /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 4b5274b2..3b79c81d 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 b3496037..e288c9af 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 1b4cc324..e1c308a2 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 2307065f..1d8e0f01 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 f19624e1..9562491a 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 91a2eaa3..c0375b7d 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 00000000..58cf4421 --- /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 00000000..0169fd43 --- /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 00000000..9dabdf88 --- /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 00000000..448784cf --- /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 00000000..25fcac84 --- /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 00000000..b7bb7821 --- /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 00000000..8f2c7e9b --- /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 00000000..c451e889 --- /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 00000000..fc969e39 --- /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 00000000..fd236b7c --- /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 fef92e1d..b2239bb5 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 832080d6..9b43b67c 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 3a67b9d4..45dbe561 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 748b389d..221de911 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 8ca7fdae..d181e8ad 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 00000000..c8dddba5 --- /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 00000000..e0ce6903 --- /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 677a38e7..78304979 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 4b346687..15b52ae0 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 236e60ab..2d29f06c 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 114782b5..e14f0d86 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 e8ec4933..02df6734 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 fefc0f80..793a502b 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 21d62cac..0beb5750 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 7f6e9b40..43a79c2b 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 bb7b3aff..6cfb8d39 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() {