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
+61 -22
View File
@@ -2,10 +2,13 @@ use super::install_remote::{
canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name, canonical_source_url, owner_qualifier, repo_name_slug, validate_bundle_name,
}; };
use super::paths; use super::paths;
use crate::config::AssetCategory;
use crate::function::write_file_atomic; use crate::function::write_file_atomic;
use crate::utils::IS_STDOUT_TERMINAL;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use inquire::Confirm;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -92,9 +95,6 @@ pub(crate) struct ResolvedBundleName {
pub(crate) name: String, pub(crate) name: String,
/// The unqualified name this install asked for, when it had to be owner-qualified. /// The unqualified name this install asked for, when it had to be owner-qualified.
pub(crate) qualified_from: Option<String>, pub(crate) qualified_from: Option<String>,
/// The record key this source was previously tracked under, when it changed.
#[allow(dead_code)]
pub(crate) migrated_from: Option<String>,
/// Source URL of the different-source bundle that already holds the unqualified name. /// Source URL of the different-source bundle that already holds the unqualified name.
pub(crate) same_name_other_source: Option<String>, pub(crate) same_name_other_source: Option<String>,
} }
@@ -216,15 +216,21 @@ impl BundleStore {
let mut resolved = ResolvedBundleName { let mut resolved = ResolvedBundleName {
name: base.clone(), name: base.clone(),
qualified_from: None, qualified_from: None,
migrated_from: None,
same_name_other_source: None, same_name_other_source: None,
}; };
if let Some(other_source) = self.source_of_other_bundle(&base, &canonical) { let reserved = AssetCategory::parse(&base).is_some();
let collision = self.source_of_other_bundle(&base, &canonical);
if reserved || collision.is_some() {
let reason = match &collision {
Some(other_source) => {
format!("is already used by an install from '{other_source}'")
}
None => "is reserved for an asset category".to_string(),
};
if base.contains('/') { if base.contains('/') {
bail!( bail!(
"bundle name '{base}' is already used by an install from \ "bundle name '{base}' {reason} and cannot be qualified further; \
'{other_source}' and cannot be qualified further; \
uninstall it or pick a different manifest name" uninstall it or pick a different manifest name"
); );
} }
@@ -233,21 +239,37 @@ impl BundleStore {
.filter(|owner| !owner.is_empty()); .filter(|owner| !owner.is_empty());
let Some(owner) = owner else { let Some(owner) = owner else {
bail!( bail!(
"bundle name '{base}' is already used by an install from \ "bundle name '{base}' {reason}, and no owner qualifier \
'{other_source}', and no owner qualifier can be derived from '{url}'" can be derived from '{url}'"
); );
}; };
let qualified = format!("{owner}/{base}"); let qualified = format!("{owner}/{base}");
if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) { if let Some(source) = self.source_of_other_bundle(&qualified, &canonical) {
bail!( bail!(
"bundle names '{base}' and '{qualified}' are both used by installs \ "bundle name '{base}' {reason}, and '{qualified}' is already \
from other sources ('{other_source}', '{source}'); \ used by an install from '{source}'; \
uninstall one or pick a different manifest name" uninstall one or pick a different manifest name"
); );
} }
if let Some(other_source) = &collision
&& manifest_name.is_some()
&& *IS_STDOUT_TERMINAL
{
let proceed = Confirm::new(&format!(
"Bundle name '{base}' is already used by an install from \
'{other_source}' (a fork or typo-squat?). Track this install \
as '{qualified}'?"
))
.with_default(false)
.prompt()
.with_context(|| "failed to read bundle name confirmation")?;
if !proceed {
bail!("install aborted: bundle name '{base}' {reason}");
}
}
resolved.name = qualified; resolved.name = qualified;
resolved.qualified_from = Some(base); resolved.qualified_from = Some(base);
resolved.same_name_other_source = Some(other_source); resolved.same_name_other_source = collision;
} }
let already_recorded = existing_key.as_deref() == Some(resolved.name.as_str()); let already_recorded = existing_key.as_deref() == Some(resolved.name.as_str());
@@ -263,18 +285,16 @@ impl BundleStore {
"Bundle '{old_key}' from {url} is now tracked as '{}'.", "Bundle '{old_key}' from {url} is now tracked as '{}'.",
resolved.name resolved.name
); );
resolved.migrated_from = Some(old_key);
self.save()?; self.save()?;
} }
if let (Some(from), false) = (&resolved.qualified_from, already_recorded) { if let (Some(from), false) = (&resolved.qualified_from, already_recorded) {
let other = resolved let detail = match resolved.same_name_other_source.as_deref() {
.same_name_other_source Some(other) => format!("is already used by an install from '{other}'"),
.as_deref() None => "is reserved for an asset category".to_string(),
.unwrap_or_default(); };
println!( println!(
"Bundle name '{from}' is already used by an install from '{other}'; \ "Bundle name '{from}' {detail}; tracking this install as '{}'.",
tracking this install as '{}'.",
resolved.name resolved.name
); );
} }
@@ -877,7 +897,6 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(resolved.name, "omc"); assert_eq!(resolved.name, "omc");
assert_eq!(resolved.migrated_from, None);
assert_eq!(resolved.qualified_from, None); assert_eq!(resolved.qualified_from, None);
} }
@@ -897,7 +916,6 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(resolved.name, "oh-my-coyote"); assert_eq!(resolved.name, "oh-my-coyote");
assert_eq!(resolved.migrated_from.as_deref(), Some("omc"));
let reloaded = dir.store(); let reloaded = dir.store();
assert!(reloaded.get("omc").is_none()); assert!(reloaded.get("omc").is_none());
assert_eq!(reloaded.get("oh-my-coyote").unwrap().files.len(), 1); assert_eq!(reloaded.get("oh-my-coyote").unwrap().files.len(), 1);
@@ -955,7 +973,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(resolved.name, "b/repo"); assert_eq!(resolved.name, "b/repo");
assert_eq!(resolved.migrated_from, None); assert_eq!(resolved.qualified_from.as_deref(), Some("repo"));
} }
#[test] #[test]
@@ -970,6 +988,27 @@ mod tests {
assert_eq!(resolved.name, "next-js"); assert_eq!(resolved.name, "next-js");
} }
#[test]
fn resolve_reserves_asset_category_names() {
let dir = TempStoreDir::new("bundles-reserved");
let mut store = dir.store();
let resolved = store
.resolve_bundle_name("https://github.com/x/agents", None)
.unwrap();
assert_eq!(resolved.name, "x/agents");
assert_eq!(resolved.qualified_from.as_deref(), Some("agents"));
assert!(resolved.same_name_other_source.is_none());
let resolved = store
.resolve_bundle_name("https://github.com/y/repo", Some("mcp_config"))
.unwrap();
assert_eq!(resolved.name, "y/mcp_config");
assert_eq!(resolved.qualified_from.as_deref(), Some("mcp_config"));
}
#[test] #[test]
fn resolve_rejects_invalid_manifest_names() { fn resolve_rejects_invalid_manifest_names() {
let dir = TempStoreDir::new("bundles-invalid-name"); let dir = TempStoreDir::new("bundles-invalid-name");
+68 -2
View File
@@ -8,14 +8,14 @@ use crate::function::Language;
use crate::mcp::{McpServer, McpServersConfig}; use crate::mcp::{McpServer, McpServersConfig};
use crate::utils; use crate::utils;
use crate::utils::IS_STDOUT_TERMINAL; 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 anyhow::{Context, Result, anyhow, bail};
use clap::ValueEnum; use clap::ValueEnum;
use indexmap::IndexMap; use indexmap::IndexMap;
use indoc::formatdoc; use indoc::formatdoc;
use inquire::{Confirm, Select}; use inquire::{Confirm, Select};
use serde::Deserialize; use serde::Deserialize;
use std::collections::{HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::ffi::{OsStr, OsString}; use std::ffi::{OsStr, OsString};
use std::fs; use std::fs;
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
@@ -472,6 +472,7 @@ struct UninstallFileSummary {
struct UninstallMcpSummary { struct UninstallMcpSummary {
removed: Vec<String>, removed: Vec<String>,
kept: Vec<String>, kept: Vec<String>,
secrets: Vec<String>,
} }
/// Kept and failed items stay in the record, so a re-run offers them again. /// 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() { if !mcp.kept.is_empty() {
println!(" = kept servers: {}", mcp.kept.join(", ")); 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(()) Ok(())
} }
@@ -787,6 +795,24 @@ fn uninstall_mcp_entries(
None 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 changed = false;
let mut released = Vec::new(); let mut released = Vec::new();
let mut sticky: Option<ObsoleteAction> = None; let mut sticky: Option<ObsoleteAction> = None;
@@ -4265,6 +4291,46 @@ mod tests {
let _ = fs::remove_dir_all(&dir); 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] #[test]
fn uninstall_mcp_keeps_modified_entries_and_their_records() { fn uninstall_mcp_keeps_modified_entries_and_their_records() {
let dir = fresh_temp_dir("uninst-mcp-modified-"); let dir = fresh_temp_dir("uninst-mcp-modified-");