Skip to main content

devboy_storage/
plugin_protocol.rs

1//! JSON-RPC over stdio wire protocol for `SecretSource`
2//! plugins per [ADR-021] §10 (subprocess plugin extension).
3//!
4//! The host (`devboy-tools`) and the plugin exchange one
5//! request and one response per line — the same newline-delimited
6//! framing the secrets-agent daemon uses. Methods correspond
7//! 1:1 to the [`crate::source::SecretSource`] trait so an
8//! out-of-tree plugin can implement any backend the framework
9//! doesn't ship natively (Doppler, AWS Secrets Manager, custom
10//! HSMs, …) without touching the core.
11//!
12//! ## Method names
13//!
14//! | RPC method                    | Trait method                           |
15//! |-------------------------------|----------------------------------------|
16//! | `secret_source.init`          | (no equivalent — capability handshake) |
17//! | `secret_source.is_available`  | [`SecretSource::is_available`]         |
18//! | `secret_source.get`           | [`SecretSource::get`]                  |
19//! | `secret_source.list`          | [`SecretSource::list`]                 |
20//! | `secret_source.validate`      | [`SecretSource::validate`]             |
21//!
22//! `init` is the first call — the host sends config and gets
23//! back the plugin's name + capability bitset. Subsequent
24//! calls happen against an initialised session.
25//!
26//! ## Wire format
27//!
28//! Each line is a complete JSON-RPC 2.0 frame
29//! (id + method or id + result/error). The `params` and
30//! `result` payloads are typed via the enums below.
31//!
32//! ## What this module does **not** do
33//!
34//! Spawn the subprocess, manage its lifetime, or implement
35//! retry / restart semantics. Those concerns live in the
36//! plugin client (P15.2) which builds on top of these wire
37//! types.
38//!
39//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-secret-source-router.md
40//! [`SecretSource::is_available`]: crate::source::SecretSource::is_available
41//! [`SecretSource::get`]: crate::source::SecretSource::get
42//! [`SecretSource::list`]: crate::source::SecretSource::list
43//! [`SecretSource::validate`]: crate::source::SecretSource::validate
44
45use std::collections::BTreeMap;
46
47use serde::{Deserialize, Serialize};
48
49// =============================================================================
50// JSON-RPC 2.0 framing
51// =============================================================================
52
53/// Pinned protocol version. Bumped on a breaking change so
54/// host + plugin can refuse to talk if the major versions
55/// don't match.
56pub const PROTOCOL_VERSION: &str = "1.0";
57
58/// Wrapper for a single request line. Always includes
59/// `jsonrpc = "2.0"`, an integer `id`, and a method name with
60/// typed params.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct PluginRpcRequest {
63    pub jsonrpc: JsonRpcVersion,
64    pub id: u64,
65    #[serde(flatten)]
66    pub call: PluginRequest,
67}
68
69/// Wrapper for a single response line. Carries either `result`
70/// or `error`.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct PluginRpcResponse {
73    pub jsonrpc: JsonRpcVersion,
74    pub id: u64,
75    #[serde(flatten)]
76    pub outcome: RpcOutcome,
77}
78
79/// `result` xor `error` — one of, never both. Tagged
80/// internally so a malformed response that includes both
81/// fields fails to deserialise.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum RpcOutcome {
85    Result(PluginResponse),
86    Error(PluginError),
87}
88
89/// Newtype around the literal `"2.0"` so a malformed frame
90/// fails fast at parse time instead of at the dispatcher.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct JsonRpcVersion(String);
93
94impl JsonRpcVersion {
95    pub fn current() -> Self {
96        Self("2.0".into())
97    }
98    pub fn as_str(&self) -> &str {
99        &self.0
100    }
101    pub fn is_supported(&self) -> bool {
102        self.0 == "2.0"
103    }
104}
105
106impl Default for JsonRpcVersion {
107    fn default() -> Self {
108        Self::current()
109    }
110}
111
112// =============================================================================
113// Requests
114// =============================================================================
115
116/// One request the host sends. Tagged by `method` for clean
117/// JSON-RPC 2.0 framing and so a future method addition can be
118/// added as a new variant without touching dispatch.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(tag = "method", content = "params", rename_all = "snake_case")]
121pub enum PluginRequest {
122    /// First call after spawn. Hands the plugin its name +
123    /// per-instance config (the `[[source]]` block from
124    /// `sources.toml`) and receives the plugin's name +
125    /// capability bitset back.
126    #[serde(rename = "secret_source.init")]
127    Init(InitParams),
128    /// Probe — returns the plugin's current readiness.
129    #[serde(rename = "secret_source.is_available")]
130    IsAvailable,
131    /// Fetch the value at `reference`. Plugin returns
132    /// [`GetResult`] or an error.
133    #[serde(rename = "secret_source.get")]
134    Get(GetParams),
135    /// Enumerate the plugin's inventory.
136    #[serde(rename = "secret_source.list")]
137    List,
138    /// Confirm a reference is well-formed without round-trip.
139    #[serde(rename = "secret_source.validate")]
140    Validate(ValidateParams),
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct InitParams {
145    /// Source name as configured in `sources.toml` (e.g.
146    /// `"prod-vault"`). Lets the plugin distinguish multiple
147    /// instances of itself.
148    pub source_name: String,
149    /// Per-instance config from `sources.toml` (free-form
150    /// table — the plugin parses what it needs).
151    pub config: BTreeMap<String, serde_json::Value>,
152    /// Pinned protocol version. Plugin compares to its own and
153    /// errors on mismatch.
154    pub protocol_version: String,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct GetParams {
159    pub reference: String,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
163pub struct ValidateParams {
164    pub reference: String,
165}
166
167// =============================================================================
168// Responses
169// =============================================================================
170
171/// Successful reply payload. Variants mirror the request set
172/// (minus `IsAvailable`/`Validate`/`List` which have their own
173/// shapes).
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(untagged)]
176pub enum PluginResponse {
177    Init(InitResult),
178    IsAvailable(IsAvailableResult),
179    Get(GetResult),
180    List(ListResult),
181    Validate(ValidateResult),
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct InitResult {
186    /// Echoed source name — lets the host detect a plugin
187    /// that's been pointed at the wrong config.
188    pub source_name: String,
189    /// Capability bitset (raw bits — see
190    /// [`crate::source::Capabilities`] for the layout).
191    pub capabilities_bits: u32,
192    /// Plugin's self-reported version (advisory; logged for
193    /// support).
194    pub plugin_version: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct IsAvailableResult {
199    pub status: IsAvailableStatus,
200    /// Optional human-readable detail (e.g. "Vault token
201    /// expired in 12s"). The host surfaces this in `doctor`.
202    pub detail: Option<String>,
203}
204
205/// Ground states the plugin can report. Mirrors
206/// [`crate::source::SourceStatus`] but kept independent so the
207/// wire protocol can evolve separately from the in-process
208/// trait.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
210#[serde(rename_all = "kebab-case")]
211pub enum IsAvailableStatus {
212    Available,
213    Unavailable,
214    /// Plugin reachable but its credential is missing /
215    /// expired (e.g. `op signin` needed). The host shows the
216    /// detail string and may try a fallback source.
217    NeedsCredential,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct GetResult {
222    /// The secret value as plaintext. The plugin client wraps
223    /// it in [`secrecy::SecretString`] before returning to the
224    /// rest of the framework — the wire is the only place a
225    /// raw `String` is acceptable.
226    pub value: String,
227    /// Upstream-reported lease duration, in seconds. `None`
228    /// means "no lease" → cache uses default TTL.
229    pub lease_seconds: Option<u64>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct ListResult {
234    pub entries: Vec<RemoteRefDto>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct RemoteRefDto {
239    pub reference: String,
240    pub display: Option<String>,
241}
242
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct ValidateResult {
245    /// `true` when the reference parses cleanly. `false` is
246    /// reported via [`PluginError::BadReference`] instead so
247    /// success is unambiguous.
248    pub ok: bool,
249}
250
251// =============================================================================
252// Errors
253// =============================================================================
254
255/// Error variants the plugin can return. The codes mirror
256/// JSON-RPC 2.0 reserved ranges; payload is structured so the
257/// host can map back to [`crate::source::SourceError`] without
258/// regex-parsing strings.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
260#[serde(tag = "kind", rename_all = "kebab-case")]
261pub enum PluginError {
262    /// `secret_source.is_available` returned non-Available.
263    #[error("source unavailable: {detail}")]
264    Unavailable { detail: String },
265    /// Plugin doesn't implement the requested capability
266    /// (e.g. `list` on a write-only backend).
267    #[error("source does not support capability: {capability}")]
268    UnsupportedCapability { capability: String },
269    /// Reference string didn't parse for this backend.
270    #[error("source rejected reference `{reference}`: {reason}")]
271    BadReference { reference: String, reason: String },
272    /// Plugin needs a credential it doesn't have (e.g. `op
273    /// signin` not yet completed).
274    #[error("source needs credential: {detail}")]
275    NeedsCredential { detail: String },
276    /// Anything else — wire-format parse error, transport
277    /// failure, etc. The host treats this as opaque and surfaces
278    /// it in logs.
279    #[error("source error: {detail}")]
280    Other { detail: String },
281}
282
283// =============================================================================
284// Tests
285// =============================================================================
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use serde_json::json;
291
292    fn req(call: PluginRequest, id: u64) -> PluginRpcRequest {
293        PluginRpcRequest {
294            jsonrpc: JsonRpcVersion::current(),
295            id,
296            call,
297        }
298    }
299
300    fn ok(id: u64, resp: PluginResponse) -> PluginRpcResponse {
301        PluginRpcResponse {
302            jsonrpc: JsonRpcVersion::current(),
303            id,
304            outcome: RpcOutcome::Result(resp),
305        }
306    }
307
308    // -- JSON-RPC framing ------------------------------------
309
310    #[test]
311    fn jsonrpc_version_constant_is_v2() {
312        assert_eq!(JsonRpcVersion::current().as_str(), "2.0");
313        assert!(JsonRpcVersion::current().is_supported());
314    }
315
316    #[test]
317    fn jsonrpc_version_rejects_anything_other_than_v2() {
318        let v = JsonRpcVersion("1.0".into());
319        assert!(!v.is_supported());
320    }
321
322    // -- Request framing -------------------------------------
323
324    #[test]
325    fn init_request_round_trips_through_json() {
326        let mut config = BTreeMap::new();
327        config.insert("address".into(), json!("https://vault.example.invalid"));
328        let r = req(
329            PluginRequest::Init(InitParams {
330                source_name: "prod-vault".into(),
331                config,
332                protocol_version: PROTOCOL_VERSION.into(),
333            }),
334            1,
335        );
336        let line = serde_json::to_string(&r).unwrap();
337        assert!(line.contains("\"method\":\"secret_source.init\""));
338        assert!(line.contains("\"prod-vault\""));
339        let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
340        assert_eq!(back, r);
341    }
342
343    #[test]
344    fn is_available_request_has_no_params() {
345        let r = req(PluginRequest::IsAvailable, 7);
346        let line = serde_json::to_string(&r).unwrap();
347        assert!(line.contains("\"method\":\"secret_source.is_available\""));
348        let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
349        assert_eq!(back, r);
350    }
351
352    #[test]
353    fn get_request_carries_reference_only() {
354        let r = req(
355            PluginRequest::Get(GetParams {
356                reference: "secret/data/team/jira".into(),
357            }),
358            42,
359        );
360        let line = serde_json::to_string(&r).unwrap();
361        assert!(line.contains("\"method\":\"secret_source.get\""));
362        assert!(line.contains("\"reference\":\"secret/data/team/jira\""));
363        let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
364        assert_eq!(back, r);
365    }
366
367    #[test]
368    fn list_request_has_no_params() {
369        let r = req(PluginRequest::List, 99);
370        let line = serde_json::to_string(&r).unwrap();
371        assert!(line.contains("\"method\":\"secret_source.list\""));
372        let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
373        assert_eq!(back, r);
374    }
375
376    #[test]
377    fn validate_request_round_trips() {
378        let r = req(
379            PluginRequest::Validate(ValidateParams {
380                reference: "op://Private/jira".into(),
381            }),
382            123,
383        );
384        let line = serde_json::to_string(&r).unwrap();
385        assert!(line.contains("\"method\":\"secret_source.validate\""));
386        let back: PluginRpcRequest = serde_json::from_str(&line).unwrap();
387        assert_eq!(back, r);
388    }
389
390    // -- Response framing -------------------------------------
391
392    #[test]
393    fn init_result_round_trips() {
394        let resp = ok(
395            1,
396            PluginResponse::Init(InitResult {
397                source_name: "prod-vault".into(),
398                capabilities_bits: 0b0000_0011,
399                plugin_version: "0.1.0".into(),
400            }),
401        );
402        let line = serde_json::to_string(&resp).unwrap();
403        let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
404        assert_eq!(back, resp);
405    }
406
407    #[test]
408    fn get_result_round_trips_with_lease() {
409        let resp = ok(
410            42,
411            PluginResponse::Get(GetResult {
412                value: "test-value-not-secret".into(),
413                lease_seconds: Some(3600),
414            }),
415        );
416        let line = serde_json::to_string(&resp).unwrap();
417        let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
418        assert_eq!(back, resp);
419    }
420
421    #[test]
422    fn list_result_round_trips_empty_and_populated() {
423        let resp = ok(99, PluginResponse::List(ListResult { entries: vec![] }));
424        let line = serde_json::to_string(&resp).unwrap();
425        let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
426        assert_eq!(back, resp);
427
428        let resp2 = ok(
429            100,
430            PluginResponse::List(ListResult {
431                entries: vec![RemoteRefDto {
432                    reference: "secret/data/team/jira".into(),
433                    display: Some("Jira API token".into()),
434                }],
435            }),
436        );
437        let line2 = serde_json::to_string(&resp2).unwrap();
438        let back2: PluginRpcResponse = serde_json::from_str(&line2).unwrap();
439        assert_eq!(back2, resp2);
440    }
441
442    #[test]
443    fn is_available_status_strings_are_pinned() {
444        assert_eq!(
445            serde_json::to_value(IsAvailableStatus::Available).unwrap(),
446            json!("available")
447        );
448        assert_eq!(
449            serde_json::to_value(IsAvailableStatus::NeedsCredential).unwrap(),
450            json!("needs-credential")
451        );
452        assert_eq!(
453            serde_json::to_value(IsAvailableStatus::Unavailable).unwrap(),
454            json!("unavailable")
455        );
456    }
457
458    // -- Error framing ----------------------------------------
459
460    #[test]
461    fn error_response_round_trips_each_kind() {
462        for err in [
463            PluginError::Unavailable {
464                detail: "vault sealed".into(),
465            },
466            PluginError::UnsupportedCapability {
467                capability: "list".into(),
468            },
469            PluginError::BadReference {
470                reference: "garbage".into(),
471                reason: "not a vault path".into(),
472            },
473            PluginError::NeedsCredential {
474                detail: "op signin required".into(),
475            },
476            PluginError::Other {
477                detail: "transport timeout".into(),
478            },
479        ] {
480            let envelope = PluginRpcResponse {
481                jsonrpc: JsonRpcVersion::current(),
482                id: 1,
483                outcome: RpcOutcome::Error(err),
484            };
485            let line = serde_json::to_string(&envelope).unwrap();
486            let back: PluginRpcResponse = serde_json::from_str(&line).unwrap();
487            assert_eq!(back, envelope);
488        }
489    }
490
491    #[test]
492    fn rpc_outcome_distinguishes_result_from_error_at_parse_time() {
493        let line_ok = r#"{"jsonrpc":"2.0","id":1,"result":{"value":"v","lease_seconds":null}}"#;
494        let parsed: PluginRpcResponse = serde_json::from_str(line_ok).unwrap();
495        assert!(matches!(parsed.outcome, RpcOutcome::Result(_)));
496
497        let line_err = r#"{"jsonrpc":"2.0","id":1,"error":{"kind":"unavailable","detail":"x"}}"#;
498        let parsed: PluginRpcResponse = serde_json::from_str(line_err).unwrap();
499        assert!(matches!(parsed.outcome, RpcOutcome::Error(_)));
500    }
501}