# Source plugin protocol: writing your own `SecretSource` A guide for community plugin authors who want to extend the list of secret sources in `devboy-tools` beyond what ships in the box (keychain, local-vault, 1Password, Vault, env-store). Covers the stdio JSON-RPC wire format, the sidecar manifest, discovery, and the lifecycle contract. See [ADR-021](https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-secret-source-router.md) §6 (the `SecretSource` trait) and §10 (subprocess plugin extension), plus `crates/devboy-storage/src/plugin_protocol.rs`, `plugin_manifest.rs`, and `plugin_client.rs` for the reference host implementation. > Russian translation: [`ru/source-plugin-protocol.md`](./ru/source-plugin-protocol.md). --- ## Why this exists The standard sources cover typical setups, but if your team stores secrets in: - an internal self-hosted KV server that doesn't speak the Vault HTTP API, - a proprietary HSM with a CLI wrapper, - a legacy pipeline that pulls values from a domain-specific DB, it's faster to ship a subprocess plugin than wait for an upstream feature. The host launches the plugin lazily and talks to it over stdio JSON-RPC; the plugin can be in any language (Python, Go, shell — anything that reads stdin line-by-line and writes to stdout). ## High-level architecture ```text ┌──────────────────────────────────┐ │ devboy host (CLI / daemon / MCP) │ └──────────────────────────────────┘ │ ▼ lazy spawn on first request ┌──────────────────────────────────────────────────┐ │ ~/.devboy/plugins/secrets/devboy-source- │ │ ↑↓ newline-delimited JSON-RPC over stdin/stdout│ └──────────────────────────────────────────────────┘ │ ▼ real backend (HSM, custom KV, …) ``` The host keeps one subprocess per plugin alive for the session (with an idle reaper — see lifecycle), sends commands over stdin, reads replies from stdout. No network sockets, no FFI — only a pipe. ## Wire format: JSON-RPC 2.0 over stdin/stdout Each frame is one line containing one JSON object, terminated by `\n`. Requests and replies are strictly serial (one request → one reply; no concurrency). ### Request (host → plugin) ```json { "jsonrpc": "2.0", "id": 1, "method": "secret_source.init", "params": { "source_name": "", "config": { "address": "https://example.invalid/" }, "protocol_version": "1.0" } } ``` - `jsonrpc` — always the string `"2.0"`. Any other value must reply with an error. - `id` — integer (u64). The host guarantees uniqueness within a single process. - `method` — one of the five names below. - `params` — method-specific object. Empty object (`{}`) or omitted for parameter-less methods. ### Successful reply (plugin → host) ```json { "jsonrpc": "2.0", "id": 1, "result": { "source_name": "", "capabilities_bits": 1, "plugin_version": "0.1.0" } } ``` `id` must echo the request's `id`. The host checks and aborts if they don't match. ### Error reply ```json { "jsonrpc": "2.0", "id": 1, "error": { "kind": "needs-credential", "detail": "op signin required for account " } } ``` `result` and `error` are mutually exclusive — exactly one is present in each reply. ## The five methods ### `secret_source.init` The first call after spawn. The host hands the source name and config from `sources.toml`; the plugin returns its capabilities. **Request params**: ```json { "source_name": "", "config": { "": "" }, "protocol_version": "1.0" } ``` **Response result**: ```json { "source_name": "", "capabilities_bits": 1, "plugin_version": "" } ``` `capabilities_bits` is a bit mask (see `crates/devboy-storage/src/source.rs::Capabilities`): | Bit | Capability | What you're claiming | |---|---|---| | `0b0000_0001` | `READ` | Supports `secret_source.get` | | `0b0000_0010` | `LIST` | Supports `secret_source.list` | | `0b0000_0100` | `VALIDATE` | Supports `secret_source.validate` | | `0b0000_1000` | `WRITE` | Reserved (registered, not yet used) | | `0b0001_0000` | `ROTATE` | Reserved | | `0b0010_0000` | `BIOMETRIC_PROMPT` | Source may prompt for biometrics (1Password Touch ID) | | `0b0100_0000` | `AUDIT_LOGGED` | Source writes an audit log on every access | If the plugin only supports READ — send `"capabilities_bits": 1`. The host refuses to continue if the request's `protocol_version` is older than the plugin understands at the major version. ### `secret_source.is_available` Readiness check. Cheap operation, must return quickly (< 100 ms is the target). **Request params**: none (empty object `{}` or omitted). **Response result**: ```json { "status": "available", "detail": null } ``` Possible `status` values: - `available` — plugin is ready to answer `get` / `list` / `validate`. - `unavailable` — backend down (network down, file corrupt). `detail` — short description for `doctor`. - `needs-credential` — plugin is up, but its credential is missing or expired (`op signin` required). `detail` — exactly what to do. ### `secret_source.get` Fetch a value by reference. **Request params**: ```json { "reference": "secret/data/team//api-key" } ``` **Response result**: ```json { "value": "", "lease_seconds": 3600 } ``` `value` is plaintext. **This is the only place in the protocol where a value crosses the wire.** The host wraps it in `secrecy::SecretString` immediately and zeroizes after use. The plugin must not log `value`. `lease_seconds` is optional — for backends with leases (Vault dynamic secrets). The host uses it as the upper bound for its cache TTL. If the value isn't there, return `error.kind = "bad-reference"` with `reference: "<reference>"` and `reason: "not found"`. ### `secret_source.list` List the backend's inventory. Used by the discovery flow in TUI/GUI and by the `secrets_propose_new_path` MCP tool. **Request params**: none. **Response result**: ```json { "entries": [ { "reference": "secret/data/team/<provider>/api-key", "display": "Team <provider> API key" }, { "reference": "secret/data/team/<provider>/deploy-token", "display": null } ] } ``` If the backend doesn't support enumeration, return `error.kind = "unsupported-capability"` with `capability: "list"`. ### `secret_source.validate` Check that a reference is well-formed without round-tripping for the value. Cheap sanity check. **Request params**: ```json { "reference": "secret/data/team/<provider>/api-key" } ``` **Response result**: ```json { "ok": true } ``` `ok = false` is unusual; report errors via `error.kind = "bad-reference"` instead. ## Error variants ```json { "kind": "unavailable", "detail": "<...>" } { "kind": "unsupported-capability", "capability": "<list|write|...>" } { "kind": "bad-reference", "reference": "<r>", "reason": "<...>" } { "kind": "needs-credential", "detail": "<...>" } { "kind": "other", "detail": "<...>" } ``` The host maps these one-to-one onto its `SourceError` enum. `other` is a catch-all for anything that doesn't fit the others (transport timeout, parse error, …). ## Sidecar manifest Every plugin sits next to a TOML descriptor: ```text ~/.devboy/plugins/secrets/ ├── devboy-source-<name>.toml # sidecar manifest └── devboy-source-<name> # executable ``` ### `devboy-source-<name>.toml` format ```toml name = "<name>" version = "0.1.0" executable = "devboy-source-<name>" allowed_env_vars = ["HOME", "PATH"] checksum_sha256 = "<lower-case-hex-digest-of-executable>" ``` Fields: - `name` — short identifier. Must equal `<name>` in the manifest filename — the host refuses to load otherwise. - `version` — advisory; logged and shown in `doctor`. Not used for semantic compatibility. - `executable` — path relative to the manifest directory (or absolute). The host canonicalises and checks the file exists. - `allowed_env_vars` — the only env vars that reach the child process. The host calls `Command::env_clear()` before exec, then injects exactly these variables. Everything else (including `$AWS_SECRET_KEY`) is hidden. - `checksum_sha256` — SHA-256 hex (case-insensitive) of the executable bytes. The host re-hashes and refuses to launch on mismatch. ### Where it lives The default discovery directory is `$HOME/.devboy/plugins/secrets/`. The host scans it at startup, looks for files starting with `devboy-source-` and ending in `.toml`. Each manifest parses independently — one broken config does not disable the rest of the plugins. ## Lifecycle contract See ADR-021 §10 for the full statement. In short: | Parameter | Default | Description | |---|---|---| | **Lazy spawn** | — | The plugin is not launched until the host actually needs the first request. | | **Idle timeout** | 60 seconds | If unused for longer, the host kills the process before the next request and respawns. | | **Shutdown grace** | 10 seconds | On stop, the host sends `SIGTERM`, waits the grace, then `SIGKILL`. | | **Restart cap** | 3 crashes / 60 seconds | Crash above the cap → state `Disabled`. Further requests refuse without spawn. Reset by the operator via `doctor`. | | **Env restriction** | `allowed_env_vars` from manifest | The child process sees only the listed variables. | What this means for the plugin author: - **Don't assume long uptime.** Any long-lived state lives in the external backend (KV server, file), not the process memory. After idle-reap, in-memory state is gone. - **Init must be cheap.** Every lazy spawn is `init` + first operation. If init reads a 100 MB cache from disk, the user notices a lag on every cold call. - **Crash safety.** A panic counts as a crash against the restart cap. Catch exceptions in the plugin and return `error.kind = "other"` with a useful `detail` instead of letting the process die. - **Clean shutdown.** When the host closes your stdin, that's the signal for graceful exit. Release resources and return from main. ## Sample plugin: echo-source The repo ships a working example at `examples/secrets-source-echo/`. A Python script that: - Accepts init and claims `READ | LIST | VALIDATE` capabilities. - On `get`, returns `value = format!("echo:{reference}")` — i.e. value mirrors reference. Convenient for smoke tests and learning the protocol. - On `list`, returns three fake entries. - On `validate`, accepts any non-empty reference. See [`examples/secrets-source-echo/README.md`](https://github.com/meteora-pro/devboy-tools/blob/main/examples/secrets-source-echo/README.md) for build + install + smoke-test instructions. ## New-plugin checklist 1. [ ] Implement the main loop: read stdin line-by-line, parse JSON, dispatch by `method`, print one line of JSON reply to stdout. 2. [ ] Cover all five methods or return `unsupported-capability` for ones you don't. 3. [ ] Declare honest `capabilities_bits` in the `init` reply — otherwise the host will ask for things you don't deliver. 4. [ ] Never log the `value` from `secret_source.get`. Never. 5. [ ] Handle signals (SIGTERM / Ctrl+D) cleanly — graceful exit, no hangs. 6. [ ] Write the sidecar TOML with correct `name`, `executable`, `allowed_env_vars`, `checksum_sha256`. 7. [ ] Drop both files into `~/.devboy/plugins/secrets/`. 8. [ ] Run `devboy doctor --secrets`. Your plugin should show up in the source list. 9. [ ] Run `devboy secrets describe --source <name>` (when the flag lands) or trigger the `secrets_request_provision` MCP tool against a path that routes into your plugin. ## See also - [`onboarding.md`](./onboarding.md) — how to add a custom source to `sources.toml` so the router uses it. - [`agent-protocol.md`](./agent-protocol.md) — how the agent sees your source through MCP tools. - [`token-catalog.md`](./token-catalog.md) — JSON catalogs that fill the GUI's "where do I get this token" panel; orthogonal to the source plugin protocol but you may want both. - [`catalog-url-sources.md`](./catalog-url-sources.md) — serving catalogs over the network with sha-pinning + audit log. - ADR-021 §6 — formal `SecretSource` trait this wire protocol maps onto. - ADR-021 §10 — subprocess plugin lifecycle contract with rationale for the defaults. - `crates/devboy-storage/src/plugin_protocol.rs` — wire-format types in Rust. - `crates/devboy-storage/src/plugin_manifest.rs` — sidecar parser + checksum verification. - `crates/devboy-storage/src/plugin_client.rs` — host-side supervisor with lazy spawn / idle reap / restart cap.