1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
//! Error type shared by manifest validation, provenance I/O, and the
//! installer trait.
/// Result alias used throughout this crate.
pub type PluginResult<T> = Result<T, PluginError>;
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
/// `plugin.json` failed structural validation (see
/// [`crate::manifest::PluginManifest::validate`]) — includes bad ids,
/// bad semver shape, path-traversal attempts, duplicate ids, etc.
#[error("invalid plugin manifest: {0}")]
InvalidManifest(String),
/// No installed plugin with this id (uninstall/lookup).
#[error("plugin not found: {0}")]
NotFound(String),
/// A plugin with this id is already installed and `install` was called
/// with [`crate::installer::InstallDisposition::FailIfInstalled`] (the
/// `bamboo plugin install` verb). Re-run as an upgrade
/// (`bamboo plugin update` / [`crate::installer::InstallDisposition::Upgrade`])
/// to replace it in place.
#[error("plugin already installed: {0} (use `update` to upgrade in place)")]
AlreadyInstalled(String),
/// A capability the plugin declares collides with an existing entry in a
/// shared store that is NOT owned by this plugin (a user's own entry, or
/// another plugin's). For MCP servers and workflows the install REFUSES
/// rather than clobbering — an MCP server id / workflow filename is
/// referenced elsewhere, so silently overwriting (and later deleting on
/// uninstall) would destroy the user's entry. `kind` is a short label
/// such as `"mcp server"` or `"workflow"`.
#[error(
"{kind} '{name}' already exists and is not owned by plugin '{plugin_id}'; \
refusing to overwrite — rename the conflicting entry or the plugin's, then retry"
)]
Conflict {
kind: &'static str,
name: String,
plugin_id: String,
},
/// The manifest's `platforms` gate excludes the current OS.
#[error("plugin '{plugin_id}' does not support platform '{platform}'")]
UnsupportedPlatform { plugin_id: String, platform: String },
/// A step this foundation crate deliberately leaves for a later agent
/// (capability-registration wiring — see `PLUGIN_PLAN.md`). Returned
/// instead of panicking so a partially-stacked branch fails a request
/// cleanly rather than crashing the process.
#[error("not yet implemented: {0}")]
NotImplemented(String),
/// A capability failed to register/deregister against `AppState` for a
/// reason OTHER than an ownership conflict (e.g. `config.json` couldn't
/// be persisted, a network fetch during source-staging failed). Kept
/// distinct from [`Self::Conflict`] (a deliberate REFUSAL, not a
/// failure) so callers/HTTP status mapping can tell "your plugin
/// collides with something" apart from "something broke while trying to
/// register/fetch it".
#[error("plugin registration failed: {0}")]
Registration(String),
/// A downloaded artifact's sha256 did not match the manifest's declared
/// hash. Checked BEFORE unpacking (supply-chain: a URL-installed plugin
/// ships a binary that will be executed) — never surfaced as a generic
/// `Registration`/`InvalidManifest` error so callers can distinguish "the
/// author's manifest is malformed" from "the bytes served at that URL do
/// not match what the manifest promised".
#[error("artifact verification failed: {0}")]
ArtifactVerificationFailed(String),
/// A downloaded URL plugin BUNDLE (the `plugin.json` or the archive
/// containing it — whatever the app layer's URL-source fetch downloads)
/// did not hash to the caller-supplied expected sha256. Checked BEFORE
/// any extraction/parsing, so a tampered bundle is never trusted even
/// partially. Distinct from [`Self::ArtifactVerificationFailed`] (which
/// is pinned by a hash declared INSIDE the manifest — itself only
/// trustworthy once the bundle carrying it is verified): this is the
/// root-of-trust check that closes the circular-trust hole where the
/// manifest's own artifact hash could be rewritten by whoever tampered
/// with the bundle.
#[error("bundle verification failed: {0}")]
BundleVerificationFailed(String),
/// A URL plugin install/update was requested with neither a `sha256` to
/// verify the downloaded bundle against, nor an explicit
/// `allow_unverified` opt-in. This is the secure-by-default refusal: a
/// URL install must be either checksum-pinned or an explicit,
/// deliberate risk acceptance — never a silent "download and trust any
/// tar.gz". Raised BEFORE the URL is ever fetched (no network access
/// happens for a refused install).
#[error("{0}")]
ChecksumRequired(String),
/// A URL plugin install/update's host (and path) is not in the
/// configured `plugin_trust.trusted_hosts` allowlist, and the request did
/// not set `allow_untrusted_host`. This is the SOURCE-authorization
/// layer (host allowlist) — distinct from [`Self::BundleVerificationFailed`]
/// (integrity) and [`Self::UnsignedOrUntrustedSignature`] (publisher
/// authenticity). Raised BEFORE the URL is ever fetched, same posture as
/// [`Self::ChecksumRequired`].
#[error("{0}")]
UntrustedHost(String),
/// A URL plugin bundle is unsigned, or its `.sig` sidecar does not verify
/// against any key in `plugin_trust.trusted_keys`, and the request did
/// not set `allow_unsigned`. This is the PUBLISHER-authenticity layer —
/// a signature proves who produced the bytes, which a bare sha256 (only
/// proving WHAT the bytes are) cannot. Raised after the bundle is
/// downloaded (the signature is verified over the downloaded bytes) but
/// before anything is extracted/parsed.
#[error("{0}")]
UnsignedOrUntrustedSignature(String),
/// A URL plugin install whose bytes will NOT be cryptographically
/// authenticated (no signature required AND no `sha256` — the fully
/// opted-out `allow_unsigned && allow_unverified`, "host-only trust"
/// case) was served an HTTP redirect instead of the bytes. In that case
/// the host allowlist is the SOLE control over where the bytes come
/// from, and it only vetted the FIRST hop — transparently following the
/// redirect would let the bytes come from an unvetted host and silently
/// defeat the allowlist, so redirects are not followed and a redirect
/// response is refused. A trust/authorization refusal (same 403 family
/// as [`Self::UntrustedHost`] / [`Self::UnsignedOrUntrustedSignature`]),
/// NOT a server error — it tells the caller how to proceed (install from
/// the canonical/final URL, provide a signature/`--sha256`, or trust the
/// redirect target). Does not arise once a signature or checksum is in
/// play: those authenticate the bytes regardless of which host served
/// them, so redirects are followed in that case.
#[error("{0}")]
RedirectRefused(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}