Marketplace: warn on imported global plugins; tidy import follow-ups

- The import preview counts global plugin installs and warns on them:
  a plugin can bring hooks and MCP servers into every container and an
  imported install skips the confirm step, like a hook.
- Item keys, hosts and branches in errors are quoted with {:?} and
  capped, since they can come from an import file.
- After an import, caches and snapshots of marketplaces the import
  dropped are removed (under the repo lock) and pins are refreshed for
  the imported installs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 09:46:56 -07:00
co-authored by Claude Opus 5.5
parent 89859b9a3c
commit 6cf9664dc8
7 changed files with 126 additions and 6 deletions
@@ -62,6 +62,19 @@ pub(crate) mod ops {
}
}
/// An unvalidated value as it may appear in an error: quoted and escaped
/// (`{:?}`) and capped at 60 characters, since it can come from an
/// import file rather than from what the person just typed.
pub fn shown(value: &str) -> String {
const MAX: usize = 60;
if value.chars().count() > MAX {
let head: String = value.chars().take(MAX).collect();
format!("{:?}…", head)
} else {
format!("{:?}", value)
}
}
pub fn validate_label(label: &str) -> Result<String, String> {
let label = label.trim();
if label.is_empty() {
@@ -86,7 +99,7 @@ pub(crate) mod ops {
if git::valid_branch(&b) {
Ok(Some(b))
} else {
Err(format!("{b:?} is not a valid branch name."))
Err(format!("{} is not a valid branch name.", shown(&b)))
}
}
@@ -101,7 +114,7 @@ pub(crate) mod ops {
if auth::valid_host(&host) && !host.starts_with('.') && !host.starts_with(':') && port_ok {
Ok(host)
} else {
Err(format!("{host:?} is not a valid host name."))
Err(format!("{} is not a valid host name.", shown(&host)))
}
}
@@ -294,7 +307,10 @@ fn validate_item(item: &MarketplaceItemRef) -> Result<(), String> {
if is_valid_item_key(&item.key) {
Ok(())
} else {
Err(format!("\"{}\" is not a valid item name.", item.key))
Err(format!(
"{} is not a valid item name.",
ops::shown(&item.key)
))
}
}
@@ -382,6 +398,17 @@ pub(crate) fn validate_imported_marketplace_state(
Ok(())
}
/// Marketplaces configured in `before` that `after` no longer has: an import
/// that drops them leaves their caches and snapshots to be removed.
pub(crate) fn dropped_marketplace_ids(before: &AppSettings, after: &AppSettings) -> Vec<String> {
before
.marketplaces
.iter()
.filter(|m| !after.marketplaces.iter().any(|a| a.id == m.id))
.map(|m| m.id.clone())
.collect()
}
/// The in-memory snapshot, else the cached one (which is then remembered).
fn snapshot_or_cached(mgr: &MarketplaceManager, m: &Marketplace) -> MarketplaceSnapshot {
if let Some(s) = mgr.snapshot(&m.id) {
@@ -406,7 +433,7 @@ async fn snapshot_blocking(
/// Make each cache's pin refs exactly the commits installs reference, so a
/// pinned version can never be garbage-collected away. Under the repo lock
/// (pre-flight F11): a concurrent fetch writes refs in the same repos.
async fn refresh_pins(state: &AppState) {
pub(crate) async fn refresh_pins(state: &AppState) {
let settings = state.settings_store.get();
let pins = mk::pins_by_marketplace(&settings, &state.projects_store.list());
let root = state.marketplace.data_root().to_path_buf();
@@ -432,7 +459,7 @@ async fn refresh_pins(state: &AppState) {
}
/// Forget a marketplace's snapshot and delete its cache, under the repo lock.
async fn remove_cache(state: &AppState, marketplace_id: &str) {
pub(crate) async fn remove_cache(state: &AppState, marketplace_id: &str) {
state.marketplace.remove_snapshot(marketplace_id);
let path = git::cache_path(state.marketplace.data_root(), marketplace_id);
let _repo_guard = state.marketplace.repo_lock().lock().await;
@@ -1144,4 +1171,38 @@ mod tests {
s.marketplaces.clear();
validate_imported_marketplace_state(&mut s, &tokens()).unwrap();
}
#[test]
fn an_invalid_item_key_is_quoted_and_capped_in_the_error() {
use crate::models::marketplace::MarketplaceItemRef;
let item = |key: String| MarketplaceItemRef {
marketplace_id: MARKET.into(),
kind: ItemKind::Agent,
key,
};
let e = validate_item(&item("bad\nkey".into())).unwrap_err();
assert!(!e.contains('\n'), "raw control character in {e:?}");
assert!(e.contains("\"bad\\nkey\""), "{e}");
let e = validate_item(&item(format!("{}/", "x".repeat(500)))).unwrap_err();
assert!(
e.chars().count() < 150,
"not capped: {} chars",
e.chars().count()
);
}
#[test]
fn marketplaces_an_import_drops_are_the_ones_whose_caches_go() {
let mut before = imported();
let mut kept = before.marketplaces[0].clone();
kept.id = "kept-1".into();
before.marketplaces.push(kept.clone());
let mut after = AppSettings::default();
after.marketplaces.push(kept);
assert_eq!(
dropped_marketplace_ids(&before, &after),
vec![MARKET.to_string()]
);
assert!(dropped_marketplace_ids(&after, &before).is_empty());
}
}
@@ -441,6 +441,13 @@ pub async fn apply_settings_import(
) = imported_marketplace;
state.settings_store.update(s)?
};
// Caches of marketplaces the import dropped are dead weight now, and
// the pins must match the imported installs.
use crate::commands::marketplace_commands as mc;
for id in mc::dropped_marketplace_ids(&current, &saved) {
mc::remove_cache(&state, &id).await;
}
mc::refresh_pins(&state).await;
// `reconcile_gateway` (inside `update_settings`) only reacts to a changed
// *shape* — port, provider, base URL, models — because that's what's