Support Agent Servers on remoting (#42683)

<img width="348" height="359" alt="Screenshot 2025-11-13 at 6 53 39 PM"
src="https://github.com/user-attachments/assets/6fe75796-8ceb-4f98-9d35-005c90417fd4"
/>

Also added support for per-target env vars to Agent Server Extensions

Closes https://github.com/zed-industries/zed/issues/42291

Release Notes:

- Per-target env vars are now supported on Agent Server Extensions
- Agent Server Extensions are now available when doing SSH remoting

---------

Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>
This commit is contained in:
Richard Feldman 2025-11-17 10:48:14 -05:00 committed by GitHub
parent bb46bc167a
commit 4b050b651a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 241 additions and 34 deletions

1
Cargo.lock generated
View file

@ -5861,6 +5861,7 @@ dependencies = [
"lsp",
"parking_lot",
"pretty_assertions",
"proto",
"semantic_version",
"serde",
"serde_json",

View file

@ -25,6 +25,7 @@ language.workspace = true
log.workspace = true
lsp.workspace = true
parking_lot.workspace = true
proto.workspace = true
semantic_version.workspace = true
serde.workspace = true
serde_json.workspace = true

View file

@ -193,6 +193,36 @@ pub struct TargetConfig {
/// If not provided and the URL is a GitHub release, we'll attempt to fetch it from GitHub.
#[serde(default)]
pub sha256: Option<String>,
/// Environment variables to set when launching the agent server.
/// These target-specific env vars will override any env vars set at the agent level.
#[serde(default)]
pub env: HashMap<String, String>,
}
impl TargetConfig {
pub fn from_proto(proto: proto::ExternalExtensionAgentTarget) -> Self {
Self {
archive: proto.archive,
cmd: proto.cmd,
args: proto.args,
sha256: proto.sha256,
env: proto.env.into_iter().collect(),
}
}
pub fn to_proto(&self) -> proto::ExternalExtensionAgentTarget {
proto::ExternalExtensionAgentTarget {
archive: self.archive.clone(),
cmd: self.cmd.clone(),
args: self.args.clone(),
sha256: self.sha256.clone(),
env: self
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
}
}
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]

View file

@ -17,7 +17,10 @@ use gpui::{
use http_client::{HttpClient, github::AssetKind};
use node_runtime::NodeRuntime;
use remote::RemoteClient;
use rpc::{AnyProtoClient, TypedEnvelope, proto};
use rpc::{
AnyProtoClient, TypedEnvelope,
proto::{self, ExternalExtensionAgent},
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::{RegisterSetting, SettingsStore};
@ -114,6 +117,13 @@ enum AgentServerStoreState {
downstream_client: Option<(u64, AnyProtoClient)>,
settings: Option<AllAgentServersSettings>,
http_client: Arc<dyn HttpClient>,
extension_agents: Vec<(
Arc<str>,
String,
HashMap<String, extension::TargetConfig>,
HashMap<String, String>,
Option<String>,
)>,
_subscriptions: [Subscription; 1],
},
Remote {
@ -257,20 +267,15 @@ impl AgentServerStore {
});
// Insert agent servers from extension manifests
match &self.state {
match &mut self.state {
AgentServerStoreState::Local {
node_runtime,
project_environment,
fs,
http_client,
..
extension_agents, ..
} => {
extension_agents.clear();
for (ext_id, manifest) in manifests {
for (agent_name, agent_entry) in &manifest.agent_servers {
let display = SharedString::from(agent_entry.name.clone());
// Store absolute icon path if provided, resolving symlinks for dev extensions
if let Some(icon) = &agent_entry.icon {
let icon_path = if let Some(icon) = &agent_entry.icon {
let icon_path = extensions_dir.join(ext_id).join(icon);
// Canonicalize to resolve symlinks (dev extensions are symlinked)
let absolute_icon_path = icon_path
@ -279,30 +284,81 @@ impl AgentServerStore {
.to_string_lossy()
.to_string();
self.agent_icons.insert(
ExternalAgentServerName(display.clone()),
SharedString::from(absolute_icon_path),
ExternalAgentServerName(agent_name.clone().into()),
SharedString::from(absolute_icon_path.clone()),
);
}
Some(absolute_icon_path)
} else {
None
};
// Archive-based launcher (download from URL)
self.external_agents.insert(
ExternalAgentServerName(display),
Box::new(LocalExtensionArchiveAgent {
fs: fs.clone(),
http_client: http_client.clone(),
node_runtime: node_runtime.clone(),
project_environment: project_environment.clone(),
extension_id: Arc::from(ext_id),
agent_id: agent_name.clone(),
targets: agent_entry.targets.clone(),
env: agent_entry.env.clone(),
}) as Box<dyn ExternalAgentServer>,
);
extension_agents.push((
agent_name.clone(),
ext_id.to_owned(),
agent_entry.targets.clone(),
agent_entry.env.clone(),
icon_path,
));
}
}
self.reregister_agents(cx);
}
_ => {
// Only local projects support local extension agents
AgentServerStoreState::Remote {
project_id,
upstream_client,
} => {
let mut agents = vec![];
for (ext_id, manifest) in manifests {
for (agent_name, agent_entry) in &manifest.agent_servers {
// Store absolute icon path if provided, resolving symlinks for dev extensions
let icon = if let Some(icon) = &agent_entry.icon {
let icon_path = extensions_dir.join(ext_id).join(icon);
// Canonicalize to resolve symlinks (dev extensions are symlinked)
let absolute_icon_path = icon_path
.canonicalize()
.unwrap_or(icon_path)
.to_string_lossy()
.to_string();
// Store icon locally for remote client
self.agent_icons.insert(
ExternalAgentServerName(agent_name.clone().into()),
SharedString::from(absolute_icon_path.clone()),
);
Some(absolute_icon_path)
} else {
None
};
agents.push(ExternalExtensionAgent {
name: agent_name.to_string(),
icon_path: icon,
extension_id: ext_id.to_string(),
targets: agent_entry
.targets
.iter()
.map(|(k, v)| (k.clone(), v.to_proto()))
.collect(),
env: agent_entry
.env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
});
}
}
upstream_client
.read(cx)
.proto_client()
.send(proto::ExternalExtensionAgentsUpdated {
project_id: *project_id,
agents,
})
.log_err();
}
AgentServerStoreState::Collab => {
// Do nothing
}
}
@ -320,6 +376,7 @@ impl AgentServerStore {
}
pub fn init_headless(session: &AnyProtoClient) {
session.add_entity_message_handler(Self::handle_external_extension_agents_updated);
session.add_entity_request_handler(Self::handle_get_agent_server_command);
}
@ -354,6 +411,7 @@ impl AgentServerStore {
downstream_client,
settings: old_settings,
http_client,
extension_agents,
..
} = &mut self.state
else {
@ -420,6 +478,31 @@ impl AgentServerStore {
}) as Box<dyn ExternalAgentServer>,
)
}));
self.external_agents.extend(extension_agents.iter().map(
|(agent_name, ext_id, targets, env, icon_path)| {
let name = ExternalAgentServerName(agent_name.clone().into());
// Restore icon if present
if let Some(icon) = icon_path {
self.agent_icons
.insert(name.clone(), SharedString::from(icon.clone()));
}
(
name,
Box::new(LocalExtensionArchiveAgent {
fs: fs.clone(),
http_client: http_client.clone(),
node_runtime: node_runtime.clone(),
project_environment: project_environment.clone(),
extension_id: Arc::from(&**ext_id),
targets: targets.clone(),
env: env.clone(),
agent_id: agent_name.clone(),
}) as Box<dyn ExternalAgentServer>,
)
},
));
*old_settings = Some(new_settings.clone());
@ -463,6 +546,7 @@ impl AgentServerStore {
http_client,
downstream_client: None,
settings: None,
extension_agents: vec![],
_subscriptions: [subscription],
},
external_agents: Default::default(),
@ -728,6 +812,55 @@ impl AgentServerStore {
})?
}
async fn handle_external_extension_agents_updated(
this: Entity<Self>,
envelope: TypedEnvelope<proto::ExternalExtensionAgentsUpdated>,
mut cx: AsyncApp,
) -> Result<()> {
this.update(&mut cx, |this, cx| {
let AgentServerStoreState::Local {
extension_agents, ..
} = &mut this.state
else {
panic!(
"handle_external_extension_agents_updated \
should not be called for a non-remote project"
);
};
for ExternalExtensionAgent {
name,
icon_path,
extension_id,
targets,
env,
} in envelope.payload.agents
{
let icon_path_string = icon_path.clone();
if let Some(icon_path) = icon_path {
this.agent_icons.insert(
ExternalAgentServerName(name.clone().into()),
icon_path.into(),
);
}
extension_agents.push((
Arc::from(&*name),
extension_id,
targets
.into_iter()
.map(|(k, v)| (k, extension::TargetConfig::from_proto(v)))
.collect(),
env.into_iter().collect(),
icon_path_string,
));
}
this.reregister_agents(cx);
cx.emit(AgentServersUpdated);
Ok(())
})?
}
async fn handle_loading_status_updated(
this: Entity<Self>,
envelope: TypedEnvelope<proto::ExternalAgentLoadingStatusUpdated>,
@ -1830,6 +1963,7 @@ mod extension_agent_tests {
cmd: "./agent".into(),
args: vec![],
sha256: None,
env: Default::default(),
},
);
@ -1870,6 +2004,7 @@ mod extension_agent_tests {
cmd: "./my-agent".into(),
args: vec!["--serve".into()],
sha256: None,
env: Default::default(),
},
);
map
@ -1907,6 +2042,7 @@ mod extension_agent_tests {
cmd: "./release-agent".into(),
args: vec!["serve".into()],
sha256: None,
env: Default::default(),
},
);
@ -1949,6 +2085,7 @@ mod extension_agent_tests {
cmd: "node".into(),
args: vec!["index.js".into()],
sha256: None,
env: Default::default(),
},
);
map
@ -1995,6 +2132,7 @@ mod extension_agent_tests {
"./config.json".into(),
],
sha256: None,
env: Default::default(),
},
);
map

