Cargo.lock 🔗
@@ -5861,6 +5861,7 @@ dependencies = [
"lsp",
"parking_lot",
"pretty_assertions",
+ "proto",
"semantic_version",
"serde",
"serde_json",
Richard Feldman , Lukas Wirth , and Mikayla Maki created
<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>
Cargo.lock | 1
crates/extension/Cargo.toml | 1
crates/extension/src/extension_manifest.rs | 30 +++
crates/project/src/agent_server_store.rs | 196 ++++++++++++++++++++---
crates/proto/proto/ai.proto | 21 ++
crates/proto/proto/zed.proto | 5
crates/proto/src/proto.rs | 2
docs/src/extensions/agent-servers.md | 19 +
8 files changed, 241 insertions(+), 34 deletions(-)
@@ -5861,6 +5861,7 @@ dependencies = [
"lsp",
"parking_lot",
"pretty_assertions",
+ "proto",
"semantic_version",
"serde",
"serde_json",
@@ -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
@@ -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)]
@@ -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
+ };
+
+ extension_agents.push((
+ agent_name.clone(),
+ ext_id.to_owned(),
+ agent_entry.targets.clone(),
+ agent_entry.env.clone(),
+ icon_path,
+ ));
+ }
+ }
+ self.reregister_agents(cx);
+ }
+ 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()),
);
- }
- // 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>,
- );
+ 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();
}
- _ => {
- // Only local projects support local extension agents
+ 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
@@ -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;
@@ -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;
@@ -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,
@@ -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