acp: Allow running NPM agents from registry (#47291)

Release Notes:

- N/A
This commit is contained in:
Ben Brandt authored and GitHub committed 2026-01-21 14:52:44 +00:00
1 parent e5af0f1e46
commit 5d1e65bdfb
4 files changed
+447 -156

No files matched your search

+173 -79
View File
@@ -8,6 +8,7 @@ use semver::Version;
use serde::Deserialize;
use smol::io::BufReader;
use smol::{fs, lock::Mutex};
use std::collections::HashMap;
use std::fmt::Display;
use std::{
env::{self, consts},
@@ -30,6 +31,15 @@ pub struct NodeBinaryOptions {
pub use_paths: Option<(PathBuf, PathBuf)>,
}
/// Use this when you need to launch npm as a long-lived process (for example, an agent server),
/// so the invocation and environment stay consistent with the Node runtime's proxy and CA setup.
#[derive(Clone, Debug)]
pub struct NpmCommand {
pub path: PathBuf,
pub args: Vec<String>,
pub env: HashMap<String, String>,
}
pub enum VersionStrategy<'a> {
/// Install if current version doesn't match pinned version
Pin(&'a Version),
@@ -86,7 +96,7 @@ impl NodeRuntime {
Err(err) => {
return Box::new(UnavailableNodeRuntime {
error_message: err.to_string().into(),
});
}) as Box<dyn NodeRuntimeTrait>;
}
}
};
@@ -128,7 +138,7 @@ impl NodeRuntime {
log::info!("using Node.js found on PATH: {:?}", instance);
state.instance = Some(instance.boxed_clone());
state.last_options = Some(options);
return Box::new(instance);
return Box::new(instance) as Box<dyn NodeRuntimeTrait>;
}
Err(err) => Some(err),
}
@@ -228,6 +238,14 @@ impl NodeRuntime {
.await
}
pub async fn npm_command(&self, subcommand: &str, args: &[&str]) -> Result<NpmCommand> {
let http = self.0.lock().await.http.clone();
self.instance()
.await
.npm_command(http.proxy(), subcommand, args)
.await
}
pub async fn npm_package_latest_version(&self, name: &str) -> Result<Version> {
let http = self.0.lock().await.http.clone();
let output = self
@@ -352,6 +370,13 @@ trait NodeRuntimeTrait: Send + Sync {
args: &[&str],
) -> Result<Output>;
async fn npm_command(
&self,
proxy: Option<&Url>,
subcommand: &str,
args: &[&str],
) -> Result<NpmCommand>;
async fn npm_package_installed_version(
&self,
local_package_directory: &Path,
@@ -528,40 +553,12 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
subcommand: &str,
args: &[&str],
) -> Result<Output> {
let attempt = || async move {
let node_binary = self.installation_path.join(Self::NODE_PATH);
let npm_file = self.installation_path.join(Self::NPM_PATH);
let env_path = path_with_node_binary_prepended(&node_binary).unwrap_or_default();
anyhow::ensure!(
smol::fs::metadata(&node_binary).await.is_ok(),
"missing node binary file"
);
anyhow::ensure!(
smol::fs::metadata(&npm_file).await.is_ok(),
"missing npm file"
);
let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
let mut command = util::command::new_smol_command(node_binary);
command.env("PATH", env_path);
command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs);
command.arg(npm_file).arg(subcommand);
command.arg(format!(
"--cache={}",
self.installation_path.join("cache").display()
));
command.args([
"--userconfig".into(),
self.installation_path.join("blank_user_npmrc"),
]);
command.args([
"--globalconfig".into(),
self.installation_path.join("blank_global_npmrc"),
]);
command.args(args);
configure_npm_command(&mut command, directory, proxy);
let attempt = || async {
let npm_command = self.npm_command(proxy, subcommand, args).await?;
let mut command = util::command::new_smol_command(npm_command.path);
command.args(npm_command.args);
command.envs(npm_command.env);
configure_npm_command(&mut command, directory);
command.output().await.map_err(|e| anyhow!("{e}"))
};
@@ -586,6 +583,43 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
output.map_err(|e| anyhow!("{e}"))
}
async fn npm_command(
&self,
proxy: Option<&Url>,
subcommand: &str,
args: &[&str],
) -> Result<NpmCommand> {
let node_binary = self.installation_path.join(Self::NODE_PATH);
let npm_file = self.installation_path.join(Self::NPM_PATH);
anyhow::ensure!(
smol::fs::metadata(&node_binary).await.is_ok(),
"missing node binary file"
);
anyhow::ensure!(
smol::fs::metadata(&npm_file).await.is_ok(),
"missing npm file"
);
let command_args = build_npm_command_args(
Some(&npm_file),
&self.installation_path.join("cache"),
Some(&self.installation_path.join("blank_user_npmrc")),
Some(&self.installation_path.join("blank_global_npmrc")),
proxy,
subcommand,
args,
);
let command_env = npm_command_env(Some(&node_binary));
Ok(NpmCommand {
path: node_binary,
args: command_args,
env: command_env,
})
}
async fn npm_package_installed_version(
&self,
local_package_directory: &Path,
@@ -688,19 +722,11 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
subcommand: &str,
args: &[&str],
) -> anyhow::Result<Output> {
let node_ca_certs = env::var(NODE_CA_CERTS_ENV_VAR).unwrap_or_else(|_| String::new());
let mut command = util::command::new_smol_command(self.npm.clone());
let path = path_with_node_binary_prepended(&self.node).unwrap_or_default();
command
.env("PATH", path)
.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
.arg(subcommand)
.arg(format!(
"--cache={}",
self.scratch_dir.join("cache").display()
))
.args(args);
configure_npm_command(&mut command, directory, proxy);
let npm_command = self.npm_command(proxy, subcommand, args).await?;
let mut command = util::command::new_smol_command(npm_command.path);
command.args(npm_command.args);
command.envs(npm_command.env);
configure_npm_command(&mut command, directory);
let output = command.output().await?;
anyhow::ensure!(
output.status.success(),
@@ -711,6 +737,30 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
Ok(output)
}
async fn npm_command(
&self,
proxy: Option<&Url>,
subcommand: &str,
args: &[&str],
) -> Result<NpmCommand> {
let command_args = build_npm_command_args(
None,
&self.scratch_dir.join("cache"),
None,
None,
proxy,
subcommand,
args,
);
let command_env = npm_command_env(Some(&self.node));
Ok(NpmCommand {
path: self.npm.clone(),
args: command_args,
env: command_env,
})
}
async fn npm_package_installed_version(
&self,
local_package_directory: &Path,
@@ -773,6 +823,15 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime {
bail!("{}", self.error_message)
}
async fn npm_command(
&self,
_proxy: Option<&Url>,
_subcommand: &str,
_args: &[&str],
) -> Result<NpmCommand> {
bail!("{}", self.error_message)
}
async fn npm_package_installed_version(
&self,
_local_package_directory: &Path,
@@ -782,59 +841,101 @@ impl NodeRuntimeTrait for UnavailableNodeRuntime {
}
}
fn configure_npm_command(
command: &mut smol::process::Command,
directory: Option<&Path>,
proxy: Option<&Url>,
) {
fn configure_npm_command(command: &mut smol::process::Command, directory: Option<&Path>) {
if let Some(directory) = directory {
command.current_dir(directory);
command.args(["--prefix".into(), directory.to_path_buf()]);
}
}
if let Some(mut proxy) = proxy.cloned() {
// Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809`
// NodeRuntime without environment information can not parse `localhost`
// correctly.
// TODO: map to `[::1]` if we are using ipv6
if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost"))
{
// When localhost is a valid Host, so is `127.0.0.1`
let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST));
fn proxy_argument(proxy: Option<&Url>) -> Option<String> {
let mut proxy = proxy.cloned()?;
// Map proxy settings from `http://localhost:10809` to `http://127.0.0.1:10809`
// NodeRuntime without environment information can not parse `localhost`
// correctly.
// TODO: map to `[::1]` if we are using ipv6
if matches!(proxy.host(), Some(Host::Domain(domain)) if domain.eq_ignore_ascii_case("localhost"))
{
// When localhost is a valid Host, so is `127.0.0.1`
let _ = proxy.set_ip_host(IpAddr::V4(Ipv4Addr::LOCALHOST));
}
Some(proxy.as_str().to_string())
}
fn build_npm_command_args(
entrypoint: Option<&Path>,
cache_dir: &Path,
user_config: Option<&Path>,
global_config: Option<&Path>,
proxy: Option<&Url>,
subcommand: &str,
args: &[&str],
) -> Vec<String> {
let mut command_args = Vec::new();
if let Some(entrypoint) = entrypoint {
command_args.push(entrypoint.to_string_lossy().into_owned());
}
command_args.push(subcommand.to_string());
command_args.push(format!("--cache={}", cache_dir.display()));
if let Some(user_config) = user_config {
command_args.push("--userconfig".into());
command_args.push(user_config.to_string_lossy().into_owned());
}
if let Some(global_config) = global_config {
command_args.push("--globalconfig".into());
command_args.push(global_config.to_string_lossy().into_owned());
}
if let Some(proxy_arg) = proxy_argument(proxy) {
command_args.push("--proxy".into());
command_args.push(proxy_arg);
}
command_args.extend(args.into_iter().map(|a| a.to_string()));
command_args
}
fn npm_command_env(node_binary: Option<&Path>) -> HashMap<String, String> {
let mut command_env = HashMap::new();
if let Some(node_binary) = node_binary {
let env_path = path_with_node_binary_prepended(node_binary).unwrap_or_default();
command_env.insert("PATH".into(), env_path.to_string_lossy().into_owned());
}
if let Ok(node_ca_certs) = env::var(NODE_CA_CERTS_ENV_VAR) {
if !node_ca_certs.is_empty() {
command_env.insert(NODE_CA_CERTS_ENV_VAR.to_string(), node_ca_certs);
}
command.args(["--proxy", proxy.as_str()]);
}
#[cfg(windows)]
{
// SYSTEMROOT is a critical environment variables for Windows.
if let Some(val) = env::var("SYSTEMROOT")
.context("Missing environment variable: SYSTEMROOT!")
.log_err()
{
command.env("SYSTEMROOT", val);
command_env.insert("SYSTEMROOT".into(), val);
}
// Without ComSpec, the post-install will always fail.
if let Some(val) = env::var("ComSpec")
.context("Missing environment variable: ComSpec!")
.log_err()
{
command.env("ComSpec", val);
command_env.insert("ComSpec".into(), val);
}
}
command_env
}
#[cfg(test)]
mod tests {
use http_client::Url;
use super::configure_npm_command;
use super::proxy_argument;
// Map localhost to 127.0.0.1
// NodeRuntime without environment information can not parse `localhost` correctly.
#[test]
fn test_configure_npm_command_map_localhost_proxy() {
fn test_proxy_argument_map_localhost_proxy() {
const CASES: [(&str, &str); 4] = [
// Map localhost to 127.0.0.1
("http://localhost:9090/", "http://127.0.0.1:9090/"),
@@ -851,15 +952,8 @@ mod tests {
];
for (proxy, mapped_proxy) in CASES {
let mut dummy = smol::process::Command::new("");
let proxy = Url::parse(proxy).unwrap();
configure_npm_command(&mut dummy, None, Some(&proxy));
let proxy = dummy
.get_args()
.skip_while(|&arg| arg != "--proxy")
.skip(1)
.next();
let proxy = proxy.expect("Proxy was not passed to Command correctly");
let proxy = proxy_argument(Some(&proxy)).expect("Proxy was not passed correctly");
assert_eq!(
proxy, mapped_proxy,
"Incorrectly mapped localhost to 127.0.0.1"