View file

@ -186,6 +186,27 @@ message ExternalAgentsUpdated {
repeated string names = 2;
}
message ExternalExtensionAgentTarget {
string archive = 1;
string cmd = 2;
repeated string args = 3;
optional string sha256 = 4;
map<string, string> env = 5;
}
message ExternalExtensionAgent {
string name = 1;
optional string icon_path = 2;
string extension_id = 3;
map<string, ExternalExtensionAgentTarget> targets = 4;
map<string, string> env = 5;
}
message ExternalExtensionAgentsUpdated {
uint64 project_id = 1;
repeated ExternalExtensionAgent agents = 2;
}
message ExternalAgentLoadingStatusUpdated {
uint64 project_id = 1;
string name = 2;

View file

@ -410,7 +410,6 @@ message Envelope {
AgentServerCommand agent_server_command = 374;
ExternalAgentsUpdated external_agents_updated = 375;
ExternalAgentLoadingStatusUpdated external_agent_loading_status_updated = 376;
NewExternalAgentVersionAvailable new_external_agent_version_available = 377;
@ -436,7 +435,9 @@ message Envelope {
OpenImageByPath open_image_by_path = 391;
OpenImageResponse open_image_response = 392;
CreateImageForPeer create_image_for_peer = 393; // current max
CreateImageForPeer create_image_for_peer = 393;
ExternalExtensionAgentsUpdated external_extension_agents_updated = 394; // current max
}
reserved 87 to 88;

View file

@ -331,6 +331,7 @@ messages!(
(GetAgentServerCommand, Background),
(AgentServerCommand, Background),
(ExternalAgentsUpdated, Background),
(ExternalExtensionAgentsUpdated, Background),
(ExternalAgentLoadingStatusUpdated, Background),
(NewExternalAgentVersionAvailable, Background),
(RemoteStarted, Background),
@ -681,6 +682,7 @@ entity_messages!(
GitClone,
GetAgentServerCommand,
ExternalAgentsUpdated,
ExternalExtensionAgentsUpdated,
ExternalAgentLoadingStatusUpdated,
NewExternalAgentVersionAvailable,
GitGetWorktrees,

View file

@ -46,15 +46,25 @@ Each target must specify:
- `archive`: URL to download the archive from (supports `.tar.gz`, `.zip`, etc.)
- `cmd`: Command to run the agent server (relative to the extracted archive)
- `args`: Command-line arguments to pass to the agent server (optional)
- `sha256`: SHA-256 hash string of the archive's bytes (optional, but recommended for security)
- `env`: Environment variables specific to this target (optional, overrides agent-level env vars with the same name)
### Optional Fields
You can also optionally specify:
You can also optionally specify at the agent server level:
- `sha256`: SHA-256 hash string of the archive's bytes. Zed will check this after the archive is downloaded and give an error if it doesn't match, so doing this improves security.
- `env`: Environment variables to set in the agent's spawned process.
- `env`: Environment variables to set in the agent's spawned process. These apply to all targets by default.
- `icon`: Path to an SVG icon (relative to extension root) for display in menus.
### Environment Variables
Environment variables can be configured at two levels:
1. **Agent-level** (`[agent_servers.my-agent.env]`): Variables that apply to all platforms
2. **Target-level** (`[agent_servers.my-agent.targets.{platform}.env]`): Variables specific to a platform
When both are specified, target-level environment variables override agent-level variables with the same name. Variables defined only at the agent level are inherited by all targets.
### Complete Example
Here's a more complete example with all optional fields:
@ -79,6 +89,9 @@ archive = "https://github.com/example/agent/releases/download/v2.0.0/agent-linux
cmd = "./bin/agent"
args = ["serve", "--port", "8080"]
sha256 = "def456abc123..."
[agent_servers.example-agent.targets.linux-x86_64.env]
AGENT_MEMORY_LIMIT = "2GB" # Linux-specific override
```
## Installation Process