Skip to main content

plugins_protocol/
lib.rs

1//! Newt-Agent provider-plugin protocol.
2//!
3//! Provider plugins run as separate processes and speak JSON-RPC over stdio.
4//! They register opt-in inference backends — most notably the cloud
5//! backends (OpenAI, Anthropic) that the default Newt binary deliberately
6//! does not link.
7//!
8//! v0 surface: `initialize`, `list_models`, `complete`, `stream`, `shutdown`.
9
10mod client;
11mod server;
12
13pub use client::PluginClient;
14pub use server::{PluginHandler, PluginServer};
15
16use serde::{Deserialize, Serialize};
17
18pub type Result<T> = std::result::Result<T, Error>;
19
20#[derive(Debug, thiserror::Error)]
21pub enum Error {
22    #[error("plugin I/O error: {0}")]
23    Io(#[from] std::io::Error),
24    #[error("plugin JSON error: {0}")]
25    Json(#[from] serde_json::Error),
26    #[error("plugin RPC error {code}: {message}")]
27    Rpc { code: i64, message: String },
28    #[error("plugin protocol error: {0}")]
29    Protocol(String),
30    #[error("plugin request timed out: {method}")]
31    Timeout { method: String },
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct InitializeRequest {
36    pub protocol_version: u32,
37    pub client_name: String,
38    pub client_version: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct InitializeResponse {
43    pub plugin_name: String,
44    pub plugin_version: String,
45    pub supported_models: Vec<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CompleteRequest {
50    pub model: String,
51    pub messages: Vec<Message>,
52    pub max_tokens: Option<u32>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct CompleteResponse {
57    pub content: String,
58    pub model_id: String,
59    pub usage: Option<Usage>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ListModelsResponse {
64    pub models: Vec<String>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Message {
69    pub role: String,
70    pub content: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct Usage {
75    pub input_tokens: u32,
76    pub output_tokens: u32,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RpcErrorObject {
81    pub code: i64,
82    pub message: String,
83}
84
85pub const PROTOCOL_VERSION: u32 = 0;
86
87/// Environment variable a Newt host sets when spawning a provider plugin to
88/// hand the plugin a base64-encoded JSON [`CertChain`] (an attenuated
89/// [`AgentKey`]) for this dispatch.
90///
91/// Phase 1c transport (issue #35). The host minted this child cert by
92/// calling [`AgentKey::delegate`] on its own parent key — so the chain is
93/// signed end-to-end, attenuation is structurally enforced, and the plugin
94/// can verify the chain locally without a separate trust anchor (the chain
95/// roots at the user's `UserPublic`, which is embedded in the leaf cert).
96///
97/// Plugins that **don't** read this env var run with whatever ambient
98/// authority they had before phase 1c — that's a deliberate
99/// back-compat behavior for older plugins. Plugins built against phase 1c
100/// or later **SHOULD** read this and use it as the source of truth for
101/// every tool dispatch they make.
102///
103/// Why env var (and not a `CompleteRequest` field)?
104/// - Simpler — no protocol-version bump required, older plugins ignore it.
105/// - Per-process — the cert is attached to the plugin's lifetime, not to
106///   any individual call.
107/// - Phase 1d can swap env-var transport for a stdin handshake without
108///   changing the wire JSON shape (just stop reading env, start reading
109///   stdin). The wire format (base64'd CertChain JSON) is stable.
110///
111/// **Caveat:** env vars on Unix are visible to other processes running as
112/// the same uid (via `/proc/$PID/environ`). For 35c this is acceptable
113/// because the plugin and host run with the same authority anyway — the
114/// adversary model is a confused plugin, not a same-uid attacker reading
115/// `/proc`. Phase 1d hardens this by moving the handshake to stdin.
116///
117/// [`AgentKey`]: https://docs.rs/agent-mesh-protocol/latest/agent_mesh_protocol/agent_key/struct.AgentKey.html
118/// [`AgentKey::delegate`]: https://docs.rs/agent-mesh-protocol/latest/agent_mesh_protocol/agent_key/struct.AgentKey.html#method.delegate
119/// [`CertChain`]: https://docs.rs/agent-mesh-protocol/latest/agent_mesh_protocol/agent_key/struct.CertChain.html
120pub const AGENT_KEY_ENV: &str = "NEWT_AGENT_KEY";
121
122/// Read the agent-key envelope from the [`AGENT_KEY_ENV`] env var, if set.
123///
124/// Plugin-side helper. Returns `None` if the variable is not set or is empty
125/// — back-compat with hosts and plugins built before phase 1c.
126///
127/// This helper deliberately does **no** decoding or verification: the value
128/// is an opaque base64 string here. Plugins that link `newt-mesh` (or
129/// roll their own agent-mesh import) consume that string with
130/// `newt_mesh::plugin_envelope::caveats_from_envelope`, which decodes,
131/// signature-checks the chain, and extracts the attenuated [`Caveats`].
132///
133/// Keeping verification out of `plugins-protocol` is intentional: this
134/// crate is the *workspace* crate, and the workspace forbids depending on
135/// `agent-mesh-protocol` (see the workspace `exclude` list and
136/// `docs/decisions/mesh_integration.md`). Plugins that don't need
137/// cryptographic verification can still call this helper and treat the
138/// returned string as opaque.
139#[must_use]
140pub fn read_agent_key_envelope_from_env() -> Option<String> {
141    match std::env::var(AGENT_KEY_ENV) {
142        Ok(s) if !s.is_empty() => Some(s),
143        _ => None,
144    }
145}
146
147/// Emission shapes a coder plugin can produce, surfaced in
148/// `TaskReply.emission_shape` when the newt-coder plugin processed the
149/// request.
150///
151/// Downstream consumers (drake-foreman scorecard, audit logs, the
152/// pilot dashboard) compare against these constants so the wire-level
153/// strings can't drift between producer and consumer.
154///
155/// The taxonomy is documented in
156/// `~/workspaces/knowledge/board/drake/2026-05-29_newt-coder-failure-mode-taxonomy.md`.
157pub mod emission_shape {
158    /// One or more `FILE: <path>\n<contents>\nEND-FILE` blocks — the
159    /// S5 whole-file-emit strategy's preferred shape.
160    pub const WHOLE_FILES: &str = "whole_files";
161
162    /// A unified diff (fenced or unfenced). Legacy path; useful when a
163    /// model ignores the whole-file directive but lands a valid hunk.
164    pub const UNIFIED_DIFF: &str = "unified_diff";
165
166    /// No structured emission detected; the model emitted prose only
167    /// (failure mode T0a in the taxonomy).
168    pub const PROSE: &str = "prose";
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn emission_shape_constants_are_stable_strings() {
177        // These constants are part of the wire protocol. Changing them
178        // breaks every downstream consumer; pin them with an explicit
179        // test so a careless rename fails CI loudly.
180        assert_eq!(emission_shape::WHOLE_FILES, "whole_files");
181        assert_eq!(emission_shape::UNIFIED_DIFF, "unified_diff");
182        assert_eq!(emission_shape::PROSE, "prose");
183    }
184
185    #[test]
186    fn agent_key_env_name_is_stable() {
187        // Wire-protocol contract: host and plugin agree on this name.
188        // A rename without coordinated update breaks every phase-1c
189        // plugin in the wild.
190        assert_eq!(AGENT_KEY_ENV, "NEWT_AGENT_KEY");
191    }
192
193    // The env-reader helper is racy when tested in parallel (other
194    // tests in this binary may set/unset the same variable). Guard the
195    // two cases with a mutex so they don't trample each other.
196    use std::sync::Mutex;
197    static ENV_LOCK: Mutex<()> = Mutex::new(());
198
199    #[test]
200    fn read_agent_key_envelope_returns_none_when_unset() {
201        let _g = ENV_LOCK.lock().unwrap();
202        // SAFETY: std::env::remove_var is unsafe in 2024 edition.
203        // We're in 2021 edition where it's safe; the lock above
204        // serializes access to the env var across this test binary.
205        std::env::remove_var(AGENT_KEY_ENV);
206        assert_eq!(read_agent_key_envelope_from_env(), None);
207    }
208
209    #[test]
210    fn read_agent_key_envelope_returns_value_when_set() {
211        let _g = ENV_LOCK.lock().unwrap();
212        std::env::set_var(AGENT_KEY_ENV, "abc123==");
213        assert_eq!(
214            read_agent_key_envelope_from_env(),
215            Some("abc123==".to_string())
216        );
217        std::env::remove_var(AGENT_KEY_ENV);
218    }
219
220    #[test]
221    fn read_agent_key_envelope_treats_empty_as_none() {
222        // An empty env var is semantically "not set" — no provider
223        // plugin will get useful work out of a zero-byte envelope, and
224        // treating it as `Some("")` would force every consumer to
225        // re-check for emptiness.
226        let _g = ENV_LOCK.lock().unwrap();
227        std::env::set_var(AGENT_KEY_ENV, "");
228        assert_eq!(read_agent_key_envelope_from_env(), None);
229        std::env::remove_var(AGENT_KEY_ENV);
230    }
231
232    #[tokio::test]
233    async fn plugin_server_round_trips_complete() {
234        struct EchoHandler;
235
236        #[async_trait::async_trait]
237        impl crate::PluginHandler for EchoHandler {
238            async fn initialize(
239                &self,
240                _req: InitializeRequest,
241            ) -> crate::Result<InitializeResponse> {
242                Ok(InitializeResponse {
243                    plugin_name: "echo".to_string(),
244                    plugin_version: "0.0.0-test".to_string(),
245                    supported_models: vec!["gpt-test".to_string()],
246                })
247            }
248
249            async fn list_models(&self) -> crate::Result<crate::ListModelsResponse> {
250                Ok(crate::ListModelsResponse {
251                    models: vec!["gpt-test".to_string()],
252                })
253            }
254
255            async fn complete(&self, req: CompleteRequest) -> crate::Result<CompleteResponse> {
256                Ok(CompleteResponse {
257                    content: format!("{}:{}", req.model, req.messages[0].content),
258                    model_id: req.model,
259                    usage: Some(Usage {
260                        input_tokens: 3,
261                        output_tokens: 5,
262                    }),
263                })
264            }
265        }
266
267        let input = concat!(
268            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocol_version":0,"client_name":"test","client_version":"0"}}"#,
269            "\n",
270            r#"{"jsonrpc":"2.0","id":2,"method":"complete","params":{"model":"gpt-test","messages":[{"role":"user","content":"hi"}],"max_tokens":16}}"#,
271            "\n"
272        );
273        let mut output = Vec::new();
274
275        crate::PluginServer::new(EchoHandler)
276            .run(input.as_bytes(), &mut output)
277            .await
278            .unwrap();
279
280        let lines: Vec<serde_json::Value> = String::from_utf8(output)
281            .unwrap()
282            .lines()
283            .map(|line| serde_json::from_str(line).unwrap())
284            .collect();
285        assert_eq!(lines.len(), 2);
286        assert_eq!(lines[0]["result"]["plugin_name"], "echo");
287        assert_eq!(lines[1]["result"]["content"], "gpt-test:hi");
288        assert_eq!(lines[1]["result"]["usage"]["input_tokens"], 3);
289    }
290}