Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

152 changes: 152 additions & 0 deletions crates/nebula-core/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Item = (OsString, OsString)>,
) -> Vec<OsString> {
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<String> {
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<String> {
Expand All @@ -102,6 +184,76 @@ pub fn home_dir() -> Option<PathBuf> {
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());
Expand Down
1 change: 1 addition & 0 deletions crates/nebula/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
10 changes: 10 additions & 0 deletions crates/nebula/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions crates/nebula/tests/e2e_pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
Expand Down