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
2 changes: 2 additions & 0 deletions site/src/content/docs/features/shell-environment.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Claudette captures that environment for you.

At app launch, Claudette runs your shell as `$SHELL -l -i -c 'env -0'` (or the equivalent fish command), captures the result, diffs it against the launchd / desktop-launcher baseline, applies a denylist, and exposes the remaining vars as a low-precedence env tier. Per-project env from direnv, mise, and dotenv plugins layers on top — they always win on key collision.

The probe runs in the background, but the first agent or integrated terminal waits for that bounded probe before spawning. This ensures sessions opened immediately after a Finder, Dock, Spotlight, or desktop-launcher start receive the captured `PATH` instead of permanently inheriting the minimal GUI `PATH`. If the probe fails or times out, Claudette falls back to the environment supplied by the OS.

You can inspect and control what was captured at **Settings → Environment → Shell environment**.

## Denylist
Expand Down
6 changes: 3 additions & 3 deletions src-tauri/src/commands/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1474,7 +1474,7 @@ pub async fn set_shell_env_denylist(
// can call list_shell_env again without restarting.
claudette::env::invalidate_shell_env();
let cleaned: Vec<String> = patterns.into_iter().filter(|s| !s.is_empty()).collect();
std::thread::spawn(move || claudette::env::prewarm_shell_env(cleaned));
claudette::env::prewarm_shell_env(cleaned);
Ok(())
}

Expand Down Expand Up @@ -1513,7 +1513,7 @@ pub async fn reload_shell_env(state: State<'_, AppState>) -> Result<(), String>
})
.unwrap_or_default();
claudette::env::invalidate_shell_env();
std::thread::spawn(move || claudette::env::prewarm_shell_env(user_deny));
claudette::env::prewarm_shell_env(user_deny);
Ok(())
}

