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
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct InitializeRequest {
14 pub protocol_version: u32,
15 pub client_name: String,
16 pub client_version: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct InitializeResponse {
21 pub plugin_name: String,
22 pub plugin_version: String,
23 pub supported_models: Vec<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CompleteRequest {
28 pub model: String,
29 pub messages: Vec<Message>,
30 pub max_tokens: Option<u32>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CompleteResponse {
35 pub content: String,
36 pub model_id: String,
37 pub usage: Option<Usage>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct Message {
42 pub role: String,
43 pub content: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Usage {
48 pub input_tokens: u32,
49 pub output_tokens: u32,
50}
51
52pub const PROTOCOL_VERSION: u32 = 0;
53
54/// Environment variable a Newt host sets when spawning a provider plugin to
55/// hand the plugin a base64-encoded JSON [`CertChain`] (an attenuated
56/// [`AgentKey`]) for this dispatch.
57///
58/// Phase 1c transport (issue #35). The host minted this child cert by
59/// calling [`AgentKey::delegate`] on its own parent key — so the chain is
60/// signed end-to-end, attenuation is structurally enforced, and the plugin
61/// can verify the chain locally without a separate trust anchor (the chain
62/// roots at the user's `UserPublic`, which is embedded in the leaf cert).
63///
64/// Plugins that **don't** read this env var run with whatever ambient
65/// authority they had before phase 1c — that's a deliberate
66/// back-compat behavior for older plugins. Plugins built against phase 1c
67/// or later **SHOULD** read this and use it as the source of truth for
68/// every tool dispatch they make.
69///
70/// Why env var (and not a `CompleteRequest` field)?
71/// - Simpler — no protocol-version bump required, older plugins ignore it.
72/// - Per-process — the cert is attached to the plugin's lifetime, not to
73/// any individual call.
74/// - Phase 1d can swap env-var transport for a stdin handshake without
75/// changing the wire JSON shape (just stop reading env, start reading
76/// stdin). The wire format (base64'd CertChain JSON) is stable.
77///
78/// **Caveat:** env vars on Unix are visible to other processes running as
79/// the same uid (via `/proc/$PID/environ`). For 35c this is acceptable
80/// because the plugin and host run with the same authority anyway — the
81/// adversary model is a confused plugin, not a same-uid attacker reading
82/// `/proc`. Phase 1d hardens this by moving the handshake to stdin.
83///
84/// [`AgentKey`]: https://docs.rs/agent-mesh-protocol/latest/agent_mesh_protocol/agent_key/struct.AgentKey.html
85/// [`AgentKey::delegate`]: https://docs.rs/agent-mesh-protocol/latest/agent_mesh_protocol/agent_key/struct.AgentKey.html#method.delegate
86/// [`CertChain`]: https://docs.rs/agent-mesh-protocol/latest/agent_mesh_protocol/agent_key/struct.CertChain.html
87pub const AGENT_KEY_ENV: &str = "NEWT_AGENT_KEY";
88
89/// Read the agent-key envelope from the [`AGENT_KEY_ENV`] env var, if set.
90///
91/// Plugin-side helper. Returns `None` if the variable is not set or is empty
92/// — back-compat with hosts and plugins built before phase 1c.
93///
94/// This helper deliberately does **no** decoding or verification: the value
95/// is an opaque base64 string here. Plugins that link `newt-mesh` (or
96/// roll their own agent-mesh import) consume that string with
97/// `newt_mesh::plugin_envelope::caveats_from_envelope`, which decodes,
98/// signature-checks the chain, and extracts the attenuated [`Caveats`].
99///
100/// Keeping verification out of `plugins-protocol` is intentional: this
101/// crate is the *workspace* crate, and the workspace forbids depending on
102/// `agent-mesh-protocol` (see the workspace `exclude` list and
103/// `docs/decisions/mesh_integration.md`). Plugins that don't need
104/// cryptographic verification can still call this helper and treat the
105/// returned string as opaque.
106#[must_use]
107pub fn read_agent_key_envelope_from_env() -> Option<String> {
108 match std::env::var(AGENT_KEY_ENV) {
109 Ok(s) if !s.is_empty() => Some(s),
110 _ => None,
111 }
112}
113
114/// Emission shapes a coder plugin can produce, surfaced in
115/// `TaskReply.emission_shape` when the newt-coder plugin processed the
116/// request.
117///
118/// Downstream consumers (drake-foreman scorecard, audit logs, the
119/// pilot dashboard) compare against these constants so the wire-level
120/// strings can't drift between producer and consumer.
121///
122/// The taxonomy is documented in
123/// `~/workspaces/knowledge/board/drake/2026-05-29_newt-coder-failure-mode-taxonomy.md`.
124pub mod emission_shape {
125 /// One or more `FILE: <path>\n<contents>\nEND-FILE` blocks — the
126 /// S5 whole-file-emit strategy's preferred shape.
127 pub const WHOLE_FILES: &str = "whole_files";
128
129 /// A unified diff (fenced or unfenced). Legacy path; useful when a
130 /// model ignores the whole-file directive but lands a valid hunk.
131 pub const UNIFIED_DIFF: &str = "unified_diff";
132
133 /// No structured emission detected; the model emitted prose only
134 /// (failure mode T0a in the taxonomy).
135 pub const PROSE: &str = "prose";
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn emission_shape_constants_are_stable_strings() {
144 // These constants are part of the wire protocol. Changing them
145 // breaks every downstream consumer; pin them with an explicit
146 // test so a careless rename fails CI loudly.
147 assert_eq!(emission_shape::WHOLE_FILES, "whole_files");
148 assert_eq!(emission_shape::UNIFIED_DIFF, "unified_diff");
149 assert_eq!(emission_shape::PROSE, "prose");
150 }
151
152 #[test]
153 fn agent_key_env_name_is_stable() {
154 // Wire-protocol contract: host and plugin agree on this name.
155 // A rename without coordinated update breaks every phase-1c
156 // plugin in the wild.
157 assert_eq!(AGENT_KEY_ENV, "NEWT_AGENT_KEY");
158 }
159
160 // The env-reader helper is racy when tested in parallel (other
161 // tests in this binary may set/unset the same variable). Guard the
162 // two cases with a mutex so they don't trample each other.
163 use std::sync::Mutex;
164 static ENV_LOCK: Mutex<()> = Mutex::new(());
165
166 #[test]
167 fn read_agent_key_envelope_returns_none_when_unset() {
168 let _g = ENV_LOCK.lock().unwrap();
169 // SAFETY: std::env::remove_var is unsafe in 2024 edition.
170 // We're in 2021 edition where it's safe; the lock above
171 // serializes access to the env var across this test binary.
172 std::env::remove_var(AGENT_KEY_ENV);
173 assert_eq!(read_agent_key_envelope_from_env(), None);
174 }
175
176 #[test]
177 fn read_agent_key_envelope_returns_value_when_set() {
178 let _g = ENV_LOCK.lock().unwrap();
179 std::env::set_var(AGENT_KEY_ENV, "abc123==");
180 assert_eq!(
181 read_agent_key_envelope_from_env(),
182 Some("abc123==".to_string())
183 );
184 std::env::remove_var(AGENT_KEY_ENV);
185 }
186
187 #[test]
188 fn read_agent_key_envelope_treats_empty_as_none() {
189 // An empty env var is semantically "not set" — no provider
190 // plugin will get useful work out of a zero-byte envelope, and
191 // treating it as `Some("")` would force every consumer to
192 // re-check for emptiness.
193 let _g = ENV_LOCK.lock().unwrap();
194 std::env::set_var(AGENT_KEY_ENV, "");
195 assert_eq!(read_agent_key_envelope_from_env(), None);
196 std::env::remove_var(AGENT_KEY_ENV);
197 }
198}