Support Agent Servers on remoting (#42683)

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>

Change summary

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(-)

Detailed changes

Cargo.lock 🔗

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

crates/extension/Cargo.toml 🔗

@@ -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

crates/extension/src/extension_manifest.rs 🔗

@@ -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)]

crates/project/src/agent_server_store.rs 🔗

@@ -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

crates/proto/proto/ai.proto 🔗

@@ -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;

crates/proto/proto/zed.proto 🔗

@@ -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;

crates/proto/src/proto.rs 🔗

@@ -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,

docs/src/extensions/agent-servers.md 🔗

@@ -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