diff --git a/Cargo.lock b/Cargo.lock index 67fa79b009fe59b052c22f77cf3b3b1c364d0c66..1b70d2680e5e1e15d916511440ea4b73174373aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5861,6 +5861,7 @@ dependencies = [ "lsp", "parking_lot", "pretty_assertions", + "proto", "semantic_version", "serde", "serde_json", diff --git a/crates/extension/Cargo.toml b/crates/extension/Cargo.toml index e9f1c71908b633362b349df451f8e9743269412a..09492027a1bb59770e3ac70166f042cae8e22d29 100644 --- a/crates/extension/Cargo.toml +++ b/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 diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index a3374069f7da6a30f455601b3bc0d4b027f207ae..11cefa339b24f8d6707c0f683ec38b50394c6a9e 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/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, + /// 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, +} + +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)] diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 67d3a0b8132be1db487fe347f3b79e42a8b5910d..f1fb210084fb118832f5ca8f5ffa78990c892aa1 100644 --- a/crates/project/src/agent_server_store.rs +++ b/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, http_client: Arc, + extension_agents: Vec<( + Arc, + String, + HashMap, + HashMap, + Option, + )>, _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, - ); + 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, ) })); + 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, + ) + }, + )); *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, + envelope: TypedEnvelope, + 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, envelope: TypedEnvelope, @@ -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 diff --git a/crates/proto/proto/ai.proto b/crates/proto/proto/ai.proto index 9b4cc27dcb9755f5205907cc5fd93687aa76bc4f..2216446a825c9ca3954306e80b9ccaaf06215306 100644 --- a/crates/proto/proto/ai.proto +++ b/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 env = 5; +} + +message ExternalExtensionAgent { + string name = 1; + optional string icon_path = 2; + string extension_id = 3; + map targets = 4; + map env = 5; +} + +message ExternalExtensionAgentsUpdated { + uint64 project_id = 1; + repeated ExternalExtensionAgent agents = 2; +} + message ExternalAgentLoadingStatusUpdated { uint64 project_id = 1; string name = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 34987ba06754be2db31aea51b384e7e099dca728..6ecea916ca5143ecd75678cd2e21587087f67b51 100644 --- a/crates/proto/proto/zed.proto +++ b/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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 0d9ffd5e0491a65e5ff39a67af5a2efd015476fc..fa6af5c3899da3519ce13d772bdc61fb78194d19 100644 --- a/crates/proto/src/proto.rs +++ b/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, diff --git a/docs/src/extensions/agent-servers.md b/docs/src/extensions/agent-servers.md index ce6204e33ee0afd91d705cd90fe4134b9652f8be..c8367a8418d07f827258403587a9787779f55cb9 100644 --- a/docs/src/extensions/agent-servers.md +++ b/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