Skip to content

Commit 77221dd

Browse files
authored
fix: validate managed ACP packages via real entrypoints (#429)
## Summary - validate managed ACP packages through their real runtime entrypoints during `prepare-managed-resources` - import smoke-test importable packages like `claude-agent-acp` via their module entrypoint - syntax-check CLI-only packages like `codex-acp` via their actual bin script instead of assuming an importable package root ## Verification - cargo test -p aionui-runtime resolve_package_smoke_target_ -- --nocapture - cargo test -p aionui-runtime acp_tool_runtime:: -- --nocapture - cargo clippy -p aionui-runtime -p aionui-app -- -D warnings - just push -u origin fix/managed-acp-smoke-main --------- Co-authored-by: zk <>
1 parent e10d264 commit 77221dd

1 file changed

Lines changed: 145 additions & 12 deletions

File tree

  • crates/aionui-runtime/src/acp_tool_runtime

crates/aionui-runtime/src/acp_tool_runtime/mod.rs

Lines changed: 145 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,16 @@ struct InstalledPackageJson {
4242
name: String,
4343
#[serde(default)]
4444
bin: serde_json::Value,
45+
#[serde(default)]
46+
main: Option<String>,
47+
#[serde(default)]
48+
exports: serde_json::Value,
49+
}
50+
51+
#[derive(Debug, Clone, PartialEq, Eq)]
52+
enum PackageSmokeTarget {
53+
Import(PathBuf),
54+
SyntaxCheck(PathBuf),
4555
}
4656

4757
#[derive(Debug, Serialize)]
@@ -523,7 +533,7 @@ async fn prepare_local_tool_source_to_root(
523533
validate_bridge_entrypoint(&project_dir, &manifest)?;
524534
validate_platform_binary(tool, &project_dir, spec)?;
525535
validate_dependency_tree(node_runtime, &project_dir, &npm_cache_dir, tool).await?;
526-
validate_package_import_smoke(node_runtime, &project_dir, tool).await?;
536+
validate_package_smoke(node_runtime, &project_dir, tool).await?;
527537

528538
let manifest_path = project_dir.join("manifest.json");
529539
fs::write(
@@ -536,7 +546,7 @@ async fn prepare_local_tool_source_to_root(
536546
managed_resources::materialize_directory(&project_dir, target_root).map_err(ManagedAcpToolError::io)?;
537547
let resolved = validate_tool_root(tool, target_root, None)?;
538548
validate_dependency_tree(node_runtime, target_root, &npm_cache_dir, tool).await?;
539-
validate_package_import_smoke(node_runtime, target_root, tool).await?;
549+
validate_package_smoke(node_runtime, target_root, tool).await?;
540550
info!(
541551
tool = tool.slug(),
542552
version = tool.version(),
@@ -689,23 +699,39 @@ async fn validate_dependency_tree(
689699
.await
690700
}
691701

692-
async fn validate_package_import_smoke(
702+
async fn validate_package_smoke(
693703
node_runtime: &crate::ResolvedNodeRuntime,
694704
project_dir: &Path,
695705
tool: ManagedAcpToolId,
696706
) -> Result<(), ManagedAcpToolError> {
707+
let package_json_path = package_json_path(project_dir, tool.package_name());
708+
let contents = fs::read_to_string(&package_json_path).map_err(ManagedAcpToolError::io)?;
709+
let package_json: InstalledPackageJson = serde_json::from_str(&contents).map_err(|error| {
710+
ManagedAcpToolError::invalid(format!(
711+
"parse installed package manifest failed for {}: {error}",
712+
package_json_path.display()
713+
))
714+
})?;
715+
let smoke_target = resolve_package_smoke_target(project_dir, &package_json)?;
697716
let mut builder = Builder::clean_cli(node_runtime.node_path.clone());
698-
builder.current_dir(project_dir).args([
699-
"--input-type=module",
700-
"-e",
701-
"await import(process.argv[1]);",
702-
tool.package_name(),
703-
]);
717+
builder.current_dir(project_dir);
718+
match &smoke_target {
719+
PackageSmokeTarget::Import(path) => {
720+
builder
721+
.arg("--input-type=module")
722+
.arg("-e")
723+
.arg("import { pathToFileURL } from 'node:url'; await import(pathToFileURL(process.argv[1]).href);")
724+
.arg(path);
725+
}
726+
PackageSmokeTarget::SyntaxCheck(path) => {
727+
builder.arg("--check").arg(path);
728+
}
729+
}
704730
let output = tokio::time::timeout(MANAGED_ACP_SMOKE_TIMEOUT, builder.output())
705731
.await
706732
.map_err(|_| {
707733
ManagedAcpToolError::invalid(format!(
708-
"smoke test for managed {} package import timed out after {}s",
734+
"smoke test for managed {} package timed out after {}s",
709735
tool.display_name(),
710736
MANAGED_ACP_SMOKE_TIMEOUT.as_secs()
711737
))
@@ -725,18 +751,22 @@ async fn validate_package_import_smoke(
725751
format!("{stderr}; stdout: {stdout}")
726752
};
727753
Err(ManagedAcpToolError::invalid(format!(
728-
"smoke test for managed {} package import failed with exit code {:?}: {detail}",
754+
"smoke test for managed {} package failed with exit code {:?}: {detail}",
729755
tool.display_name(),
730756
output.status.code()
731757
)))
732758
}
733759

734760
fn package_json_path(project_dir: &Path, package_name: &str) -> PathBuf {
761+
package_root(project_dir, package_name).join("package.json")
762+
}
763+
764+
fn package_root(project_dir: &Path, package_name: &str) -> PathBuf {
735765
let mut path = project_dir.join("node_modules");
736766
for segment in package_path_segments(package_name) {
737767
path.push(segment);
738768
}
739-
path.join("package.json")
769+
path
740770
}
741771

742772
fn package_path_segments(package_name: &str) -> Vec<&str> {
@@ -774,6 +804,47 @@ fn resolve_package_bin_entry(package_name: &str, bin_field: &serde_json::Value)
774804
}
775805
}
776806

807+
fn resolve_package_smoke_target(
808+
project_dir: &Path,
809+
package_json: &InstalledPackageJson,
810+
) -> Result<PackageSmokeTarget, ManagedAcpToolError> {
811+
if let Some(entry) = resolve_package_import_entry(&package_json.exports, package_json.main.as_deref()) {
812+
return Ok(PackageSmokeTarget::Import(
813+
package_root(project_dir, &package_json.name).join(entry),
814+
));
815+
}
816+
817+
let bin_entry = resolve_package_bin_entry(package_json.name.as_str(), &package_json.bin)?;
818+
Ok(PackageSmokeTarget::SyntaxCheck(
819+
package_root(project_dir, &package_json.name).join(bin_entry),
820+
))
821+
}
822+
823+
fn resolve_package_import_entry(exports_field: &serde_json::Value, main_field: Option<&str>) -> Option<String> {
824+
let exports_entry = match exports_field {
825+
serde_json::Value::String(value) if !value.is_empty() => Some(value.clone()),
826+
serde_json::Value::Object(entries) => entries.get(".").and_then(|root| match root {
827+
serde_json::Value::String(value) if !value.is_empty() => Some(value.clone()),
828+
serde_json::Value::Object(root_entries) => root_entries
829+
.get("import")
830+
.and_then(|value| match value {
831+
serde_json::Value::String(value) if !value.is_empty() => Some(value.clone()),
832+
_ => None,
833+
})
834+
.or_else(|| {
835+
root_entries.get("default").and_then(|value| match value {
836+
serde_json::Value::String(value) if !value.is_empty() => Some(value.clone()),
837+
_ => None,
838+
})
839+
}),
840+
_ => None,
841+
}),
842+
_ => None,
843+
};
844+
845+
exports_entry.or_else(|| main_field.and_then(|value| if value.is_empty() { None } else { Some(value.to_owned()) }))
846+
}
847+
777848
fn normalize_slashes(path: &Path) -> String {
778849
path.to_string_lossy().replace('\\', "/")
779850
}
@@ -893,6 +964,7 @@ fn format_error_with_causes(error: &(dyn StdError + 'static)) -> String {
893964
#[cfg(test)]
894965
mod tests {
895966
use super::*;
967+
use serde_json::json;
896968
use std::fmt;
897969

898970
#[test]
@@ -1033,6 +1105,67 @@ mod tests {
10331105
assert_eq!(entry, "dist/index.js");
10341106
}
10351107

1108+
#[test]
1109+
fn resolve_package_smoke_target_prefers_importable_entry_for_exported_package() {
1110+
let tmp = tempfile::tempdir().unwrap();
1111+
let project_dir = tmp.path();
1112+
let package_json = InstalledPackageJson {
1113+
name: "@agentclientprotocol/claude-agent-acp".into(),
1114+
bin: json!({
1115+
"claude-agent-acp": "dist/index.js",
1116+
}),
1117+
main: Some("dist/lib.js".into()),
1118+
exports: json!({
1119+
".": {
1120+
"types": "./dist/lib.d.ts",
1121+
"import": "./dist/lib.js"
1122+
}
1123+
}),
1124+
};
1125+
1126+
let target = resolve_package_smoke_target(project_dir, &package_json).expect("smoke target");
1127+
1128+
assert_eq!(
1129+
target,
1130+
PackageSmokeTarget::Import(
1131+
project_dir
1132+
.join("node_modules")
1133+
.join("@agentclientprotocol")
1134+
.join("claude-agent-acp")
1135+
.join("dist")
1136+
.join("lib.js")
1137+
)
1138+
);
1139+
}
1140+
1141+
#[test]
1142+
fn resolve_package_smoke_target_falls_back_to_bin_check_for_cli_only_package() {
1143+
let tmp = tempfile::tempdir().unwrap();
1144+
let project_dir = tmp.path();
1145+
let package_json = InstalledPackageJson {
1146+
name: "@zed-industries/codex-acp".into(),
1147+
bin: json!({
1148+
"codex-acp": "bin/codex-acp.js",
1149+
}),
1150+
main: None,
1151+
exports: serde_json::Value::Null,
1152+
};
1153+
1154+
let target = resolve_package_smoke_target(project_dir, &package_json).expect("smoke target");
1155+
1156+
assert_eq!(
1157+
target,
1158+
PackageSmokeTarget::SyntaxCheck(
1159+
project_dir
1160+
.join("node_modules")
1161+
.join("@zed-industries")
1162+
.join("codex-acp")
1163+
.join("bin")
1164+
.join("codex-acp.js")
1165+
)
1166+
);
1167+
}
1168+
10361169
#[test]
10371170
fn package_path_segments_preserve_scoped_package_structure() {
10381171
assert_eq!(

0 commit comments

Comments
 (0)