Skip to main content

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}