windows: Add support for fetching shell environment in remote projects (#39831)

Closes #39216

Note that this affects all platforms, I'm just using the prefix to make
auto-cherry-picking easier.

Release Notes:

- Fixed shell commands run by agents failing to find installed programs
in some cases.
This commit is contained in:
Cole Miller 2025-10-12 19:31:40 -04:00 committed by GitHub
parent abc1e67221
commit 92e765b5d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 180 additions and 59 deletions

1
Cargo.lock generated
View file

@ -13077,6 +13077,7 @@ dependencies = [
"shellexpand 2.1.2",
"smol",
"sysinfo",
"task",
"thiserror 2.0.12",
"toml 0.8.20",
"unindent",

View file

@ -20,7 +20,6 @@ use std::{
cmp::Reverse,
collections::HashSet,
fmt::Write,
path::Path,
sync::Arc,
time::{Duration, Instant},
};
@ -328,17 +327,13 @@ impl ActivityIndicator {
.flatten()
}
fn pending_environment_errors<'a>(
&'a self,
cx: &'a App,
) -> impl Iterator<Item = (&'a Arc<Path>, &'a EnvironmentErrorMessage)> {
self.project.read(cx).shell_environment_errors(cx)
fn pending_environment_error<'a>(&'a self, cx: &'a App) -> Option<&'a EnvironmentErrorMessage> {
self.project.read(cx).peek_environment_error(cx)
}
fn content_to_render(&mut self, cx: &mut Context<Self>) -> Option<Content> {
// Show if any direnv calls failed
if let Some((abs_path, error)) = self.pending_environment_errors(cx).next() {
let abs_path = abs_path.clone();
if let Some(error) = self.pending_environment_error(cx) {
return Some(Content {
icon: Some(
Icon::new(IconName::Warning)
@ -348,7 +343,7 @@ impl ActivityIndicator {
message: error.0.clone(),
on_click: Some(Arc::new(move |this, window, cx| {
this.project.update(cx, |project, cx| {
project.remove_environment_error(&abs_path, cx);
project.pop_environment_error(cx);
});
window.dispatch_action(Box::new(workspace::OpenLog), cx);
})),

View file

@ -21,6 +21,7 @@ use rpc::{AnyProtoClient, TypedEnvelope, proto};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::SettingsStore;
use task::Shell;
use util::{ResultExt as _, debug_panic};
use crate::ProjectEnvironment;
@ -850,7 +851,11 @@ impl ExternalAgentServer for LocalGemini {
cx.spawn(async move |cx| {
let mut env = project_environment
.update(cx, |project_environment, cx| {
project_environment.get_directory_environment(root_dir.clone(), cx)
project_environment.get_local_directory_environment(
&Shell::System,
root_dir.clone(),
cx,
)
})?
.await
.unwrap_or_default();
@ -937,7 +942,11 @@ impl ExternalAgentServer for LocalClaudeCode {
cx.spawn(async move |cx| {
let mut env = project_environment
.update(cx, |project_environment, cx| {
project_environment.get_directory_environment(root_dir.clone(), cx)
project_environment.get_local_directory_environment(
&Shell::System,
root_dir.clone(),
cx,
)
})?
.await
.unwrap_or_default();
@ -1023,7 +1032,11 @@ impl ExternalAgentServer for LocalCodex {
cx.spawn(async move |cx| {
let mut env = project_environment
.update(cx, |project_environment, cx| {
project_environment.get_directory_environment(root_dir.clone(), cx)
project_environment.get_local_directory_environment(
&Shell::System,
root_dir.clone(),
cx,
)
})?
.await
.unwrap_or_default();
@ -1163,7 +1176,11 @@ impl ExternalAgentServer for LocalCustomAgent {
cx.spawn(async move |cx| {
let mut env = project_environment
.update(cx, |project_environment, cx| {
project_environment.get_directory_environment(root_dir.clone(), cx)
project_environment.get_local_directory_environment(
&Shell::System,
root_dir.clone(),
cx,
)
})?
.await
.unwrap_or_default();

View file

@ -49,7 +49,7 @@ use std::{
path::{Path, PathBuf},
sync::{Arc, Once},
};
use task::{DebugScenario, SpawnInTerminal, TaskContext, TaskTemplate};
use task::{DebugScenario, Shell, SpawnInTerminal, TaskContext, TaskTemplate};
use util::{ResultExt as _, rel_path::RelPath};
use worktree::Worktree;
@ -279,7 +279,11 @@ impl DapStore {
.unwrap()
.environment
.update(cx, |environment, cx| {
environment.get_directory_environment(cwd, cx)
environment.get_local_directory_environment(
&Shell::System,
cwd,
cx,
)
})
})?
.await;

View file

@ -1,6 +1,8 @@
use futures::{FutureExt, future::Shared};
use language::Buffer;
use std::{path::Path, sync::Arc};
use remote::RemoteClient;
use rpc::proto::{self, REMOTE_SERVER_PROJECT_ID};
use std::{collections::VecDeque, path::Path, sync::Arc};
use task::Shell;
use util::ResultExt;
use worktree::Worktree;
@ -16,10 +18,9 @@ use crate::{
pub struct ProjectEnvironment {
cli_environment: Option<HashMap<String, String>>,
environments: HashMap<Arc<Path>, Shared<Task<Option<HashMap<String, String>>>>>,
shell_based_environments:
HashMap<(Shell, Arc<Path>), Shared<Task<Option<HashMap<String, String>>>>>,
environment_error_messages: HashMap<Arc<Path>, EnvironmentErrorMessage>,
local_environments: HashMap<(Shell, Arc<Path>), Shared<Task<Option<HashMap<String, String>>>>>,
remote_environments: HashMap<(Shell, Arc<Path>), Shared<Task<Option<HashMap<String, String>>>>>,
environment_error_messages: VecDeque<EnvironmentErrorMessage>,
}
pub enum ProjectEnvironmentEvent {
@ -32,8 +33,8 @@ impl ProjectEnvironment {
pub fn new(cli_environment: Option<HashMap<String, String>>) -> Self {
Self {
cli_environment,
environments: Default::default(),
shell_based_environments: Default::default(),
local_environments: Default::default(),
remote_environments: Default::default(),
environment_error_messages: Default::default(),
}
}
@ -48,19 +49,6 @@ impl ProjectEnvironment {
}
}
/// Returns an iterator over all pairs `(abs_path, error_message)` of
/// environment errors associated with this project environment.
pub(crate) fn environment_errors(
&self,
) -> impl Iterator<Item = (&Arc<Path>, &EnvironmentErrorMessage)> {
self.environment_error_messages.iter()
}
pub(crate) fn remove_environment_error(&mut self, abs_path: &Path, cx: &mut Context<Self>) {
self.environment_error_messages.remove(abs_path);
cx.emit(ProjectEnvironmentEvent::ErrorsUpdated);
}
pub(crate) fn get_buffer_environment(
&mut self,
buffer: &Entity<Buffer>,
@ -115,15 +103,16 @@ impl ProjectEnvironment {
abs_path = parent.into();
}
self.get_directory_environment(abs_path, cx)
self.get_local_directory_environment(&Shell::System, abs_path, cx)
}
/// Returns the project environment, if possible.
/// If the project was opened from the CLI, then the inherited CLI environment is returned.
/// If it wasn't opened from the CLI, and an absolute path is given, then a shell is spawned in
/// that directory, to get environment variables as if the user has `cd`'d there.
pub fn get_directory_environment(
pub fn get_local_directory_environment(
&mut self,
shell: &Shell,
abs_path: Arc<Path>,
cx: &mut Context<Self>,
) -> Shared<Task<Option<HashMap<String, String>>>> {
@ -136,26 +125,53 @@ impl ProjectEnvironment {
return Task::ready(Some(cli_environment)).shared();
}
self.environments
.entry(abs_path.clone())
self.local_environments
.entry((shell.clone(), abs_path.clone()))
.or_insert_with(|| {
get_directory_env_impl(&Shell::System, abs_path.clone(), cx).shared()
get_local_directory_environment_impl(shell, abs_path.clone(), cx).shared()
})
.clone()
}
/// Returns the project environment, if possible, with the given shell.
pub fn get_directory_environment_for_shell(
pub fn get_remote_directory_environment(
&mut self,
shell: &Shell,
abs_path: Arc<Path>,
remote_client: Entity<RemoteClient>,
cx: &mut Context<Self>,
) -> Shared<Task<Option<HashMap<String, String>>>> {
self.shell_based_environments
if cfg!(any(test, feature = "test-support")) {
return Task::ready(Some(HashMap::default())).shared();
}
self.remote_environments
.entry((shell.clone(), abs_path.clone()))
.or_insert_with(|| get_directory_env_impl(shell, abs_path.clone(), cx).shared())
.or_insert_with(|| {
let response =
remote_client
.read(cx)
.proto_client()
.request(proto::GetDirectoryEnvironment {
project_id: REMOTE_SERVER_PROJECT_ID,
shell: Some(shell.clone().to_proto()),
directory: abs_path.to_string_lossy().to_string(),
});
cx.spawn(async move |_, _| {
let environment = response.await.log_err()?;
Some(environment.environment.into_iter().collect())
})
.shared()
})
.clone()
}
pub fn peek_environment_error(&self) -> Option<&EnvironmentErrorMessage> {
self.environment_error_messages.front()
}
pub fn pop_environment_error(&mut self) -> Option<EnvironmentErrorMessage> {
self.environment_error_messages.pop_front()
}
}
fn set_origin_marker(env: &mut HashMap<String, String>, origin: EnvironmentOrigin) {
@ -307,7 +323,7 @@ async fn load_shell_environment(
}
}
fn get_directory_env_impl(
fn get_local_directory_environment_impl(
shell: &Shell,
abs_path: Arc<Path>,
cx: &Context<ProjectEnvironment>,
@ -341,8 +357,8 @@ fn get_directory_env_impl(
if let Some(error) = error_message {
this.update(cx, |this, cx| {
log::error!("{error}",);
this.environment_error_messages.insert(abs_path, error);
log::error!("{error}");
this.environment_error_messages.push_back(error);
cx.emit(ProjectEnvironmentEvent::ErrorsUpdated)
})
.log_err();

View file

@ -62,6 +62,7 @@ use std::{
time::Instant,
};
use sum_tree::{Edit, SumTree, TreeSet};
use task::Shell;
use text::{Bias, BufferId};
use util::{
ResultExt, debug_panic,
@ -4599,7 +4600,7 @@ impl Repository {
.upgrade()
.context("missing project environment")?
.update(cx, |project_environment, cx| {
project_environment.get_directory_environment(work_directory_abs_path.clone(), cx)
project_environment.get_local_directory_environment(&Shell::System, work_directory_abs_path.clone(), cx)
})?
.await
.unwrap_or_else(|| {

View file

@ -1912,20 +1912,24 @@ impl Project {
cx: &mut App,
) -> Shared<Task<Option<HashMap<String, String>>>> {
self.environment.update(cx, |environment, cx| {
environment.get_directory_environment_for_shell(shell, abs_path, cx)
if let Some(remote_client) = self.remote_client.clone() {
environment.get_remote_directory_environment(shell, abs_path, remote_client, cx)
} else {
environment.get_local_directory_environment(shell, abs_path, cx)
}
})
}
pub fn shell_environment_errors<'a>(
pub fn peek_environment_error<'a>(
&'a self,
cx: &'a App,
) -> impl Iterator<Item = (&'a Arc<Path>, &'a EnvironmentErrorMessage)> {
self.environment.read(cx).environment_errors()
) -> Option<&'a EnvironmentErrorMessage> {
self.environment.read(cx).peek_environment_error()
}
pub fn remove_environment_error(&mut self, abs_path: &Path, cx: &mut Context<Self>) {
self.environment.update(cx, |environment, cx| {
environment.remove_environment_error(abs_path, cx);
pub fn pop_environment_error(&mut self, cx: &mut Context<Self>) {
self.environment.update(cx, |environment, _| {
environment.pop_environment_error();
});
}

View file

@ -19,6 +19,7 @@ use rpc::{
},
};
use settings::WorktreeId;
use task::Shell;
use util::{ResultExt as _, rel_path::RelPath};
use crate::{
@ -521,7 +522,11 @@ impl LocalToolchainStore {
let project_env = environment
.update(cx, |environment, cx| {
environment.get_directory_environment(abs_path.as_path().into(), cx)
environment.get_local_directory_environment(
&Shell::System,
abs_path.as_path().into(),
cx,
)
})
.ok()?
.await;
@ -574,7 +579,11 @@ impl LocalToolchainStore {
let project_env = environment
.update(cx, |environment, cx| {
environment.get_directory_environment(path.as_path().into(), cx)
environment.get_local_directory_environment(
&Shell::System,
path.as_path().into(),
cx,
)
})?
.await;
cx.background_spawn(async move { toolchain_lister.resolve(path, project_env).await })

View file

@ -48,3 +48,13 @@ message SpawnInTerminal {
map<string, string> env = 4;
optional string cwd = 5;
}
message GetDirectoryEnvironment {
uint64 project_id = 1;
Shell shell = 2;
string directory = 3;
}
message DirectoryEnvironment {
map<string, string> environment = 1;
}

View file

@ -418,7 +418,10 @@ message Envelope {
GitRenameBranch git_rename_branch = 380;
RemoteStarted remote_started = 381; // current max
RemoteStarted remote_started = 381;
GetDirectoryEnvironment get_directory_environment = 382;
DirectoryEnvironment directory_environment = 383; // current max
}
reserved 87 to 88;

View file

@ -319,6 +319,8 @@ messages!(
(GitClone, Background),
(GitCloneResponse, Background),
(ToggleLspLogs, Background),
(GetDirectoryEnvironment, Background),
(DirectoryEnvironment, Background),
(GetAgentServerCommand, Background),
(AgentServerCommand, Background),
(ExternalAgentsUpdated, Background),
@ -497,6 +499,7 @@ request_messages!(
(GetDefaultBranch, GetDefaultBranchResponse),
(GitClone, GitCloneResponse),
(ToggleLspLogs, Ack),
(GetDirectoryEnvironment, DirectoryEnvironment),
(GetProcesses, GetProcessesResponse),
(GetAgentServerCommand, AgentServerCommand),
(RemoteStarted, Ack),
@ -634,6 +637,7 @@ entity_messages!(
GitCheckoutFiles,
SetIndexText,
ToggleLspLogs,
GetDirectoryEnvironment,
Push,
Fetch,

View file

@ -60,6 +60,7 @@ settings.workspace = true
shellexpand.workspace = true
smol.workspace = true
sysinfo.workspace = true
task.workspace = true
util.workspace = true
watch.workspace = true
worktree.workspace = true

View file

@ -50,6 +50,7 @@ pub struct HeadlessProject {
pub languages: Arc<LanguageRegistry>,
pub extensions: Entity<HeadlessExtensionStore>,
pub git_store: Entity<GitStore>,
pub environment: Entity<ProjectEnvironment>,
// Used mostly to keep alive the toolchain store for RPC handlers.
// Local variant is used within LSP store, but that's a separate entity.
pub _toolchain_store: Entity<ToolchainStore>,
@ -199,7 +200,7 @@ impl HeadlessProject {
let mut agent_server_store = AgentServerStore::local(
node_runtime.clone(),
fs.clone(),
environment,
environment.clone(),
http_client.clone(),
cx,
);
@ -255,6 +256,7 @@ impl HeadlessProject {
session.add_entity_request_handler(Self::handle_open_new_buffer);
session.add_entity_request_handler(Self::handle_find_search_candidates);
session.add_entity_request_handler(Self::handle_open_server_settings);
session.add_entity_request_handler(Self::handle_get_directory_environment);
session.add_entity_message_handler(Self::handle_toggle_lsp_logs);
session.add_entity_request_handler(BufferStore::handle_update_buffer);
@ -295,6 +297,7 @@ impl HeadlessProject {
languages,
extensions,
git_store,
environment,
_toolchain_store: toolchain_store,
}
}
@ -764,6 +767,26 @@ impl HeadlessProject {
Ok(proto::GetProcessesResponse { processes })
}
async fn handle_get_directory_environment(
this: Entity<Self>,
envelope: TypedEnvelope<proto::GetDirectoryEnvironment>,
mut cx: AsyncApp,
) -> Result<proto::DirectoryEnvironment> {
let shell = task::Shell::from_proto(envelope.payload.shell.context("missing shell")?)?;
let directory = PathBuf::from(envelope.payload.directory);
let environment = this
.update(&mut cx, |this, cx| {
this.environment.update(cx, |environment, cx| {
environment.get_local_directory_environment(&shell, directory.into(), cx)
})
})?
.await
.context("failed to get directory environment")?
.into_iter()
.collect();
Ok(proto::DirectoryEnvironment { environment })
}
}
fn prompt_to_proto(

View file

@ -9,6 +9,7 @@ mod task_template;
mod vscode_debug_format;
mod vscode_format;
use anyhow::Context as _;
use collections::{HashMap, HashSet, hash_map};
use gpui::SharedString;
use schemars::JsonSchema;
@ -361,6 +362,38 @@ impl Shell {
Shell::System => ShellKind::system(),
}
}
pub fn from_proto(proto: proto::Shell) -> anyhow::Result<Self> {
let shell_type = proto.shell_type.context("invalid shell type")?;
let shell = match shell_type {
proto::shell::ShellType::System(_) => Self::System,
proto::shell::ShellType::Program(program) => Self::Program(program),
proto::shell::ShellType::WithArguments(program) => Self::WithArguments {
program: program.program,
args: program.args,
title_override: None,
},
};
Ok(shell)
}
pub fn to_proto(self) -> proto::Shell {
let shell_type = match self {
Shell::System => proto::shell::ShellType::System(proto::System {}),
Shell::Program(program) => proto::shell::ShellType::Program(program),
Shell::WithArguments {
program,
args,
title_override: _,
} => proto::shell::ShellType::WithArguments(proto::shell::WithArguments {
program,
args,
}),
};
proto::Shell {
shell_type: Some(shell_type),
}
}
}
type VsCodeEnvVariable = String;