返回 CodeWhale
mod.rs
根目录 / crates / tui / src / plugins / install / mod.rs
1 //! Plugin install on-ramp (#5182).
2 //!
3 //! Fetches a plugin bundle from a local directory, a `github:owner/repo`
4 //! archive, or a direct tarball URL, and places it under the user plugins
5 //! root (`~/.codewhale/plugins/<name>/`). This module deliberately mirrors
6 //! [`crate::skills::install`]: the download, network-gating, traversal
7 //! rejection, and marker machinery is *reused* from there (`fetch_tarball`,
8 //! `is_safe_path`, `write_installed_from_v2`, `INSTALLED_FROM_MARKER`), while
9 //! the scan/extract step is plugin-shaped (a bundle is rooted at the single
10 //! supported plugin manifest in the tree, not at a `SKILL.md`).
11 //!
12 //! # Hard rules
13 //!
14 //! * Everything is staged in a private `.staging-*` sibling first. The
15 //! destination is only created (via atomic rename) once the bundle clears
16 //! every check — half-installed plugins never appear on disk.
17 //! * The fetched tree must contain **exactly one** plugin bundle root holding
18 //! `plugin.json`, `kimi.plugin.json`, or `plugin.toml`. Zero (not a plugin)
19 //! or more than one (ambiguous mono-repo) are both rejected.
20 //! * Path traversal (`..`, absolute paths) and symlinks/hard links inside the
21 //! selected bundle subtree are rejected. Entries outside the subtree are
22 //! never extracted.
23 //! * The manifest `[plugin].name` must be a single path-safe segment; it
24 //! becomes the destination directory name.
25 //! * Overwriting a bundle that lacks the `.installed-from` marker is refused
26 //! — hand-placed bundles are never clobbered. `update` swaps atomically
27 //! only when the upstream bytes changed; a changed bundle automatically
28 //! invalidates the hash-bound trust receipt at the next discovery.
29 //! * Installed bits land **disabled and untrusted**; trust/enablement is the
30 //! existing registry flow, not this module's concern.
31
32 //!
33 //! # Module map
34 //!
35 //! This module owns the source spec, the result types, and the three
36 //! verbs. The pipeline stages each live next door:
37 //!
38 //! * [`stage`] — copy a local bundle into a private `.staging-*` sibling
39 //! (symlink, file-count, and size rejection; manifest validation).
40 //! * [`tarball`] — the two-pass archive reader: scan for the single
41 //! supported manifest under the size cap, then extract just that subtree.
42 //! * [`place`] — atomic rename into `<name>/`, marker write, and the
43 //! containment guards shared with discovery.
44 //!
45 //! Fetching is not ours: remote bytes come from
46 //! [`crate::skills::install::fetch_tarball`], network gating included.
47
48 use std::fs;
49 use std::path::{Path, PathBuf};
50
51 use anyhow::{Context, Result, bail};
52 use thiserror::Error;
53
54 /// A validated manifest name is already occupied by another loaded bundle.
55 /// Keep this typed so API clients receive a conflict, not a server failure.
56 #[derive(Debug, Error)]
57 #[error("{0}")]
58 pub struct PluginNameConflict(pub String);
59
60 use crate::network_policy::NetworkPolicy;
61 use crate::plugins::manifest::PluginManifest;
62 use crate::skills::install::{
63 self as skill_install, FetchOutcome, InstallSource, InstalledFromMarker, fetch_tarball,
64 sha256_hex, source_spec_string,
65 };
66
67 pub(crate) mod dsh;
68 mod place;
69 mod stage;
70 mod tarball;
71
72 #[cfg(test)]
73 mod tests;
74
75 use place::{ensure_target_within_plugins_dir, finalize_install, plugin_target_path};
76 use stage::stage_local_copy;
77 use tarball::stage_tarball;
78
79 /// Marker file shared with the skill installer. Its presence means "this
80 /// bundle was placed by `/plugin install`" and enables update/uninstall.
81 pub use crate::skills::install::INSTALLED_FROM_MARKER;
82
83 /// Default per-bundle size cap. Mirrors the skill installer; the runtime
84 /// staging budget in `registry.rs` stays the outer bound.
85 pub const DEFAULT_MAX_SIZE_BYTES: u64 = skill_install::DEFAULT_MAX_SIZE_BYTES;
86
87 // ─────────────────────────────────────────────────────────────────────────────
88 // Source parsing
89 // ─────────────────────────────────────────────────────────────────────────────
90
91 /// Where a plugin bundle is installed from. See [`PluginInstallSource::parse`].
92 #[derive(Debug, Clone, PartialEq, Eq)]
93 pub enum PluginInstallSource {
94 /// Local bundle directory (copied, never executed). Parsed from a plain
95 /// path or an explicit `path:<dir>` spec (the marker round-trip form).
96 LocalPath(PathBuf),
97 /// `github:owner/repo` or a direct `http(s)://…` tarball URL, downloaded
98 /// through the shared skill-install machinery. There is no registry
99 /// index in v1.
100 Remote(InstallSource),
101 /// A local DeepSeek Harness bundle package, converted by [`dsh`] into a
102 /// native bundle that then takes the same staged, reviewed install path.
103 /// Parsed from `dsh:<dir>`, or from a plain local path that holds a DSH
104 /// `package.json` and no native manifest.
105 Dsh(PathBuf),
106 }
107
108 impl PluginInstallSource {
109 /// Parse a user-supplied spec.
110 ///
111 /// * `github:owner/repo`, `https://…` → [`PluginInstallSource::Remote`]
112 /// (via [`InstallSource::parse`]; registry names are unreachable here)
113 /// * `path:<dir>` or any other value → [`PluginInstallSource::LocalPath`]
114 pub fn parse(spec: &str) -> Result<Self> {
115 let trimmed = spec.trim();
116 if trimmed.is_empty() {
117 bail!("install source must not be empty");
118 }
119 if let Some(path) = trimmed.strip_prefix("path:") {
120 return Self::local(path);
121 }
122 if let Some(path) = trimmed.strip_prefix("dsh:") {
123 let path = path.trim();
124 if path.is_empty() {
125 bail!("DSH package path must not be empty");
126 }
127 return Ok(Self::Dsh(PathBuf::from(path)));
128 }
129 if trimmed.starts_with("github:")
130 || trimmed.starts_with("https://")
131 || trimmed.starts_with("http://")
132 {
133 let source = InstallSource::parse(trimmed)?;
134 return match source {
135 InstallSource::GitHubRepo(_) | InstallSource::DirectUrl(_) => {
136 remote_bundle_path(&source)?;
137 Ok(Self::Remote(source))
138 }
139 InstallSource::Registry(_) => {
140 unreachable!("prefixed specs never parse as a registry name")
141 }
142 };
143 }
144 Self::local(trimmed)
145 }
146
147 fn local(spec: &str) -> Result<Self> {
148 let trimmed = spec.trim();
149 if trimmed.is_empty() {
150 bail!("local install path must not be empty");
151 }
152 let path = PathBuf::from(trimmed);
153 if crate::plugins::agent_plugin::resolve_manifest_path(&path).is_none()
154 && dsh::is_dsh_package(&path)
155 {
156 return Ok(Self::Dsh(path));
157 }
158 Ok(Self::LocalPath(path))
159 }
160 }
161
162 /// Select one manifest-rooted bundle from a repository archive. The fragment
163 /// is local extraction metadata, never a path sent to or executed by a server.
164 fn remote_bundle_path(source: &InstallSource) -> Result<Option<String>> {
165 let InstallSource::DirectUrl(raw) = source else {
166 return Ok(None);
167 };
168 let url = reqwest::Url::parse(raw).context("invalid plugin archive URL")?;
169 let Some(fragment) = url.fragment() else {
170 return Ok(None);
171 };
172 let path = fragment
173 .strip_prefix("path=")
174 .context("plugin archive fragment must be #path=<bundle-directory>")?;
175 if path.is_empty()
176 || !path.split('/').all(|part| {
177 !part.is_empty()
178 && part != "."
179 && part != ".."
180 && part
181 .bytes()
182 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_' | b'.'))
183 })
184 {
185 bail!("plugin archive bundle path must contain only safe relative directory names");
186 }
187 Ok(Some(path.to_string()))
188 }
189
190 /// Serialize a source for the `.installed-from` marker. Must round-trip
191 /// through [`PluginInstallSource::parse`].
192 fn plugin_spec_string(source: &PluginInstallSource, canonical_source: Option<&Path>) -> String {
193 match source {
194 PluginInstallSource::LocalPath(_) => {
195 let path = canonical_source.expect("local installs record the canonical source");
196 format!("path:{}", path.display())
197 }
198 PluginInstallSource::Remote(remote) => source_spec_string(remote),
199 PluginInstallSource::Dsh(_) => {
200 let path = canonical_source.expect("DSH installs record the canonical source");
201 format!("dsh:{}", path.display())
202 }
203 }
204 }
205
206 // ─────────────────────────────────────────────────────────────────────────────
207 // Outcome / result types
208 // ─────────────────────────────────────────────────────────────────────────────
209
210 /// Outcome of an install attempt. Same shape as the skill installer's so the
211 /// caller can drop `NeedsApproval`/`NetworkDenied` into its approval flow.
212 #[derive(Debug)]
213 pub enum PluginInstallOutcome {
214 /// The bundle was installed (atomic rename + marker write succeeded).
215 Installed(InstalledPlugin),
216 /// The download host requires user approval; nothing touched disk.
217 NeedsApproval(String),
218 /// The download host is denied by network policy.
219 NetworkDenied(String),
220 }
221
222 /// Metadata for a successfully installed plugin bundle.
223 #[derive(Debug, Clone)]
224 pub struct InstalledPlugin {
225 /// Plugin name from `[plugin].name`; also the destination directory name.
226 pub name: String,
227 /// Final on-disk path: `<user_plugins_dir>/<name>/`.
228 pub path: PathBuf,
229 /// Whole-bundle content hash of the staged tree (pre-marker). Informational;
230 /// trust receipts always bind to the discovery-time hash.
231 pub content_hash: String,
232 /// Whole-bundle hash after the provenance marker is written. Callers can
233 /// compare this with immediate rediscovery before reporting success.
234 pub installed_content_hash: String,
235 /// SHA-256 over the downloaded tarball bytes (empty for local copies).
236 /// Used by [`update`] to detect upstream changes without re-extracting.
237 pub source_checksum: String,
238 }
239
240 /// Result of an [`update`] call.
241 #[derive(Debug)]
242 pub enum PluginUpdateResult {
243 /// Upstream tarball is byte-identical to the recorded checksum; no action.
244 NoChange,
245 /// Upstream changed and the on-disk bundle was atomically replaced.
246 Updated(InstalledPlugin),
247 /// Network policy requires approval for the download host.
248 NeedsApproval(String),
249 /// Network policy denied the download host.
250 NetworkDenied(String),
251 }
252
253 /// Install-time errors, kept as an enum so tests can pattern-match without
254 /// parsing strings.
255 #[derive(Debug, Error)]
256 pub enum PluginInstallError {
257 #[error("entry escapes destination directory: {0}")]
258 PathTraversal(String),
259 #[error("bundle is too large; uncompressed total would exceed {limit} bytes")]
260 OversizedBundle { limit: u64 },
261 #[error(
262 "archive must contain exactly one plugin bundle root (a directory holding plugin.json, kimi.plugin.json, or plugin.toml); found {0} (install a single plugin bundle, not a mono-repo)"
263 )]
264 PluginTomlRoots(usize),
265 #[error("symlinks and hard links are not allowed in plugin bundles")]
266 SymlinkRejected,
267 #[error("plugin '{0}' is already installed; use /plugin update or uninstall it first")]
268 AlreadyInstalled(String),
269 #[error(
270 "plugin '{0}' was not installed via /plugin install (no .installed-from marker); refusing to touch the hand-placed bundle"
271 )]
272 NotInstalledHere(String),
273 }
274
275 // ─────────────────────────────────────────────────────────────────────────────
276 // Public API
277 // ─────────────────────────────────────────────────────────────────────────────
278
279 /// Install a plugin bundle into `user_plugins_dir`.
280 ///
281 /// Steps: resolve source → (remote only) network-gate and download under the
282 /// size cap → stage into a `.staging-*` sibling, enforcing traversal/symlink/
283 /// size rules and the single-manifest-root requirement → validate the staged
284 /// manifest → `name_conflict` check → atomic rename into `<name>/` → write
285 /// `.installed-from` last.
286 ///
287 /// `update = false` rejects an existing destination. `update = true` (only
288 /// called from [`update`]) requires the marker and replaces atomically with a
289 /// backup-restore on failure.
290 ///
291 /// `name_conflict` is consulted with the validated manifest name before the
292 /// rename; returning `Some(message)` aborts the install. It lets the caller
293 /// reject names already claimed by builtin/workspace bundles.
294 pub async fn install(
295 source: PluginInstallSource,
296 user_plugins_dir: &Path,
297 max_size: u64,
298 network: &NetworkPolicy,
299 update: bool,
300 name_conflict: &(dyn Fn(&str) -> Option<String> + Send + Sync),
301 ) -> Result<PluginInstallOutcome> {
302 install_inner(
303 source,
304 user_plugins_dir,
305 max_size,
306 network,
307 update,
308 name_conflict,
309 None,
310 )
311 .await
312 }
313
314 /// Install only when the exact bytes copied into staging match a prior
315 /// review hash. The comparison happens before atomic placement, so a source
316 /// that changes between inspection and copying leaves no installed bundle.
317 pub async fn install_with_expected_content_hash(
318 source: PluginInstallSource,
319 user_plugins_dir: &Path,
320 max_size: u64,
321 network: &NetworkPolicy,
322 name_conflict: &(dyn Fn(&str) -> Option<String> + Send + Sync),
323 expected_content_hash: &str,
324 ) -> Result<PluginInstallOutcome> {
325 install_inner(
326 source,
327 user_plugins_dir,
328 max_size,
329 network,
330 false,
331 name_conflict,
332 Some(expected_content_hash),
333 )
334 .await
335 }
336
337 async fn install_inner(
338 source: PluginInstallSource,
339 user_plugins_dir: &Path,
340 max_size: u64,
341 network: &NetworkPolicy,
342 update: bool,
343 name_conflict: &(dyn Fn(&str) -> Option<String> + Send + Sync),
344 expected_content_hash: Option<&str>,
345 ) -> Result<PluginInstallOutcome> {
346 match &source {
347 PluginInstallSource::LocalPath(path) => {
348 let staged = stage_local_copy(path, user_plugins_dir, max_size)?;
349 verify_expected_content_hash(&staged, expected_content_hash)?;
350 if let Some(conflict) = name_conflict(&staged.name) {
351 let _ = fs::remove_dir_all(&staged.staged_path);
352 return Err(PluginNameConflict(conflict).into());
353 }
354 let canonical = path
355 .canonicalize()
356 .with_context(|| format!("failed to resolve {}", path.display()))?;
357 finalize_install(
358 staged,
359 &plugin_spec_string(&source, Some(&canonical)),
360 None,
361 "",
362 user_plugins_dir,
363 update,
364 )
365 }
366 PluginInstallSource::Dsh(package) => {
367 let converted = convert_dsh_off_runtime(package.clone()).await?;
368 install_converted_dsh(
369 converted,
370 user_plugins_dir,
371 max_size,
372 update,
373 name_conflict,
374 expected_content_hash,
375 )
376 }
377 PluginInstallSource::Remote(remote) => {
378 let (bytes, url) = match fetch_tarball(remote, network, max_size).await? {
379 FetchOutcome::Bytes { bytes, url } => (bytes, url),
380 FetchOutcome::NeedsApproval(host) => {
381 return Ok(PluginInstallOutcome::NeedsApproval(host));
382 }
383 FetchOutcome::Denied(host) => {
384 return Ok(PluginInstallOutcome::NetworkDenied(host));
385 }
386 };
387 install_remote_bytes(
388 remote,
389 &bytes,
390 &url,
391 user_plugins_dir,
392 max_size,
393 update,
394 name_conflict,
395 expected_content_hash,
396 )
397 }
398 }
399 }
400
401 /// A DSH package converted into scratch; the scratch directory lives as long
402 /// as this value.
403 struct ConvertedDsh {
404 canonical: PathBuf,
405 _scratch: tempfile::TempDir,
406 bundle: PathBuf,
407 }
408
409 /// Parse and convert off the async runtime: conversion reads and copies the
410 /// whole package synchronously.
411 async fn convert_dsh_off_runtime(package: PathBuf) -> Result<ConvertedDsh> {
412 tokio::task::spawn_blocking(move || {
413 let canonical = package
414 .canonicalize()
415 .with_context(|| format!("failed to resolve {}", package.display()))?;
416 let (scratch, bundle, _conversion) = dsh::convert_to_scratch(&canonical)?;
417 Ok(ConvertedDsh {
418 canonical,
419 _scratch: scratch,
420 bundle,
421 })
422 })
423 .await
424 .context("DSH conversion task failed")?
425 }
426
427 /// Stage, verify and place a converted DSH bundle exactly like a local one.
428 /// The marker records the package and the converted bundle's content hash,
429 /// so update re-converts and can tell an unchanged package from a changed one.
430 fn install_converted_dsh(
431 converted: ConvertedDsh,
432 user_plugins_dir: &Path,
433 max_size: u64,
434 update: bool,
435 name_conflict: &(dyn Fn(&str) -> Option<String> + Send + Sync),
436 expected_content_hash: Option<&str>,
437 ) -> Result<PluginInstallOutcome> {
438 let canonical = converted.canonical.clone();
439 let staged = stage_local_copy(&converted.bundle, user_plugins_dir, max_size)?;
440 verify_expected_content_hash(&staged, expected_content_hash)?;
441 if let Some(conflict) = name_conflict(&staged.name) {
442 let _ = fs::remove_dir_all(&staged.staged_path);
443 return Err(PluginNameConflict(conflict).into());
444 }
445 let checksum = staged.content_hash.clone();
446 finalize_install(
447 staged,
448 &plugin_spec_string(
449 &PluginInstallSource::Dsh(canonical.clone()),
450 Some(&canonical),
451 ),
452 None,
453 &checksum,
454 user_plugins_dir,
455 update,
456 )
457 }
458
459 /// Review a DSH package without installing it: the conversion receipt and the
460 /// content hash that an exact install of the same package will stage.
461 pub(crate) fn preview_dsh(package: &Path) -> Result<(dsh::DshConversion, String)> {
462 let (_scratch, bundle, conversion) = dsh::convert_to_scratch(package)?;
463 let manifest = crate::plugins::agent_plugin::resolve_manifest_path(&bundle)
464 .context("converted DSH bundle has no manifest")?;
465 let validated = PluginManifest::validate_from_path(&manifest)
466 .map_err(|error| anyhow::anyhow!("converted DSH bundle failed validation: {error}"))?;
467 Ok((conversion, validated.content_hash))
468 }
469
470 fn verify_expected_content_hash(
471 staged: &stage::StagedPlugin,
472 expected_content_hash: Option<&str>,
473 ) -> Result<()> {
474 let Some(expected) = expected_content_hash else {
475 return Ok(());
476 };
477 if staged.content_hash == expected {
478 return Ok(());
479 }
480 let actual = staged.content_hash.clone();
481 let _ = fs::remove_dir_all(&staged.staged_path);
482 bail!(
483 "plugin source changed after review: expected content hash {expected}, copied bytes hash is {actual}; nothing was installed"
484 )
485 }
486
487 /// Stage and finalize an already-downloaded remote tarball. Kept separate
488 /// from [`install`] so [`update`] can compare the checksum of the bytes it
489 /// already fetched instead of downloading twice.
490 #[allow(clippy::too_many_arguments)]
491 fn install_remote_bytes(
492 remote: &InstallSource,
493 bytes: &[u8],
494 url: &str,
495 user_plugins_dir: &Path,
496 max_size: u64,
497 update: bool,
498 name_conflict: &(dyn Fn(&str) -> Option<String> + Send + Sync),
499 expected_content_hash: Option<&str>,
500 ) -> Result<PluginInstallOutcome> {
501 let checksum = sha256_hex(bytes);
502 let bundle_path = remote_bundle_path(remote)?;
503 let staged = stage_tarball(bytes, user_plugins_dir, max_size, bundle_path.as_deref())?;
504 verify_expected_content_hash(&staged, expected_content_hash)?;
505 if let Some(conflict) = name_conflict(&staged.name) {
506 let _ = fs::remove_dir_all(&staged.staged_path);
507 return Err(PluginNameConflict(conflict).into());
508 }
509 finalize_install(
510 staged,
511 &source_spec_string(remote),
512 Some(url),
513 &checksum,
514 user_plugins_dir,
515 update,
516 )
517 }
518
519 /// Re-fetch a previously installed plugin and atomically replace it if the
520 /// upstream tarball changed. The replaced bundle carries new content, so the
521 /// existing hash-bound trust receipt stops matching at the next discovery —
522 /// re-review is forced by the registry, not by this function.
523 ///
524 /// Bundles installed from a local path cannot be re-downloaded; reinstall
525 /// them with `/plugin install <path>` instead.
526 pub async fn update(
527 name: &str,
528 user_plugins_dir: &Path,
529 max_size: u64,
530 network: &NetworkPolicy,
531 ) -> Result<PluginUpdateResult> {
532 let target = plugin_target_path(name, user_plugins_dir)?;
533 if tokio::fs::try_exists(&target).await.unwrap_or(false) {
534 ensure_target_within_plugins_dir(&target, user_plugins_dir)?;
535 }
536 let marker_path = target.join(INSTALLED_FROM_MARKER);
537 if !tokio::fs::try_exists(&marker_path).await.unwrap_or(false) {
538 return Err(PluginInstallError::NotInstalledHere(name.to_string()).into());
539 }
540 let marker_body = tokio::fs::read_to_string(&marker_path)
541 .await
542 .with_context(|| format!("failed to read {}", marker_path.display()))?;
543 let marker: InstalledFromMarker = serde_json::from_str(&marker_body)
544 .with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?;
545 let source = PluginInstallSource::parse(&marker.spec)?;
546 if let PluginInstallSource::Dsh(package) = &source {
547 // Re-convert the recorded package. An identical converted bundle is
548 // no change; a different one replaces the installed copy, and its
549 // new content hash invalidates the trust receipt at next discovery.
550 let converted = convert_dsh_off_runtime(package.clone()).await?;
551 let content_hash = {
552 let manifest = crate::plugins::agent_plugin::resolve_manifest_path(&converted.bundle)
553 .context("converted DSH bundle has no manifest")?;
554 PluginManifest::validate_from_path(&manifest)
555 .map_err(|error| {
556 anyhow::anyhow!("converted DSH bundle failed validation: {error}")
557 })?
558 .content_hash
559 };
560 if content_hash == marker.source_checksum() {
561 return Ok(PluginUpdateResult::NoChange);
562 }
563 let outcome = install_converted_dsh(
564 converted,
565 user_plugins_dir,
566 max_size,
567 true,
568 &|actual| {
569 (actual != name).then(|| {
570 format!("updated plugin changed name from {name} to {actual}; original plugin preserved")
571 })
572 },
573 None,
574 )?;
575 return match outcome {
576 PluginInstallOutcome::Installed(installed) => {
577 Ok(PluginUpdateResult::Updated(installed))
578 }
579 PluginInstallOutcome::NeedsApproval(host) => {
580 Ok(PluginUpdateResult::NeedsApproval(host))
581 }
582 PluginInstallOutcome::NetworkDenied(host) => {
583 Ok(PluginUpdateResult::NetworkDenied(host))
584 }
585 };
586 }
587 let PluginInstallSource::Remote(remote) = source else {
588 bail!(
589 "plugin '{name}' was installed from a local path ({}) and cannot be updated from the network; \
590 reinstall it with /plugin install <path>",
591 marker.spec
592 );
593 };
594
595 let (bytes, url) = match fetch_tarball(&remote, network, max_size).await? {
596 FetchOutcome::Bytes { bytes, url } => (bytes, url),
597 FetchOutcome::NeedsApproval(host) => {
598 return Ok(PluginUpdateResult::NeedsApproval(host));
599 }
600 FetchOutcome::Denied(host) => return Ok(PluginUpdateResult::NetworkDenied(host)),
601 };
602 if sha256_hex(&bytes) == marker.source_checksum() {
603 return Ok(PluginUpdateResult::NoChange);
604 }
605
606 let outcome = install_remote_bytes(
607 &remote,
608 &bytes,
609 &url,
610 user_plugins_dir,
611 max_size,
612 true,
613 &|actual| {
614 (actual != name).then(|| {
615 format!(
616 "updated plugin changed name from {name} to {actual}; original plugin preserved"
617 )
618 })
619 },
620 None,
621 )?;
622 match outcome {
623 PluginInstallOutcome::Installed(installed) => Ok(PluginUpdateResult::Updated(installed)),
624 PluginInstallOutcome::NeedsApproval(host) => Ok(PluginUpdateResult::NeedsApproval(host)),
625 PluginInstallOutcome::NetworkDenied(host) => Ok(PluginUpdateResult::NetworkDenied(host)),
626 }
627 }
628
629 /// Remove a plugin installed via `/plugin install`.
630 ///
631 /// Refuses to touch any directory that doesn't carry the `.installed-from`
632 /// marker — that's our cue that it's hand-placed and not ours to delete.
633 /// Callers must require the bundle to be disabled first (the mutation
634 /// controller does) and prune the registry state entry afterwards.
635 pub fn uninstall(name: &str, user_plugins_dir: &Path) -> Result<()> {
636 let target = plugin_target_path(name, user_plugins_dir)?;
637 if !target.exists() {
638 bail!("plugin '{name}' is not installed at {}", target.display());
639 }
640 ensure_target_within_plugins_dir(&target, user_plugins_dir)?;
641 if !target.join(INSTALLED_FROM_MARKER).exists() {
642 return Err(PluginInstallError::NotInstalledHere(name.to_string()).into());
643 }
644 fs::remove_dir_all(&target)
645 .with_context(|| format!("failed to remove {}", target.display()))?;
646 Ok(())
647 }
648
648 lines RUST