重构Agent管理/链接
This commit is contained in:
@@ -123,7 +123,7 @@ async fn build_agent(
|
||||
|
||||
match meta.distribution {
|
||||
AgentDistribution::Npx {
|
||||
package, args, env, ..
|
||||
cmd, args, env, ..
|
||||
} => {
|
||||
let merged_env = merge_agent_env(env, runtime_env);
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
@@ -131,12 +131,10 @@ async fn build_agent(
|
||||
parts.push(format!("{k}={v}"));
|
||||
}
|
||||
parts.push(
|
||||
crate::process::normalized_program("npx")
|
||||
crate::process::normalized_program(cmd)
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
parts.push("-y".into());
|
||||
parts.push(package.into());
|
||||
for a in args {
|
||||
parts.push((*a).into());
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@ use crate::acp::binary_cache;
|
||||
use crate::acp::registry::{self, AgentDistribution};
|
||||
use crate::models::agent::AgentType;
|
||||
|
||||
/// Cache for NPX environment check results.
|
||||
/// Cache for npm environment check results.
|
||||
/// Stores `Some(checks)` after a successful (all-pass) run;
|
||||
/// stays `None` if checks failed so they are retried next time.
|
||||
static NPX_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
|
||||
static NPM_ENV_CACHE: Mutex<Option<Vec<CheckItem>>> = Mutex::new(None);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -52,7 +52,7 @@ pub async fn run_preflight(agent_type: AgentType) -> PreflightResult {
|
||||
let meta = registry::get_agent_meta(agent_type);
|
||||
debug_assert_eq!(meta.agent_type, agent_type);
|
||||
let checks = match &meta.distribution {
|
||||
AgentDistribution::Npx { node_required, .. } => check_npx_environment(*node_required).await,
|
||||
AgentDistribution::Npx { node_required, .. } => check_npm_environment(*node_required).await,
|
||||
AgentDistribution::Binary {
|
||||
version,
|
||||
cmd,
|
||||
@@ -73,11 +73,11 @@ pub async fn run_preflight(agent_type: AgentType) -> PreflightResult {
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_npx_environment(node_required: Option<&str>) -> Vec<CheckItem> {
|
||||
async fn check_npm_environment(node_required: Option<&str>) -> Vec<CheckItem> {
|
||||
// Return cached result if a previous check passed.
|
||||
// The cache stores only the base checks (node_available + npx_available);
|
||||
// The cache stores only the base checks (node_available + npm_available);
|
||||
// the per-agent node_version check is appended separately.
|
||||
let cached = NPX_ENV_CACHE.lock().unwrap().clone();
|
||||
let cached = NPM_ENV_CACHE.lock().unwrap().clone();
|
||||
if let Some(cached) = cached {
|
||||
let mut checks = cached;
|
||||
if let Some(required) = node_required {
|
||||
@@ -89,12 +89,12 @@ async fn check_npx_environment(node_required: Option<&str>) -> Vec<CheckItem> {
|
||||
return checks;
|
||||
}
|
||||
|
||||
// Run node and npx checks in parallel
|
||||
let (node_result, npx_result) = tokio::join!(
|
||||
// Run node and npm checks in parallel
|
||||
let (node_result, npm_result) = tokio::join!(
|
||||
crate::process::tokio_command("node")
|
||||
.arg("--version")
|
||||
.output(),
|
||||
crate::process::tokio_command("npx")
|
||||
crate::process::tokio_command("npm")
|
||||
.arg("--version")
|
||||
.output(),
|
||||
);
|
||||
@@ -127,22 +127,22 @@ async fn check_npx_environment(node_required: Option<&str>) -> Vec<CheckItem> {
|
||||
},
|
||||
};
|
||||
|
||||
let npx_check = match npx_result {
|
||||
let npm_check = match npm_result {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
CheckItem {
|
||||
check_id: "npx_available".into(),
|
||||
label: "npx".into(),
|
||||
check_id: "npm_available".into(),
|
||||
label: "npm".into(),
|
||||
status: CheckStatus::Pass,
|
||||
message: format!("npx {version} available"),
|
||||
message: format!("npm {version} available"),
|
||||
fixes: vec![],
|
||||
}
|
||||
}
|
||||
_ => CheckItem {
|
||||
check_id: "npx_available".into(),
|
||||
label: "npx".into(),
|
||||
check_id: "npm_available".into(),
|
||||
label: "npm".into(),
|
||||
status: CheckStatus::Fail,
|
||||
message: "npx is not installed or not in PATH".into(),
|
||||
message: "npm is not installed or not in PATH".into(),
|
||||
fixes: vec![FixAction {
|
||||
label: "Install Node.js".into(),
|
||||
kind: FixActionKind::OpenUrl,
|
||||
@@ -151,7 +151,7 @@ async fn check_npx_environment(node_required: Option<&str>) -> Vec<CheckItem> {
|
||||
},
|
||||
};
|
||||
|
||||
let mut checks = vec![node_check, npx_check];
|
||||
let mut checks = vec![node_check, npm_check];
|
||||
|
||||
// Cache only if all checks passed — failed results are not cached so
|
||||
// the user can retry after installing the missing tools.
|
||||
@@ -159,7 +159,7 @@ async fn check_npx_environment(node_required: Option<&str>) -> Vec<CheckItem> {
|
||||
.iter()
|
||||
.all(|c| !matches!(c.status, CheckStatus::Fail));
|
||||
if all_passed {
|
||||
*NPX_ENV_CACHE.lock().unwrap() = Some(checks.clone());
|
||||
*NPM_ENV_CACHE.lock().unwrap() = Some(checks.clone());
|
||||
}
|
||||
|
||||
// After caching the base checks, append the per-agent Node.js version
|
||||
|
||||
@@ -78,153 +78,24 @@ fn package_name_from_spec(package: &str) -> String {
|
||||
normalized.to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum NpmPackageBin {
|
||||
Single(String),
|
||||
Multiple(BTreeMap<String, String>),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NpmPackageManifest {
|
||||
version: Option<String>,
|
||||
bin: Option<NpmPackageBin>,
|
||||
}
|
||||
|
||||
fn read_npx_cached_package_version(package_dir: &Path) -> Option<String> {
|
||||
let manifest_path = package_dir.join("package.json");
|
||||
let content = std::fs::read_to_string(manifest_path).ok()?;
|
||||
let manifest: NpmPackageManifest = serde_json::from_str(&content).ok()?;
|
||||
manifest
|
||||
.version
|
||||
.as_deref()
|
||||
.and_then(normalize_version_candidate)
|
||||
}
|
||||
|
||||
fn read_npx_cached_package_manifest(package_dir: &Path) -> Option<NpmPackageManifest> {
|
||||
let manifest_path = package_dir.join("package.json");
|
||||
let content = std::fs::read_to_string(manifest_path).ok()?;
|
||||
serde_json::from_str(&content).ok()
|
||||
}
|
||||
|
||||
fn npx_package_parts(package: &str) -> Vec<String> {
|
||||
package_name_from_spec(package)
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn npx_cached_package_dirs(cache_dir: &Path, package: &str) -> Vec<PathBuf> {
|
||||
let package_parts = npx_package_parts(package);
|
||||
if package_parts.is_empty() {
|
||||
return vec![];
|
||||
async fn detect_global_cmd_version(cmd: &str) -> Option<String> {
|
||||
let output = crate::process::tokio_command(cmd)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let npx_root = cache_dir.join("_npx");
|
||||
let Ok(entries) = std::fs::read_dir(&npx_root) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let root = entry.path();
|
||||
if !root.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut package_dir = root.join("node_modules");
|
||||
for part in &package_parts {
|
||||
package_dir = package_dir.join(part);
|
||||
}
|
||||
if package_dir.is_dir() {
|
||||
dirs.push(package_dir);
|
||||
}
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ensure_executable(path: &Path) -> std::io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
let mut permissions = metadata.permissions();
|
||||
let current = permissions.mode();
|
||||
let next = current | 0o111;
|
||||
if next != current {
|
||||
permissions.set_mode(next);
|
||||
std::fs::set_permissions(path, permissions)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn ensure_executable(_path: &Path) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_npx_cached_bins_executable(package: &str) -> Result<(), AcpError> {
|
||||
let Some(cache_dir) = npm_cache_dir().await else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
for package_dir in npx_cached_package_dirs(&cache_dir, package) {
|
||||
let Some(manifest) = read_npx_cached_package_manifest(&package_dir) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut bin_rel_paths = Vec::new();
|
||||
match manifest.bin {
|
||||
Some(NpmPackageBin::Single(path)) => bin_rel_paths.push(path),
|
||||
Some(NpmPackageBin::Multiple(map)) => {
|
||||
bin_rel_paths.extend(map.into_values());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
for rel_path in bin_rel_paths {
|
||||
let script_path = package_dir.join(rel_path);
|
||||
if !script_path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = ensure_executable(&script_path) {
|
||||
return Err(AcpError::protocol(format!(
|
||||
"failed to set executable permission for npx package script: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn detect_npx_cached_version(package: &str) -> Option<String> {
|
||||
let cache_dir = npm_cache_dir().await?;
|
||||
let expected = version_from_package_spec(package);
|
||||
let mut detected = None;
|
||||
|
||||
for package_dir in npx_cached_package_dirs(&cache_dir, package) {
|
||||
let version = read_npx_cached_package_version(&package_dir).or_else(|| expected.clone());
|
||||
if let Some(found) = version {
|
||||
if expected.as_deref() == Some(found.as_str()) {
|
||||
return Some(found);
|
||||
}
|
||||
if detected.is_none() {
|
||||
detected = Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detected
|
||||
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
normalize_version_candidate(&raw)
|
||||
}
|
||||
|
||||
async fn detect_local_version(agent_type: AgentType) -> Option<String> {
|
||||
let meta = registry::get_agent_meta(agent_type);
|
||||
match meta.distribution {
|
||||
registry::AgentDistribution::Npx { package, .. } => {
|
||||
detect_npx_cached_version(package).await
|
||||
registry::AgentDistribution::Npx { cmd, .. } => {
|
||||
detect_global_cmd_version(cmd).await
|
||||
}
|
||||
registry::AgentDistribution::Binary { cmd, .. } => {
|
||||
binary_cache::detect_installed_version(agent_type, cmd)
|
||||
@@ -234,104 +105,49 @@ async fn detect_local_version(agent_type: AgentType) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_npx_package(package: &str) -> Result<(), AcpError> {
|
||||
let output = crate::process::tokio_command("npx")
|
||||
.arg("--yes")
|
||||
.arg("--package")
|
||||
async fn install_npm_global_package(package: &str) -> Result<(), AcpError> {
|
||||
let output = crate::process::tokio_command("npm")
|
||||
.arg("install")
|
||||
.arg("-g")
|
||||
.arg(package)
|
||||
.arg("--")
|
||||
.arg("node")
|
||||
.arg("-e")
|
||||
.arg("process.exit(0)")
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| AcpError::protocol(format!("failed to run npx: {e}")))?;
|
||||
.map_err(|e| AcpError::protocol(format!("failed to run npm install -g: {e}")))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let msg = if err.is_empty() {
|
||||
"failed to prepare npx package".to_string()
|
||||
"failed to install npm package globally".to_string()
|
||||
} else {
|
||||
format!("failed to prepare npx package: {err}")
|
||||
format!("failed to install npm package globally: {err}")
|
||||
};
|
||||
return Err(AcpError::protocol(msg));
|
||||
}
|
||||
|
||||
// Some npm packages ship bin scripts without executable bit.
|
||||
// Normalize permissions in local npx cache to avoid runtime spawn failures.
|
||||
ensure_npx_cached_bins_executable(package).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn npm_cache_dir() -> Option<PathBuf> {
|
||||
let output = crate::process::tokio_command("npm")
|
||||
.arg("config")
|
||||
.arg("get")
|
||||
.arg("cache")
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if raw.is_empty() || raw.eq_ignore_ascii_case("undefined") {
|
||||
return None;
|
||||
}
|
||||
Some(PathBuf::from(raw))
|
||||
}
|
||||
|
||||
fn remove_npx_package_cache(cache_dir: &Path, package_name: &str) -> Result<(), AcpError> {
|
||||
let npx_root = cache_dir.join("_npx");
|
||||
if !npx_root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let package_parts = package_name
|
||||
.split('/')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if package_parts.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(&npx_root)
|
||||
.map_err(|e| AcpError::protocol(format!("failed to read npx cache directory: {e}")))?;
|
||||
for entry in entries.flatten() {
|
||||
let root = entry.path();
|
||||
if !root.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let mut package_dir = root.join("node_modules");
|
||||
for part in &package_parts {
|
||||
package_dir = package_dir.join(part);
|
||||
}
|
||||
if package_dir.exists() {
|
||||
std::fs::remove_dir_all(&package_dir).map_err(|e| {
|
||||
AcpError::protocol(format!("failed to remove npx package cache: {e}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn uninstall_npx_package(package: &str) -> Result<(), AcpError> {
|
||||
async fn uninstall_npm_global_package(package: &str) -> Result<(), AcpError> {
|
||||
let package_name = package_name_from_spec(package);
|
||||
|
||||
if !package_name.is_empty() {
|
||||
// Best effort: if package was installed globally, remove it as well.
|
||||
let _ = crate::process::tokio_command("npm")
|
||||
let output = crate::process::tokio_command("npm")
|
||||
.arg("uninstall")
|
||||
.arg("-g")
|
||||
.arg(&package_name)
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
.await
|
||||
.map_err(|e| AcpError::protocol(format!("failed to run npm uninstall -g: {e}")))?;
|
||||
|
||||
if let Some(cache_dir) = npm_cache_dir().await {
|
||||
remove_npx_package_cache(&cache_dir, &package_name)?;
|
||||
if !output.status.success() {
|
||||
let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let msg = if err.is_empty() {
|
||||
"failed to uninstall npm package globally".to_string()
|
||||
} else {
|
||||
format!("failed to uninstall npm package globally: {err}")
|
||||
};
|
||||
return Err(AcpError::protocol(msg));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1214,11 +1030,9 @@ pub async fn acp_connect(
|
||||
runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into());
|
||||
}
|
||||
|
||||
if let registry::AgentDistribution::Npx { package, .. } = meta.distribution {
|
||||
if detect_npx_cached_version(package).await.is_none() {
|
||||
prepare_npx_package(package).await?;
|
||||
} else {
|
||||
ensure_npx_cached_bins_executable(package).await?;
|
||||
if let registry::AgentDistribution::Npx { cmd, package, .. } = meta.distribution {
|
||||
if detect_global_cmd_version(cmd).await.is_none() {
|
||||
install_npm_global_package(package).await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1635,7 +1449,7 @@ pub async fn acp_prepare_npx_agent(
|
||||
.flatten()
|
||||
.and_then(|m| m.installed_version);
|
||||
|
||||
prepare_npx_package(package).await?;
|
||||
install_npm_global_package(package).await?;
|
||||
let resolved = detect_local_version(agent_type)
|
||||
.await
|
||||
.or_else(|| version_from_package_spec(package))
|
||||
@@ -1647,7 +1461,7 @@ pub async fn acp_prepare_npx_agent(
|
||||
.or(existing)
|
||||
.ok_or_else(|| {
|
||||
AcpError::protocol(
|
||||
"npx install succeeded but failed to determine local version",
|
||||
"npm global install succeeded but failed to determine local version",
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -1679,7 +1493,7 @@ pub async fn acp_uninstall_agent(
|
||||
binary_cache::clear_agent_cache(agent_type)?;
|
||||
}
|
||||
registry::AgentDistribution::Npx { package, .. } => {
|
||||
uninstall_npx_package(package).await?;
|
||||
uninstall_npm_global_package(package).await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user