diff --git a/Cargo.lock b/Cargo.lock index 13a2eb49..84759710 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1139,6 +1139,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tracing", "tracing-subscriber", "vt100", ] diff --git a/crates/nebula-core/src/env.rs b/crates/nebula-core/src/env.rs index cbddf6e5..31e071ca 100644 --- a/crates/nebula-core/src/env.rs +++ b/crates/nebula-core/src/env.rs @@ -3,6 +3,7 @@ //! from one place — a typo here fails to build instead of silently falling //! back to a default. +use std::ffi::{OsStr, OsString}; use std::path::PathBuf; /// Id of the agent a hook or CLI invocation is running inside. Set on every @@ -86,6 +87,87 @@ pub const PANE_COLORTERM: &str = "truecolor"; /// out, so a user who wants none keeps none. pub const PANE_COLOR_OVERRIDES: &[&str] = &["NO_COLOR", "FORCE_COLOR"]; +/// Variables a Claude Code session sets on the shells it runs tools in, +/// naming that session: its id, its process, its messaging socket and +/// token, and the marker that says "this process is my sub-process". A +/// daemon started from such a shell — `nebula` typed by an agent, or run +/// in its terminal — would pass them to every agent, terminal and `claude` +/// probe it starts, and each would believe it belonged to that other +/// session: Claude Code turns its transcript off for an inherited +/// `CLAUDE_CODE_CHILD_SESSION` (so `--resume`, and with it a `nebula +/// worktree` relocation, finds no conversation), reports the wrong +/// session id, and talks to the other session's socket. Only identity is +/// listed: user settings that share the `CLAUDE_CODE_` prefix +/// (`CLAUDE_CODE_USE_BEDROCK`, an OAuth token) are kept. +pub const HOST_CLAUDE_SESSION_VARS: &[&str] = &[ + "CLAUDECODE", + "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_SESSION_ATTENDED", + "CLAUDE_CODE_BRIDGE_SESSION_ID", + "CLAUDE_CODE_ENTRYPOINT", + "CLAUDE_CODE_EXECPATH", + "CLAUDE_CODE_MESSAGING_SOCKET", + "CLAUDE_CODE_MESSAGING_TOKEN", + "CLAUDE_CODE_INVOKED_SKILLS", + "CLAUDE_PID", + "CLAUDE_EFFORT", + "AI_AGENT", +]; + +/// Overrides Claude Code puts on its tool shells for its own non-interactive +/// use: `GIT_EDITOR=true` (in a nebula pane a bare `git commit` would abort +/// on an empty message) and `COREPACK_ENABLE_AUTO_PIN=0`. Dropped only at +/// exactly the value it sets, and only beside a session marker, so a user +/// who sets either themselves keeps it. +pub const HOST_CLAUDE_TOOL_SHELL_OVERRIDES: &[(&str, &str)] = + &[("GIT_EDITOR", "true"), ("COREPACK_ENABLE_AUTO_PIN", "0")]; + +/// Whether an inherited `name=value` belongs to the Claude Code session +/// `nebula` was started from: one of [`HOST_CLAUDE_SESSION_VARS`], or — +/// when `in_session` (a session marker sits in the same environment) — +/// one of [`HOST_CLAUDE_TOOL_SHELL_OVERRIDES`] at its exact value. +pub fn is_host_claude_session_var(name: &OsStr, value: &OsStr, in_session: bool) -> bool { + let Some(name) = name.to_str() else { + return false; + }; + HOST_CLAUDE_SESSION_VARS.contains(&name) + || (in_session + && HOST_CLAUDE_TOOL_SHELL_OVERRIDES + .iter() + .any(|&(var, set)| var == name && value == set)) +} + +/// The names in `vars` that belong to the host Claude Code session. +pub fn host_claude_session_names( + vars: impl IntoIterator, +) -> Vec { + let vars: Vec<(OsString, OsString)> = vars.into_iter().collect(); + let in_session = vars + .iter() + .any(|(name, _)| name == "CLAUDECODE" || name == "CLAUDE_CODE_CHILD_SESSION"); + vars.into_iter() + .filter(|(name, value)| is_host_claude_session_var(name, value, in_session)) + .map(|(name, _)| name) + .collect() +} + +/// Drop the inherited Claude Code session variables from this process's +/// environment, returning the names dropped. Called first thing in +/// `main`, before a thread or a child exists, so nothing `nebula` starts — +/// the daemon and its agents, terminals and probes, or the TUI's own +/// helpers — inherits them. +pub fn scrub_host_claude_session() -> Vec { + let names = host_claude_session_names(std::env::vars_os()); + for name in &names { + std::env::remove_var(name); + } + names + .into_iter() + .map(|name| name.to_string_lossy().into_owned()) + .collect() +} + /// The value of `var`, treating unset and empty the same way — an empty /// override is how a caller says "use the default". pub fn non_empty(var: &str) -> Option { @@ -102,6 +184,76 @@ pub fn home_dir() -> Option { mod tests { use super::*; + #[test] + fn host_claude_session_vars_are_identity_only() { + let is = |name: &str, value: &str| { + is_host_claude_session_var(OsStr::new(name), OsStr::new(value), true) + }; + for name in [ + "CLAUDECODE", + "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_MESSAGING_SOCKET", + "CLAUDE_CODE_MESSAGING_TOKEN", + "CLAUDE_PID", + ] { + assert!(is(name, "1"), "{name}"); + } + // User settings under the same prefix stay. + for name in [ + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_OAUTH_TOKEN", + CLAUDE_CONFIG_DIR, + AGENT_ID, + "PATH", + ] { + assert!(!is(name, "1"), "{name}"); + } + assert!(is("GIT_EDITOR", "true")); + assert!(!is("GIT_EDITOR", "vim"), "a user's editor stays"); + assert!(is("COREPACK_ENABLE_AUTO_PIN", "0")); + assert!( + !is("COREPACK_ENABLE_AUTO_PIN", "1"), + "a user's choice stays" + ); + assert!( + !is_host_claude_session_var(OsStr::new("GIT_EDITOR"), OsStr::new("true"), false), + "outside a Claude session, GIT_EDITOR=true is the user's" + ); + assert!( + !is("CLAUDE_CODE_SSE_PORT", "1"), + "an IDE terminal's link stays" + ); + } + + #[test] + fn host_session_names_are_picked_out_of_an_environment() { + let vars = [ + ("CLAUDE_CODE_CHILD_SESSION", "1"), + ("CLAUDE_CODE_USE_BEDROCK", "1"), + ("GIT_EDITOR", "true"), + ("COREPACK_ENABLE_AUTO_PIN", "0"), + ("COREPACK_ENABLE_AUTO_PIN_EXTRA", "0"), + ("PATH", "/bin"), + ] + .map(|(k, v)| (OsString::from(k), OsString::from(v))); + assert_eq!( + host_claude_session_names(vars.clone()), + [ + "CLAUDE_CODE_CHILD_SESSION", + "GIT_EDITOR", + "COREPACK_ENABLE_AUTO_PIN" + ] + .map(OsString::from) + ); + // No session marker: the overrides are the user's own. + let plain: Vec<_> = vars + .into_iter() + .filter(|(name, _)| name != "CLAUDE_CODE_CHILD_SESSION") + .collect(); + assert!(host_claude_session_names(plain).is_empty()); + } + #[test] fn non_empty_treats_unset_and_empty_alike() { let var = format!("NEBULA_TEST_NON_EMPTY_{}", std::process::id()); diff --git a/crates/nebula/Cargo.toml b/crates/nebula/Cargo.toml index a5ce33c7..d5bf62ab 100644 --- a/crates/nebula/Cargo.toml +++ b/crates/nebula/Cargo.toml @@ -14,6 +14,7 @@ nebula-daemon = { workspace = true } nebula-tui = { workspace = true } anyhow = { workspace = true } clap = { version = "4", features = ["derive", "wrap_help"] } +tracing = { workspace = true } tracing-subscriber = { workspace = true } [dev-dependencies] diff --git a/crates/nebula/src/main.rs b/crates/nebula/src/main.rs index 54a8e249..3711e894 100644 --- a/crates/nebula/src/main.rs +++ b/crates/nebula/src/main.rs @@ -15,9 +15,16 @@ fn main() -> Result<()> { // settings along. Merge them before anything reads a setting, and before // a thread or a child exists to inherit the variable. nebula_tui::bundle::apply_forwarded(); + // Likewise before any thread or child: nothing `nebula` starts — the + // daemon and everything it runs, or the TUI's own helpers — may + // inherit the Claude Code session it was typed in. + let dropped = nebula_core::env::scrub_host_claude_session(); match cli.command { Some(Command::Daemon { foreground }) => { init_daemon_logging(foreground)?; + if !dropped.is_empty() { + tracing::info!(vars = ?dropped, "dropped the inherited Claude Code session env"); + } log_fatal( nebula_daemon::run_daemon(), &nebula_core::paths::daemon_log_path(), @@ -94,6 +101,9 @@ fn main() -> Result<()> { Some(dir) => nebula_tui::run_add_project(dir), None => { init_tui_logging()?; + if !dropped.is_empty() { + tracing::info!(vars = ?dropped, "dropped the inherited Claude Code session env"); + } let handoff = log_fatal(nebula_tui::run_tui(), &nebula_core::paths::tui_log_path())?; match handoff { diff --git a/crates/nebula/tests/e2e_pty.rs b/crates/nebula/tests/e2e_pty.rs index 81033661..0194c2e4 100644 --- a/crates/nebula/tests/e2e_pty.rs +++ b/crates/nebula/tests/e2e_pty.rs @@ -3470,6 +3470,71 @@ fn wait_for_exit(daemon: &mut DaemonProc) { } } +/// A daemon started from inside a Claude Code session (`nebula` typed in +/// an agent's tool shell) must not hand that session's identity to the +/// agents it starts: an inherited `CLAUDE_CODE_CHILD_SESSION` turns the +/// agent's own transcript off, and Claude Code's `GIT_EDITOR=true` breaks a +/// bare `git commit`. A user setting under the same prefix still comes +/// through. +#[tokio::test] +async fn agents_never_inherit_the_claude_session_the_daemon_started_in() { + let env = TestEnv::new(); + let repo = env.make_repo(); + let env_dir = env.tmp.path().join("agent-env"); + std::fs::create_dir_all(&env_dir).unwrap(); + let script = env.tmp.path().join("agent.sh"); + std::fs::write( + &script, + format!( + "#!/bin/sh\nenv | grep -E '^(NEBULA_|CLAUDE|AI_AGENT|GIT_EDITOR|COREPACK_)' > '{}'/$NEBULA_AGENT_ID.env\nexec sleep 600\n", + env_dir.display() + ), + ) + .unwrap(); + make_executable(&script); + let _daemon = env.spawn_daemon_with( + script.to_str().unwrap(), + &[ + ("CLAUDECODE", "1"), + ("CLAUDE_CODE_CHILD_SESSION", "1"), + ("CLAUDE_CODE_SESSION_ID", "host-session"), + ("CLAUDE_CODE_MESSAGING_SOCKET", "/tmp/host.sock"), + ("CLAUDE_PID", "1"), + ("AI_AGENT", "claude-code"), + ("GIT_EDITOR", "true"), + ("COREPACK_ENABLE_AUTO_PIN", "0"), + ("CLAUDE_CODE_USE_BEDROCK", "1"), + ], + ); + + let mut c = connect(&env.sock()).await; + handshake(&mut c).await; + let worktree = add_project_get_main_worktree(&mut c, &repo).await; + let agent_id = create_agent_get_id(&mut c, &worktree.id, "agent-1", 2).await; + + let agent_env = read_env_file(&env_dir.join(format!("{}.env", agent_id.0))).await; + for name in [ + "CLAUDECODE", + "CLAUDE_CODE_CHILD_SESSION", + "CLAUDE_CODE_SESSION_ID", + "CLAUDE_CODE_MESSAGING_SOCKET", + "CLAUDE_PID", + "AI_AGENT", + "GIT_EDITOR", + "COREPACK_ENABLE_AUTO_PIN", + ] { + assert!( + !agent_env.contains_key(name), + "{name} leaked: {agent_env:?}" + ); + } + assert_eq!( + agent_env.get("CLAUDE_CODE_USE_BEDROCK").map(String::as_str), + Some("1"), + "a user setting is kept: {agent_env:?}" + ); +} + /// Poll the env dump the fake agent CLI writes on boot, returning the /// NEBULA_* variables the real CLI's hooks (and `nebula rename`) would see. async fn read_env_file(path: &Path) -> std::collections::HashMap {