mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-24 05:25:18 +00:00
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:
parent
bb46bc167a
commit
4b050b651a
8 changed files with 241 additions and 34 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -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
|
||||
};
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue