fix: reserve category names, confirm fork-name collisions, report secrets on uninstall

Bundle names that collide with an asset category (agents, roles, skills,
macros, functions, mcp_config) are now owner-qualified at install time,
whether derived from the repo or declared by a manifest, so no bundle can
shadow a category by name. A manifest name that collides with a bundle
from a different source now prompts for confirmation interactively (a
fork or typo-squat is the likely cause); declining aborts before anything
is written, and non-interactive runs keep the deterministic
owner-qualification. Uninstall summaries now list the vault secrets the
bundle's MCP servers reference, noting they are installed by the bundle
but not removed. Also removes the dead ResolvedBundleName.migrated_from
field.
This commit is contained in:
2026-08-24 11:15:13 -06:00
parent 1136b1385b
commit 4324d551d6
2 changed files with 129 additions and 24 deletions
+68 -2
View File
@@ -8,14 +8,14 @@ use crate::function::Language;
use crate::mcp::{McpServer, McpServersConfig};
use crate::utils;
use crate::utils::IS_STDOUT_TERMINAL;
use crate::vault::{Vault, create_vault_password_file, interpolate_secrets};
use crate::vault::{SECRET_RE, Vault, create_vault_password_file, interpolate_secrets};
use anyhow::{Context, Result, anyhow, bail};
use clap::ValueEnum;
use indexmap::IndexMap;
use indoc::formatdoc;
use inquire::{Confirm, Select};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Component, Path, PathBuf};
@@ -472,6 +472,7 @@ struct UninstallFileSummary {
struct UninstallMcpSummary {
removed: Vec<String>,
kept: Vec<String>,
secrets: Vec<String>,
}
/// Kept and failed items stay in the record, so a re-run offers them again.
@@ -569,6 +570,13 @@ pub fn uninstall_bundle(spec: &str, assume_yes: bool) -> Result<()> {
if !mcp.kept.is_empty() {
println!(" = kept servers: {}", mcp.kept.join(", "));
}
if !mcp.secrets.is_empty() {
println!(
" ~ vault secrets referenced by this bundle's servers \
(installed by this bundle, not removed): {}",
mcp.secrets.join(", ")
);
}
Ok(())
}
@@ -787,6 +795,24 @@ fn uninstall_mcp_entries(
None
};
let mut secret_names: BTreeSet<String> = BTreeSet::new();
for server in servers {
if let Some(entry) = config
.as_ref()
.and_then(|cfg| cfg.mcp_servers.get(server.effective_key()))
&& let Ok(serialized) = serde_json::to_string(entry)
{
for capture in SECRET_RE.captures_iter(&serialized) {
if let Ok(capture) = capture
&& let Some(name) = capture.get(1)
{
secret_names.insert(name.as_str().to_string());
}
}
}
}
summary.secrets = secret_names.into_iter().collect();
let mut changed = false;
let mut released = Vec::new();
let mut sticky: Option<ObsoleteAction> = None;
@@ -4265,6 +4291,46 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn uninstall_mcp_reports_referenced_secrets_without_removing_them() {
let dir = fresh_temp_dir("uninst-mcp-secrets-");
let mut store = BundleStore::load_from(dir.join("installed-bundles.yaml")).unwrap();
store
.upsert_bundle("omc", test_metadata("https://github.com/x/omc"))
.unwrap();
let mcp = dir.join("mcp.json");
write_mcp(
&mcp,
r#"{"mcpServers": {
"srv": {
"type": "stdio",
"command": "echo",
"env": {"TOKEN": "{{OMC_TOKEN}}", "ORG": "{{OMC_ORG}}"}
}
}}"#,
);
let parsed: McpServersConfig =
serde_json::from_str(&fs::read_to_string(&mcp).unwrap()).unwrap();
let hash = hash_bytes(
serde_json::to_string(parsed.mcp_servers.get("srv").unwrap())
.unwrap()
.as_bytes(),
);
store
.record_mcp_servers(
"omc",
vec![mcp_server_record("srv", McpAction::Added, Some(hash))],
)
.unwrap();
let servers = store.get("omc").unwrap().mcp_servers.clone();
let summary = uninstall_mcp_entries(&mut store, "omc", &servers, &mcp, true).unwrap();
assert_eq!(summary.removed, vec!["srv"]);
assert_eq!(summary.secrets, vec!["OMC_ORG", "OMC_TOKEN"]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn uninstall_mcp_keeps_modified_entries_and_their_records() {
let dir = fresh_temp_dir("uninst-mcp-modified-");