fix(ci): disambiguate WASM bundle filenames to prevent tool/channel collision (#964)

* fix(ci): disambiguate WASM bundle filenames to prevent tool/channel collision

When a tool and channel share the same name (e.g. slack, telegram), the
CI build produced identical bundle filenames, causing the second to
overwrite the first. Both manifests then pointed to the wrong binary.

Prefix bundle filenames with the extension kind (tool-slack-... vs
channel-slack-...) and parse the prefix when patching manifests, so each
manifest receives the correct artifact URL and SHA256.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test(registry): add installer tests for tool/channel name disambiguation

Regression tests for the CI artifact collision fix (PR #964). Verifies:
- extract_tar_gz rejects archives with wrong wasm name (the collision bug)
- Tool bundle extracts slack-tool.wasm correctly
- Channel bundle extracts slack.wasm correctly
- Tool and channel manifests install to separate directories

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(ci): add kind validation and filter non-WASM checksum entries

- Validate .kind is "tool" or "channel" before using in build-wasm-extensions (hard error)
- Filter checksums.txt to *-wasm32-wasip2.tar.gz entries before parsing, avoiding noisy warnings from binary artifact entries in build-local-artifacts
- Add kind validation with warning+skip in both checksum-parsing loops

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: fix rustfmt formatting in installer tests

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Henry Park
2026-03-11 17:02:11 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 977b7fde99
commit 81f7b64994
3 changed files with 274 additions and 30 deletions
+166
View File
@@ -629,6 +629,7 @@ fn is_gzip(bytes: &[u8]) -> bool {
}
/// Result of extracting a tar.gz bundle.
#[derive(Debug)]
struct ExtractResult {
has_capabilities: bool,
}
@@ -1112,4 +1113,169 @@ mod tests {
"ChecksumMismatch on version-pinned URL must remain a hard block"
);
}
// Regression tests for tool/channel artifact name collision (PR #964).
// When a tool and channel share the same registry filename (e.g. slack.json),
// CI produces kind-prefixed bundles (tool-slack-*.tar.gz vs channel-slack-*.tar.gz).
// The files *inside* each archive use manifest.name (slack-tool.wasm vs slack.wasm).
// These tests verify the installer extracts by manifest.name correctly.
fn build_test_tar_gz(wasm_name: &str, caps_name: Option<&str>) -> Vec<u8> {
use flate2::Compression;
use flate2::write::GzEncoder;
use tar::Builder;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
{
let mut builder = Builder::new(&mut encoder);
let wasm_data = b"\0asm\x01\x00\x00\x00";
let mut header = tar::Header::new_gnu();
header.set_size(wasm_data.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, wasm_name, &wasm_data[..])
.unwrap();
if let Some(caps) = caps_name {
let caps_data = br#"{"auth":null}"#;
let mut header = tar::Header::new_gnu();
header.set_size(caps_data.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, caps, &caps_data[..])
.unwrap();
}
builder.finish().unwrap();
}
encoder.finish().unwrap()
}
#[test]
fn test_extract_rejects_archive_with_wrong_wasm_name() {
// Simulates the collision bug: archive contains channel's slack.wasm,
// but installer tries to extract tool's slack-tool.wasm.
let gz_bytes = build_test_tar_gz("slack.wasm", Some("slack.capabilities.json"));
let tmp = tempfile::tempdir().unwrap();
let result = extract_tar_gz(
&gz_bytes,
"slack-tool",
&tmp.path().join("slack-tool.wasm"),
&tmp.path().join("slack-tool.capabilities.json"),
"test://url",
);
let err = result.expect_err("should fail when archive has wrong wasm name");
match err {
RegistryError::DownloadFailed { reason, .. } => {
assert!(
reason.contains("slack-tool.wasm"),
"error should mention expected filename: {}",
reason
);
}
other => panic!("expected DownloadFailed, got: {:?}", other),
}
}
#[test]
fn test_extract_correct_wasm_from_tool_bundle() {
// Tool bundle contains slack-tool.wasm — extraction by name="slack-tool" succeeds.
let gz_bytes = build_test_tar_gz("slack-tool.wasm", Some("slack-tool.capabilities.json"));
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("slack-tool.wasm");
let caps_path = tmp.path().join("slack-tool.capabilities.json");
let result = extract_tar_gz(
&gz_bytes,
"slack-tool",
&wasm_path,
&caps_path,
"test://url",
)
.unwrap();
assert!(wasm_path.exists());
assert!(caps_path.exists());
assert!(result.has_capabilities);
}
#[test]
fn test_extract_correct_wasm_from_channel_bundle() {
// Channel bundle contains slack.wasm — extraction by name="slack" succeeds.
let gz_bytes = build_test_tar_gz("slack.wasm", Some("slack.capabilities.json"));
let tmp = tempfile::tempdir().unwrap();
let wasm_path = tmp.path().join("slack.wasm");
let caps_path = tmp.path().join("slack.capabilities.json");
let result =
extract_tar_gz(&gz_bytes, "slack", &wasm_path, &caps_path, "test://url").unwrap();
assert!(wasm_path.exists());
assert!(caps_path.exists());
assert!(result.has_capabilities);
}
#[tokio::test]
async fn test_tool_and_channel_install_to_separate_directories() {
// Tool and channel manifests with the same file_stem ("slack") install
// to different directories without collision.
let temp = tempfile::tempdir().expect("tempdir");
let installer = RegistryInstaller::new(
temp.path().to_path_buf(),
temp.path().join("tools"),
temp.path().join("channels"),
);
let tool_manifest = test_manifest_with_kind(
"slack-tool",
"tools-src/slack",
None,
None,
ManifestKind::Tool,
);
let channel_manifest = test_manifest_with_kind(
"slack",
"channels-src/slack",
None,
None,
ManifestKind::Channel,
);
// Both fail because source dirs don't exist, but the error path reveals
// the target directory — tool goes to tools/, channel goes to channels/.
let tool_err = installer
.install_from_source(&tool_manifest, false)
.await
.expect_err("no source dir");
let channel_err = installer
.install_from_source(&channel_manifest, false)
.await
.expect_err("no source dir");
match tool_err {
RegistryError::ManifestRead { path, .. } => {
assert!(
path.ends_with("tools-src/slack"),
"tool should resolve to tools-src/slack, got: {}",
path.display()
);
}
other => panic!("expected ManifestRead for tool, got: {:?}", other),
}
match channel_err {
RegistryError::ManifestRead { path, .. } => {
assert!(
path.ends_with("channels-src/slack"),
"channel should resolve to channels-src/slack, got: {}",
path.display()
);
}
other => panic!("expected ManifestRead for channel, got: {:?}", other),
}
}
}