Expand Down Expand Up @@ -1630,7 +1630,7 @@ pub fn setup_env_watcher(app: AppHandle) {
.collect()
})
.unwrap_or_default();
std::thread::spawn(move || claudette::env::prewarm_shell_env(user_deny));
claudette::env::prewarm_shell_env(user_deny);
let _ = app_for_cb.emit(
"env-cache-invalidated",
EnvCacheInvalidatedPayload {
Expand Down
11 changes: 6 additions & 5 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,11 +841,12 @@ fn main() {
// (tokio runtime may not be available during setup).
std::thread::spawn(usage::warm_user_agent_cache_sync);

// Pre-warm the shell-env cache. On Unix, this spawns
// Pre-warm the shell-env cache. On Unix, this schedules
// `$SHELL -l -i -c '<emit>'` — a NUL-delimited env dump via
// `env -0` (or the fish equivalent) — with a 5-second timeout.
// Fine to pay once at startup on a std thread, but lethal if it
// ever runs inline on a Tokio worker. On Windows this is a no-op.
// `env -0` (or the fish equivalent) — on its own worker thread.
// Calling the scheduler directly marks the probe as running before
// setup continues, so immediate agent/terminal launches can await
// it without racing the worker. On Windows this is a no-op.
// Read user-supplied shell-env deny patterns from app_settings
// so the very first probe already filters per the user's policy.
let user_deny: Vec<String> = match claudette::db::Database::open(&db_path) {
Expand All @@ -862,7 +863,7 @@ fn main() {
.unwrap_or_default(),
Err(_) => Vec::new(),
};
std::thread::spawn(move || claudette::env::prewarm_shell_env(user_deny));
claudette::env::prewarm_shell_env(user_deny);

// Set up the system tray icon (respects tray_enabled setting).
if let Err(e) = tray::setup_tray(app.handle()) {
Expand Down
3 changes: 2 additions & 1 deletion src/agent/codex_app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,8 @@ impl CodexAppServerSession {
&args,
working_dir,
options.resolved_env.as_ref(),
);
)
.await;
let mut cmd = built_command.command;
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
Expand Down
43 changes: 38 additions & 5 deletions src/agent/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,21 @@ pub(crate) struct AgentCommand {
/// terminal's process model. That keeps Nix devshell agents independent
/// from `env-direnv` and avoids importing the user's shell/profile
/// environment into the agent process.
pub(crate) fn build_agent_command(
pub(crate) async fn build_agent_command(
program: &OsStr,
args: &[String],
working_dir: &Path,
resolved_env: Option<&ResolvedEnv>,
) -> AgentCommand {
// Some control-plane sessions do not resolve workspace providers first.
// Keep the command builder as the final readiness gate so every harness
// receives the enriched PATH even when launched immediately at startup.
// Workspace resolution records an explicit disabled source, allowing
// users who opted out of shell-env forwarding to skip the probe entirely.
if !resolved_env.is_some_and(shell_env_is_disabled) {
crate::env::wait_for_shell_env_probe().await;
}

let wrapped_argv = resolved_env.and_then(|env| {
crate::env_provider::nix_develop_command_wrap(working_dir, env, program, args)
});
Expand Down Expand Up @@ -85,6 +94,12 @@ pub(crate) fn build_agent_command(
}
}

fn shell_env_is_disabled(env: &ResolvedEnv) -> bool {
env.sources.iter().any(|source| {
source.plugin_name == "shell-env" && source.error.as_deref() == Some("disabled")
})
}

fn agent_path(provider_path: Option<&Option<String>>) -> OsString {
// A provider that unsets PATH (`Some(None)`) or emits none at all
// (`None`) leaves the agent on the app's enriched PATH; an emitted
Expand All @@ -98,7 +113,7 @@ fn agent_path(provider_path: Option<&Option<String>>) -> OsString {

#[cfg(test)]
mod tests {
use super::{agent_path, build_agent_command};
use super::{agent_path, build_agent_command, shell_env_is_disabled};
use crate::env_provider::{ResolvedEnv, ResolvedSource};
use std::ffi::OsStr;
use std::time::SystemTime;
Expand Down Expand Up @@ -126,6 +141,23 @@ mod tests {
assert_eq!(agent_path(Some(&None)), crate::env::enriched_path());
}

#[test]
fn disabled_shell_env_source_skips_readiness_gate() {
let resolved = ResolvedEnv {
vars: Default::default(),
sources: vec![ResolvedSource {
plugin_name: "shell-env".to_string(),
detected: false,
vars_contributed: 0,
cached: false,
evaluated_at: SystemTime::now(),
error: Some("disabled".to_string()),
}],
};

assert!(shell_env_is_disabled(&resolved));
}

#[test]
fn teammate_command_env_var_name_matches_claude_code() {
assert_eq!(
Expand All @@ -134,8 +166,8 @@ mod tests {
);
}

#[test]
fn agent_command_does_not_wrap_for_direnv_only() {
#[tokio::test]
async fn agent_command_does_not_wrap_for_direnv_only() {
let resolved = ResolvedEnv {
vars: Default::default(),
sources: vec![ResolvedSource {
Expand All @@ -153,7 +185,8 @@ mod tests {
&["--print".to_string()],
std::path::Path::new("/tmp"),
Some(&resolved),
);
)
.await;

assert_eq!(built.invocation_program, OsStr::new("claude"));
assert_eq!(built.invocation_args, ["--print"]);
Expand Down
2 changes: 1 addition & 1 deletion src/agent/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ pub async fn run_turn(

let claude_path = resolve_claude_path().await;
let built_command =
build_agent_command(claude_path.as_os_str(), &args, working_dir, resolved_env);
build_agent_command(claude_path.as_os_str(), &args, working_dir, resolved_env).await;
let invocation_program = built_command.invocation_program.clone();
let invocation_args = built_command.invocation_args.clone();
let mut cmd = built_command.command;
Expand Down
2 changes: 1 addition & 1 deletion src/agent/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ impl PersistentSession {

let claude_path = resolve_claude_path().await;
let built_command =
build_agent_command(claude_path.as_os_str(), &args, working_dir, resolved_env);
build_agent_command(claude_path.as_os_str(), &args, working_dir, resolved_env).await;
let invocation_program = built_command.invocation_program.clone();
let invocation_args = built_command.invocation_args.clone();
let mut cmd = built_command.command;
Expand Down
Loading
Loading