Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/cli/src/agents/claude/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use crate::error::CliError;
use crate::hooks::{generated_policy_hooks, transparent_hook_forward_commands_with_config};
use crate::process::{PreparedAgentLaunch, insert_after_host};

const DISABLE_AGENT_VIEW_ENV: &str = "CLAUDE_CODE_DISABLE_AGENT_VIEW";

pub(crate) fn prepare(
launch: &mut PreparedAgentLaunch,
gateway_url: &str,
Expand All @@ -29,6 +31,10 @@ pub(crate) fn prepare(
|value| replace_custom_header(&value, &proxy_header),
);
launch.set_secret_env("ANTHROPIC_CUSTOM_HEADERS", custom_headers);

// Disable supervisor-backed sessions before Claude loads settings. The private
// settings overlay below keeps the gate active after settings are applied.
launch.env.push((DISABLE_AGENT_VIEW_ENV.into(), "1".into()));
if dry_run {
insert_after_host(
&mut launch.argv,
Expand Down Expand Up @@ -142,6 +148,7 @@ pub(crate) fn settings_overlay(
let object = settings.as_object_mut().ok_or_else(|| {
CliError::Launch("Claude Code --settings must contain a JSON object".into())
})?;
object.insert("disableAgentView".into(), Value::Bool(true));
let environment = object.entry("env").or_insert_with(|| json!({}));
let environment = environment.as_object_mut().ok_or_else(|| {
CliError::Launch("Claude Code --settings field `env` must be a JSON object".into())
Expand All @@ -150,6 +157,8 @@ pub(crate) fn settings_overlay(
"ANTHROPIC_BASE_URL".into(),
Value::String(gateway_url.into()),
);
// A matching settings `env` entry can override the inherited value and reaches child processes.
environment.insert(DISABLE_AGENT_VIEW_ENV.into(), Value::String("1".into()));
Ok(settings)
}

Expand Down
2 changes: 2 additions & 0 deletions crates/cli/src/agents/claude/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor {
hook_path: "/hooks/claude-code",
version_product: "Claude Code",
minimum_version: (2, 1, 121),
// Earlier releases cannot reliably apply Relay's final settings overlay and Agent View gate.
transparent_minimum_version: Some((2, 1, 169)),
verified_through: None,
hook_events: &[
"SessionStart",
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/agents/codex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor {
hook_path: "/hooks/codex",
version_product: "codex-cli",
minimum_version: (0, 143, 0),
transparent_minimum_version: None,
verified_through: None,
hook_events: &[
"SessionStart",
Expand Down
18 changes: 18 additions & 0 deletions crates/cli/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub(super) struct AgentDescriptor {
hook_path: &'static str,
version_product: &'static str,
minimum_version: (u64, u64, u64),
/// A stricter floor for transparent runs when the launcher needs newer host controls.
transparent_minimum_version: Option<(u64, u64, u64)>,
/// The last `major.minor` this integration was actually verified against, for a
/// host whose minor releases can break the hook contract.
///
Expand Down Expand Up @@ -127,6 +129,22 @@ impl CodingAgent {
Version::new(major, minor, patch)
}

pub(crate) fn validate_transparent_version(self, version: &Version) -> Result<(), String> {
let Some((major, minor, patch)) = self.descriptor().transparent_minimum_version else {
return Ok(());
};
let minimum = Version::new(major, minor, patch);
if version >= &minimum {
return Ok(());
}
Err(format!(
"{} {version} is unsupported for transparent runs; upgrade to {} {minimum} or use a \
persistent or managed Relay integration",
self.label(),
self.label(),
))
}

pub(crate) fn version_requirement(self) -> String {
let descriptor = self.descriptor();
format!(
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/agents/pi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub(super) const DESCRIPTOR: AgentDescriptor = AgentDescriptor {
// channel, so this floor is the version the integration was verified
// against rather than a lower bound that is expected to keep holding.
minimum_version: (0, 84, 0),
transparent_minimum_version: None,
// Which is why the floor alone was a lie by omission: it accepted 0.85.0 as
// "supported" for a host that can move a hook shape in a minor. Below the floor
// is an error, above this is a warning -- untested, not broken, and blocking a
Expand Down
9 changes: 3 additions & 6 deletions crates/cli/src/process/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,9 @@ async fn validate_agent_version(agent: CodingAgent, probe: &[String]) -> Result<
let version = agent
.validate_version_output(&stdout)
.map_err(CliError::Launch)?;
agent
.validate_transparent_version(&version)
.map_err(CliError::Launch)?;
// Logged rather than returned: this is not a reason to refuse the launch, and there is no
// note channel here -- `PreparedAgentLaunch` is already built by the time the probe runs.
if let Some(unverified) = agent.unverified_version(&version) {
Expand Down Expand Up @@ -645,12 +648,6 @@ impl PreparedAgentLaunch {
}
}

// Claude Code honors only the first `--settings` source. Preserve that source in the generated
// overlay so inserting Relay's process-private gateway setting cannot discard user configuration.
// Session hook definitions and their exact trust state share Codex's process-local CLI layer. This
// authorizes only the generated Relay command without rewriting the active user profile or using
// the process-wide hook-trust bypass.

/// Renders a bordered status frame for daemon and transparent-run startup output.
pub(crate) fn render_status_frame(lines: &[String], color: bool) -> String {
let max_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
Expand Down
21 changes: 21 additions & 0 deletions crates/cli/tests/coverage/agents/coding_agent_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ fn centralized_minimum_versions_accept_stable_boundaries() {
}
}

#[test]
fn transparent_version_policy_is_mode_specific() {
assert!(
CodingAgent::ClaudeCode
.validate_transparent_version(&semver::Version::new(2, 1, 168))
.is_err()
);
assert!(
CodingAgent::ClaudeCode
.validate_transparent_version(&semver::Version::new(2, 1, 169))
.is_ok()
);
for agent in [CodingAgent::Codex, CodingAgent::Pi] {
assert!(
agent
.validate_transparent_version(&agent.minimum_version())
.is_ok()
);
}
}

// The floor alone said "supported" for any stable version above it, which is right for a host
// whose minors are additive and wrong for one that can move a hook shape in a minor. pi is the
// second kind: above 0.84.x it is untested, and the symptom of a broken hook shape is missing
Expand Down
52 changes: 50 additions & 2 deletions crates/cli/tests/coverage/agents/launcher_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,35 @@ async fn wrapped_agent_version_probe_runs_through_the_wrapper() {
.unwrap();
}

#[cfg(unix)]
#[tokio::test]
async fn transparent_claude_requires_reliable_agent_view_controls() {
let temp = tempfile::tempdir().unwrap();
let claude = temp.path().join("claude");
std::fs::write(&claude, "#!/bin/sh\necho '2.1.168 (Claude Code)'\n").unwrap();
make_executable(&claude);

let probe = [claude.display().to_string(), "--version".into()];
let error = validate_agent_version(CodingAgent::ClaudeCode, &probe)
.await
.unwrap_err()
.to_string();
assert!(
error.contains("unsupported for transparent runs"),
"{error}"
);
assert!(error.contains("Claude Code 2.1.169"), "{error}");
assert!(
error.contains("persistent or managed Relay integration"),
"{error}"
);

std::fs::write(&claude, "#!/bin/sh\necho '2.1.169 (Claude Code)'\n").unwrap();
validate_agent_version(CodingAgent::ClaudeCode, &probe)
.await
.unwrap();
}

#[test]
fn prepares_claude_dry_run_without_writing_plugin() {
let _env = EnvScope::set(&[("ANTHROPIC_CUSTOM_HEADERS", None)]);
Expand All @@ -1043,6 +1072,14 @@ fn prepares_claude_dry_run_without_writing_plugin() {
.env
.contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into()))
);
let disable_agent_view = prepared
.env
.iter()
.filter_map(|(name, value)| {
(name == "CLAUDE_CODE_DISABLE_AGENT_VIEW").then_some(value.as_str())
})
.collect::<Vec<_>>();
assert_eq!(disable_agent_view, ["1"]);
assert!(prepared.notes[0].contains("would generate"));
let custom_headers = prepared
.env
Expand Down Expand Up @@ -1226,6 +1263,8 @@ fn prepares_claude_temp_plugin() {
settings["env"]["ANTHROPIC_BASE_URL"],
"http://127.0.0.1:1234"
);
assert_eq!(settings["disableAgentView"], true);
assert_eq!(settings["env"]["CLAUDE_CODE_DISABLE_AGENT_VIEW"], "1");
let hooks: serde_json::Value =
serde_json::from_slice(&std::fs::read(plugin_dir.join("hooks/hooks.json")).unwrap())
.unwrap();
Expand All @@ -1246,6 +1285,11 @@ fn prepares_claude_temp_plugin() {
.env
.contains(&("ANTHROPIC_BASE_URL".into(), "http://127.0.0.1:1234".into()))
);
assert!(
prepared
.env
.contains(&("CLAUDE_CODE_DISABLE_AGENT_VIEW".into(), "1".into()))
);
prepared.restore().unwrap();
assert!(!plugin_dir.exists());
}
Expand All @@ -1254,7 +1298,7 @@ fn prepares_claude_temp_plugin() {
fn claude_transparent_run_preserves_user_settings_and_prompt_boundary() {
let temp = tempfile::tempdir().unwrap();
let source = temp.path().join("claude-settings.json");
let original = br#"{"model":"claude-user-setting-sentinel","enabledPlugins":{"other@market":true,"nemo-relay-plugin@nemo-relay-local":true},"env":{"PRIVATE":"kept"}}"#;
let original = br#"{"model":"claude-user-setting-sentinel","enabledPlugins":{"other@market":true,"nemo-relay-plugin@nemo-relay-local":true},"disableAgentView":false,"env":{"PRIVATE":"kept","CLAUDE_CODE_DISABLE_AGENT_VIEW":"0"}}"#;
std::fs::write(&source, original).unwrap();
let resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
Expand Down Expand Up @@ -1296,6 +1340,8 @@ fn claude_transparent_run_preserves_user_settings_and_prompt_boundary() {
true
);
assert_eq!(overlay["env"]["PRIVATE"], "kept");
assert_eq!(overlay["disableAgentView"], true);
assert_eq!(overlay["env"]["CLAUDE_CODE_DISABLE_AGENT_VIEW"], "1");
assert_eq!(
overlay["env"]["ANTHROPIC_BASE_URL"],
"http://127.0.0.1:1234"
Expand Down Expand Up @@ -1349,7 +1395,9 @@ fn claude_settings_overlay_handles_inline_json_and_rejects_malformed_sources() {
"http://127.0.0.1:4321",
)
.unwrap();
assert_eq!(overlay.as_object().unwrap().len(), 1);
assert_eq!(overlay.as_object().unwrap().len(), 2);
assert_eq!(overlay["disableAgentView"], true);
assert_eq!(overlay["env"]["CLAUDE_CODE_DISABLE_AGENT_VIEW"], "1");

let missing = vec!["claude".into(), "--settings".into(), "--".into()];
assert!(
Expand Down
30 changes: 23 additions & 7 deletions docs/nemo-relay-cli/claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ nemo-relay claude -- "summarize this repository"
This shortcut is equivalent to `nemo-relay run -- claude`. The wrapper starts a
gateway on a dynamic `127.0.0.1` port, creates a temporary Claude plugin
directory with NeMo Relay hooks, and passes that plugin with `--plugin-dir`.
Because Claude Code gives its first `--settings` source precedence over the
process environment, Relay also creates a private settings overlay that
preserves that source and overrides only `ANTHROPIC_BASE_URL` for the launched
process. The source settings and installed plugin enablement remain unchanged.
Because matching Claude Code `settings.env` entries can override inherited
environment values, Relay also creates a private settings overlay from the
first explicit `--settings` source. The overlay copies that source, forces
`ANTHROPIC_BASE_URL`, and reasserts the Agent View gate for the launched
process; other fields in that source and installed plugin enablement remain
unchanged.
If a Relay plugin is already enabled, its MCP process authenticates and borrows
the dynamic gateway instead of launching the fixed sidecar, then monitors that
exact gateway while MCP stdio remains open. Its persistent hooks exit without
Expand All @@ -41,6 +43,19 @@ and those hooks authenticate the wrapper gateway before sending a payload, so
installed and source-marketplace plugin IDs cannot duplicate the captured
stream.

Transparent runs require Claude Code 2.1.169 or newer, while persistent installs
remain supported from 2.1.121. Relay sets
`CLAUDE_CODE_DISABLE_AGENT_VIEW=1` in both the launch environment and private
settings overlay, and sets `disableAgentView=true` in that overlay. This disables
Claude Code Agent View and its background-session entry points, provided a
configured command wrapper preserves Relay's injected environment and arguments.
Those sessions are owned by Claude's persistent
supervisor, so they can remain alive after the wrapper removes its dynamic
gateway and temporary plugin files, and later work cannot reliably use the
intended Relay policy or observability. To create or manage background sessions,
install the persistent plugin and use `claude` directly, or use an
administrator-managed Relay daemon deployment.

Inspect what would be launched without starting Claude Code:

```bash
Expand Down Expand Up @@ -211,9 +226,10 @@ slash-command expansions as `skill.load.inferred`; Claude Code does not
identify whether an expansion came from a skill or a legacy custom command.
The normal `Skill` pre-tool hook emits an observed `skill.load` mark.

The wrapper requires Claude Code 2.1.121 or newer. Earlier versions do not
support every required hook event, and `nemo-relay doctor` reports the version
mismatch.
Persistent integration requires Claude Code 2.1.121 or newer; transparent runs
require 2.1.169 or newer. Earlier versions do not support every required hook or
launch control. `nemo-relay doctor` validates the persistent floor; a live
transparent launch checks its stricter floor before starting the gateway.

Tool hooks preserve canonical fields such as `tool_use_id`, `tool_name`,
`tool_input`, `error`, `duration_ms`, and `is_interrupt`. Subagent hooks use
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/support-matrix.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ runs still do.

| Agent | Minimum version | Supported CLI capabilities | Current limitations |
| --- | --- | --- | --- |
| Claude Code | 2.1.121 | Persistent install, transparent run, lifecycle hooks, local gateway routing, and pre-tool security | Claude desktop, web, and application sessions are unsupported unless they expose the same local hook and gateway controls. Optimization requires gateway-routed LLM traffic and available hooks. |
| Claude Code | 2.1.121 persistent; 2.1.169 transparent | Persistent install, transparent run, lifecycle hooks, local gateway routing, and pre-tool security | Transparent runs require Claude's Agent View/background controls and reliable final-settings precedence, then set the controls in both the process environment and private settings overlay; configured command wrappers must preserve the injected environment and settings arguments. Use a persistent or managed integration for background sessions. Claude desktop, web, and application sessions are unsupported unless they expose the same local hook and gateway controls. Optimization requires gateway-routed LLM traffic and available hooks. |
| Codex CLI | 0.143.0 | Persistent install, transparent run, 10 supported plugin hooks, local gateway routing, and pre-tool security | Cloud or remote tasks that bypass the local machine have partial or no LLM capture. The plugin hook schema has no `SessionEnd`; Relay finalizes the cumulative session snapshot at `Stop`. Encrypted Codex multi-agent v2 payloads cannot be decrypted or reliably linked. |
| pi | 0.84.x | Transparent run through a Relay-authored pi extension, 15 forwarded event types covering session, turn, tool and inline-shell activity, tool-call security, inline-shell security, and model-call security when redirection applies | Proof of concept. `nemo-relay install pi` writes the extension into pi's own auto-discovery directory rather than generating a marketplace plugin, because pi has no plugin marketplace; installing it yourself with `pi install` or a file drop works too, and every route persists. Model traffic is redirected only when the gateway forwards to the endpoint every model of the selected provider would otherwise call, because pi's provider registration is provider-wide; otherwise there are no LLM spans for that model. Seven of pi's 39 built-in providers speak an API the gateway has no route for. Subagents and nested pi processes appear as unrelated sessions. pi ships breaking changes through minor releases, so hook signatures need re-verification after an upgrade; a minor above 0.84.x is accepted but reported as unverified rather than supported. |

Expand Down