From 3e93765763f4f9290f77141d53cb641135ee1272 Mon Sep 17 00:00:00 2001 From: Drew Bradford <16455249+DrewBradfordXYZ@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:09:33 -0400 Subject: [PATCH] nebula started from inside a Claude Code session no longer passes that session's identity to everything it starts, so Claude agents under it keep their transcripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Claude Code session sets CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_CODE_CHILD_SESSION, CLAUDE_PID, its messaging socket and token, and a few more on the shells it runs tools in. A daemon started from such a shell (`nebula` typed by an agent) kept them and passed them to every agent, terminal and `claude` probe it started, so each one believed it was a sub-process of that other session. Claude Code turns its transcript off for an inherited CLAUDE_CODE_CHILD_SESSION ("Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker"), so those agents wrote no transcript: --resume found nothing, and a `nebula worktree` relocation came back as a fresh session. They also talked to the other session's messaging socket. `nebula` now drops those variables from its own environment first thing, for every subcommand, before any thread or child exists, and logs which ones it dropped. Only session identity goes. User settings that share the CLAUDE_CODE_ prefix (CLAUDE_CODE_USE_BEDROCK, an OAuth token) stay, and so does CLAUDE_CODE_SSE_PORT, an IDE terminal's link back to the editor. Claude Code's own tool-shell overrides, GIT_EDITOR=true (which makes a bare `git commit` in a nebula terminal abort) and COREPACK_ENABLE_AUTO_PIN=0, are dropped only at exactly those values and only beside a session marker, so a user who sets either keeps it. A daemon already running keeps its environment until it restarts. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + crates/nebula-core/src/env.rs | 152 +++++++++++++++++++++++++++++++++ crates/nebula/Cargo.toml | 1 + crates/nebula/src/main.rs | 10 +++ crates/nebula/tests/e2e_pty.rs | 65 ++++++++++++++ 5 files changed, 229 insertions(+) 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 {