protocol_droid/application/server/serve.rs
1//! The front door a consumer walks through.
2//!
3//! Everything here delegates. The point is that a consumer does not have to
4//! know that a [`Dispatcher`] exists, that a [`PeerSession`] needs a
5//! [`FeatureProfile`](crate::domain::feature::FeatureProfile) resolved before the loop starts, or that
6//! [`ServeLoop::new`] takes seven arguments — none of which is a decision a
7//! consumer of this crate should have to make.
8//!
9//! The one thing deliberately *not* hidden is the session's ceiling. A profile
10//! is resolved from a revision, and until a peer says which revision it wants,
11//! something has to say what the session permits before the first frame. The
12//! [`StdioBuilder::version`] ceiling answers that, and it defaults to the
13//! newest revision this build can complete a handshake on rather than to a
14//! constant a consumer has to pick and remember to revisit.
15
16use std::sync::Arc;
17
18use crate::adapters::primary::stdio::{Diagnostics, ServeLoop, StdioConfig};
19use crate::application::jsonrpc::ProfileBatching;
20use crate::application::server::Server;
21use crate::application::server::context::{DynExchange, ExchangeError, PeerSession};
22use crate::application::server::dispatch::Dispatcher;
23use crate::application::session::SinkError;
24use crate::application::session::frame_sink::FrameSink;
25use crate::domain::feature::resolve;
26use crate::domain::ir::JsonObject;
27use crate::domain::registry::AuthContext;
28use crate::domain::session::SessionId;
29use crate::domain::{
30 CapabilitySet, Negotiation, ProtocolVersion, SupportedVersions, TransportKind,
31};
32
33/// Builds a stdio server and serves it on this process's stdin and stdout.
34///
35/// The one-call form is [`Server::serve_stdio`]; this type exists for the case
36/// where the caller needs to change one of the two things that are genuinely
37/// decisions — the revision ceiling, or the framing limits.
38///
39/// [`Server::serve_stdio`]: crate::application::server::Server::serve_stdio
40///
41/// # Examples
42///
43/// ```no_run
44/// # use protocol_droid::application::server::{Server, serve::StdioBuilder};
45/// # use protocol_droid::domain::ProtocolVersion;
46/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
47/// let mut loop_ = StdioBuilder::new(Server::builder("greeter").build())
48/// .version(ProtocolVersion::V2025_06_18)
49/// .build();
50/// let report = loop_.serve().await?;
51/// eprintln!("{report:?}");
52/// # Ok(())
53/// # }
54/// ```
55#[derive(Debug)]
56pub struct StdioBuilder {
57 server: Server,
58 version: ProtocolVersion,
59 config: StdioConfig,
60 auth: AuthContext,
61}
62
63impl StdioBuilder {
64 /// A builder serving `server` over stdio.
65 #[must_use]
66 pub fn new(server: Server) -> Self {
67 Self {
68 server,
69 version: default_ceiling(),
70 config: StdioConfig::default(),
71 // Anonymous, and that is a decision rather than a gap: this
72 // transport has no credential to check, so a principal here is
73 // something the *deployment* asserts, not something the peer
74 // proves. `auth` is where it says so.
75 auth: AuthContext::anonymous(),
76 }
77 }
78
79 /// The principal this server's peer acts as.
80 ///
81 /// A stdio peer is the child process on the other end of a pipe, and there
82 /// is nothing on a pipe to authenticate — no header, no token, no TLS peer
83 /// identity. So unlike the HTTP transports, where the principal arrives
84 /// with the request, here the deployment *declares* it and the crate holds
85 /// it for the life of the session.
86 ///
87 /// Needed because the alternative is unreachable rather than merely
88 /// inconvenient: a scope-gated tool is invisible to an anonymous session
89 /// (`RegistryServer::call` and the listing paths both read
90 /// `ctx.peer().auth()`), so a stdio server that registers a `Write`-scoped
91 /// tool could never serve it, and no setting existed that could change that.
92 /// This method is that setting.
93 ///
94 /// Defaults to [`AuthContext::anonymous`], so a stdio server that says
95 /// nothing serves exactly the primitives nobody had to authorise. Note the
96 /// one lifetime difference from HTTP: this principal covers the whole
97 /// connection, because there is no per-request credential here to differ
98 /// from.
99 ///
100 /// # Examples
101 ///
102 /// ```
103 /// use protocol_droid::application::server::{Server, serve::StdioBuilder};
104 /// use protocol_droid::domain::registry::{AuthContext, Scope, ScopeSet};
105 ///
106 /// let server = Server::builder("greeter").build();
107 /// let trusted = StdioBuilder::new(server)
108 /// .auth(AuthContext::new("local", ScopeSet::empty().with(Scope::Write)));
109 /// # let _ = trusted;
110 /// ```
111 ///
112 #[must_use]
113 pub fn auth(mut self, auth: AuthContext) -> Self {
114 self.auth = auth;
115 self
116 }
117
118 /// The newest revision this session is permitted to use before a peer
119 /// says otherwise.
120 ///
121 /// This is a **ceiling, not an answer**. The revision a peer is told in
122 /// its handshake is settled from its own `initialize`; this only decides
123 /// what the session gates on in the moment before that, and what it falls
124 /// back to for a peer that never sent one.
125 ///
126 /// Defaults to the newest revision with a handshake defined. The obvious
127 /// alternative — the newest revision the crate knows — is `2026-07-28`,
128 /// which SEP-2575 removed `initialize` from, so a server that chose it
129 /// would gate its whole life on a revision no peer can complete.
130 #[must_use]
131 pub const fn version(mut self, version: ProtocolVersion) -> Self {
132 self.version = version;
133 self
134 }
135
136 /// The framing limits: frame ceiling and diagnostics writer.
137 #[must_use]
138 pub const fn config(mut self, config: StdioConfig) -> Self {
139 self.config = config;
140 self
141 }
142
143 /// Assembles the loop this builder describes.
144 ///
145 /// Infallible: everything it needs is either in `self` or derived from it
146 /// by [`resolve`], which is a pure function over a [`Negotiation`] this
147 /// builder constructs itself.
148 #[must_use]
149 pub fn build(
150 self,
151 ) -> ServeLoop<tokio::io::Stdin, tokio::io::Stdout, tokio::io::Stderr, ProfileBatching> {
152 #[cfg(feature = "random-ids")]
153 let builder = {
154 let mut builder = self;
155 builder
156 .server
157 .ensure_request_state_key(crate::adapters::secondary::id_source::random_state_key);
158 builder
159 };
160 #[cfg(not(feature = "random-ids"))]
161 let builder = self;
162 let profile = resolve(Negotiation {
163 version: builder.version,
164 client_caps: CapabilitySet::empty(),
165 server_caps: CapabilitySet::all_for(builder.version),
166 transport: TransportKind::Stdio,
167 });
168 // `without_auth` is deliberately *not* called here, unlike the HTTP
169 // transport's persisted session. On stdio the principal is a property of
170 // the connection rather than of one request: there is no per-request
171 // credential to differ from it, and the handshake narrows a copy of this
172 // peer — `with_profile` keeps `auth` — so the declared principal
173 // survives the profile swap that replaces the session.
174 let peer = PeerSession::new(
175 builder.version,
176 profile,
177 SessionId::new(1),
178 TransportKind::Stdio,
179 Arc::new(NoCallbacks),
180 Arc::new(NullSink),
181 )
182 .with_auth(builder.auth);
183 ServeLoop::new(
184 tokio::io::stdin(),
185 tokio::io::stdout(),
186 Diagnostics::stderr(),
187 builder.config,
188 Arc::new(Dispatcher::new(Arc::new(builder.server))),
189 peer,
190 ProfileBatching,
191 )
192 }
193}
194
195/// The newest revision this build can complete a handshake on.
196///
197/// Falls back to [`ProtocolVersion::latest_known`] if that set is somehow
198/// empty, leaving the ceiling a question rather than a wrong answer.
199fn default_ceiling() -> ProtocolVersion {
200 SupportedVersions::all().map_or_else(
201 |_| ProtocolVersion::latest_known(),
202 |supported| supported.latest_handshake(),
203 )
204}
205
206/// The placeholder sink a peer is built with before the loop owns it.
207///
208/// [`ServeLoop::new`] replaces it with the loop's own stdout sink, so nothing
209/// a handler publishes ever reaches this. It refuses rather than discards, so a
210/// peer that somehow escaped that replacement fails loudly instead of silently
211/// dropping a progress update.
212#[derive(Debug, Clone, Copy)]
213struct NullSink;
214
215impl FrameSink for NullSink {
216 /// Always refuses: nothing in this path is ever written.
217 ///
218 /// # Errors
219 ///
220 /// Always a [`SinkError`], naming the absent sink.
221 fn send(&self, _frame: &str) -> impl std::future::Future<Output = Result<(), SinkError>> {
222 std::future::ready(Err(SinkError))
223 }
224}
225
226/// An exchange that services nothing.
227#[derive(Debug, Clone, Copy)]
228struct NoCallbacks;
229
230impl DynExchange for NoCallbacks {
231 /// Always refuses: the stdio loop wires no callbacks.
232 ///
233 /// # Errors
234 ///
235 /// Always an [`ExchangeError`], naming the absent exchange.
236 fn exchange_boxed<'a>(
237 &'a self,
238 _method: &'a str,
239 _params: &'a JsonObject,
240 ) -> std::pin::Pin<
241 Box<dyn std::future::Future<Output = Result<JsonObject, ExchangeError>> + Send + 'a>,
242 > {
243 Box::pin(std::future::ready(Err(ExchangeError::new(
244 "this server wires no callback exchange",
245 ))))
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 /// The default ceiling is a revision a handshake can complete on.
254 ///
255 /// The obvious wrong answer is `latest_known()`, which is `2026-07-28` —
256 /// the revision SEP-2575 removed `initialize` from. A server that gated its
257 /// whole life on it would answer every peer it could not finish talking to.
258 #[test]
259 fn the_default_ceiling_has_a_handshake() {
260 let ceiling = default_ceiling();
261 assert!(
262 crate::domain::handshake_defined_in(ceiling),
263 "{ceiling} has no initialize on it"
264 );
265 }
266
267 /// The ceiling is *not* the newest revision the crate knows.
268 ///
269 /// Those two differ by exactly the revisions that dropped a handshake, and
270 /// `2026-07-28` is currently one of them. Were they ever to converge, a
271 /// revision would have re-added `initialize` and this assertion would be
272 /// the signal to look at the ceiling's meaning again.
273 #[test]
274 fn the_ceiling_is_not_merely_the_newest_known() {
275 assert_ne!(default_ceiling(), ProtocolVersion::latest_known());
276 }
277
278 /// The loop this builder describes gates on the ceiling, not on a
279 /// revision someone picked.
280 #[test]
281 fn the_built_session_gates_on_the_ceiling() {
282 let server = Server::builder("ceiling-test").build();
283 let loop_ = StdioBuilder::new(server)
284 .version(ProtocolVersion::V2024_11_05)
285 .build();
286 assert!(
287 !loop_
288 .peer()
289 .profile()
290 .allows(crate::domain::Feature::Completions),
291 "a 2024-11-05 session must not carry a 2025-03-26 feature"
292 );
293 }
294
295 /// The principal the deployment declared is the one the peer answers as.
296 ///
297 /// The claim is reachability. Every visibility gate reads
298 /// `ctx.peer().auth()`, so before this setter existed a stdio server had no
299 /// way to be anything but anonymous — and a `Write`-scoped tool it
300 /// registered was therefore invisible to it forever, with no error to
301 /// explain why. Asserting the scopes specifically rather than the principal
302 /// name is deliberate: `holds` is what the gate calls, so an implementation
303 /// that set a principal but dropped the scopes would pass a name assertion.
304 #[test]
305 fn the_declared_principal_reaches_the_peer() {
306 use crate::domain::registry::{Scope, ScopeSet};
307
308 let declared = ScopeSet::empty().with(Scope::Read).with(Scope::Write);
309 let anonymous = StdioBuilder::new(Server::builder("default").build()).build();
310 assert_eq!(
311 anonymous.peer().auth(),
312 &AuthContext::anonymous(),
313 "the default is anonymous: a stdio server that says nothing is served as nobody in particular",
314 );
315 assert!(
316 !anonymous.peer().auth().holds(declared),
317 "and so it sees nothing that requires a scope"
318 );
319
320 let named = StdioBuilder::new(Server::builder("trusted").build())
321 .auth(AuthContext::new("local", declared))
322 .build();
323 assert!(
324 named.peer().auth().holds(declared),
325 "a declared principal holds what it was declared with"
326 );
327 }
328}