Skip to main content

basis_acp/
stdio.rs

1//! Serving ACP on stdio, with a signpost where the trap used to be.
2//!
3//! `basis serve --acp` speaks JSON-RPC on stdin. A bare `basis` invocation is
4//! usage output, so from this process's seat an editor connection can only be
5//! confused with a shell pipe when the explicit server command is chosen.
6//! Prompt-from-stdin stays explicit at `basis spawn -` (ADR-0017).
7//!
8//! What is left is the one case that is certainly a mistake: a first line that
9//! is not a message at all. Answering it costs a peek and turns an unexplained
10//! silence into a sentence naming the fix.
11//!
12//! The peek is why this builds its own transport instead of using the SDK's
13//! [`Stdio`](agent_client_protocol::Stdio): the line has to be read to be
14//! judged, and then handed back so the server sees the stream whole.
15
16use std::io;
17
18use agent_client_protocol::{Lines, schema::v1::Error};
19use futures::{AsyncBufReadExt, AsyncWriteExt, Sink, Stream, StreamExt};
20use serde_json::Value;
21use thiserror::Error as ThisError;
22
23use crate::server::{ServeConfig, serve};
24
25/// Why serving stdio ended.
26#[derive(Debug, ThisError)]
27pub enum StdioError {
28    /// The first line was not a JSON-RPC message, so nothing was served.
29    ///
30    /// Its own variant rather than a message, because the caller is the one
31    /// who knows what to suggest instead — the binary names `basis spawn -`, an
32    /// embedder would name itself.
33    #[error("the first line on stdin was not a JSON-RPC message")]
34    NotAClient,
35
36    #[error(transparent)]
37    Protocol(#[from] Error),
38
39    #[error("failed to read stdin: {0}")]
40    Stdin(#[from] io::Error),
41}
42
43/// Serves ACP on stdin/stdout until the client disconnects.
44///
45/// This is the transport behind the explicit `basis serve --acp` command. Bare
46/// `basis` prints usage; selecting a long-lived protocol server is deliberate
47/// (ADR-0017).
48///
49/// Nothing is written before the first line is read, and the first line is
50/// handed to the server unchanged, so a client that speaks the protocol cannot
51/// tell this from a transport that never looked.
52pub async fn serve_stdio(config: ServeConfig) -> Result<(), StdioError> {
53    let stdin = blocking::Unblock::new(io::stdin());
54    let mut lines = Box::pin(futures::io::BufReader::new(stdin).lines());
55
56    let (said, opening) = opening_lines(&mut lines).await?;
57    if opening == Opening::NotAClient {
58        return Err(StdioError::NotAClient);
59    }
60
61    let incoming = futures::stream::iter(said.into_iter().map(Ok)).chain(lines);
62    serve(config, Lines::new(stdout_lines(), incoming)).await?;
63
64    Ok(())
65}
66
67/// What the peer's opening proves about the peer.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69enum Opening {
70    /// It sent something shaped like a message. Whether it is a *valid* one is
71    /// the JSON-RPC layer's question, and it answers with a proper error.
72    Client,
73    /// It sent something that could not be a message under any reading.
74    NotAClient,
75    /// It sent nothing at all — an editor that spawned basis and closed the
76    /// pipe, or a script piping an empty file. There is nothing to complain
77    /// about, and serving an empty stream ends immediately anyway.
78    Silence,
79}
80
81/// Everything the peer said up to and including its first real line, and what
82/// that line proves.
83///
84/// The lines are returned rather than consumed: judging the opening must not
85/// cost the server the message it was judging.
86async fn opening_lines<S>(lines: &mut S) -> io::Result<(Vec<String>, Opening)>
87where
88    S: Stream<Item = io::Result<String>> + Unpin,
89{
90    let mut said = Vec::new();
91
92    while let Some(line) = lines.next().await {
93        let line = line?;
94        // A blank line is neither a message nor prose, so it proves nothing.
95        // It is passed through rather than dropped, so the JSON-RPC layer
96        // keeps answering it the way it always has.
97        let opening = (!line.trim().is_empty()).then(|| opening_for(&line));
98        said.push(line);
99
100        if let Some(opening) = opening {
101            return Ok((said, opening));
102        }
103    }
104
105    Ok((said, Opening::Silence))
106}
107
108/// Judges one line.
109///
110/// A JSON-RPC message is a JSON object, or an array of them in a batch — so
111/// that, and nothing narrower, is the test. Requiring a `"jsonrpc"` member or
112/// a known method would put basis in the business of policing a protocol the SDK
113/// already validates, and the cost of being wrong here is refusing a real
114/// client. Prose fails this on the first character.
115fn opening_for(line: &str) -> Opening {
116    match serde_json::from_str::<Value>(line) {
117        Ok(Value::Object(_) | Value::Array(_)) => Opening::Client,
118        _ => Opening::NotAClient,
119    }
120}
121
122/// Stdout as a sink of lines, flushed one at a time.
123///
124/// Per line rather than per batch for the reason the bridge flushes per frame:
125/// it is what makes an answer arrive as the model produces it.
126fn stdout_lines() -> impl Sink<String, Error = io::Error> + Send + 'static {
127    futures::sink::unfold(
128        blocking::Unblock::new(io::stdout()),
129        async move |mut out: blocking::Unblock<io::Stdout>, line: String| {
130            let mut bytes = line.into_bytes();
131            bytes.push(b'\n');
132            out.write_all(&bytes).await?;
133            out.flush().await?;
134            Ok::<_, io::Error>(out)
135        },
136    )
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    async fn opening_of(lines: &[&str]) -> (Vec<String>, Opening) {
144        let mut stream = futures::stream::iter(
145            lines
146                .iter()
147                .map(|line| Ok((*line).to_string()))
148                .collect::<Vec<_>>(),
149        );
150
151        opening_lines(&mut stream)
152            .await
153            .expect("an in-memory stream does not fail")
154    }
155
156    #[test]
157    fn a_json_rpc_message_is_an_object_or_a_batch() {
158        assert_eq!(
159            opening_for(r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#),
160            Opening::Client
161        );
162        assert_eq!(
163            opening_for(r#"[{"jsonrpc":"2.0","id":1}]"#),
164            Opening::Client
165        );
166    }
167
168    #[test]
169    fn a_client_is_not_refused_over_a_field_lan_does_not_own() {
170        // Version negotiation, method names, and the shape of `params` all
171        // belong to the SDK, which answers a malformed one with a proper
172        // JSON-RPC error. Judging them here would turn a protocol quibble into
173        // a refusal to start.
174        assert_eq!(opening_for(r#"{"method":"initialize"}"#), Opening::Client);
175        assert_eq!(opening_for("{}"), Opening::Client);
176    }
177
178    #[test]
179    fn prose_is_not_a_client() {
180        for line in [
181            "fix the failing test",
182            "run the tests and summarize",
183            "why is CI red?",
184        ] {
185            assert_eq!(opening_for(line), Opening::NotAClient, "{line}");
186        }
187    }
188
189    #[tokio::test]
190    async fn the_first_line_is_handed_to_the_server_unread() {
191        // The point of the peek is that it costs the client nothing: the
192        // message it opened with must still reach the dispatch loop.
193        let message = r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#;
194
195        let (said, opening) = opening_of(&[message, r#"{"jsonrpc":"2.0","id":2}"#]).await;
196
197        assert_eq!(opening, Opening::Client);
198        assert_eq!(said, vec![message.to_string()]);
199    }
200
201    #[tokio::test]
202    async fn blank_lines_before_a_message_are_kept_and_skipped_over() {
203        let message = r#"{"jsonrpc":"2.0","id":1}"#;
204
205        let (said, opening) = opening_of(&["", "   ", message]).await;
206
207        assert_eq!(opening, Opening::Client);
208        assert_eq!(
209            said,
210            vec!["".to_string(), "   ".to_string(), message.to_string()],
211            "the layer that answered blank lines before must still see them"
212        );
213    }
214
215    #[tokio::test]
216    async fn a_peer_that_says_nothing_is_not_accused_of_anything() {
217        let (said, opening) = opening_of(&[]).await;
218
219        assert_eq!(opening, Opening::Silence);
220        assert!(said.is_empty());
221    }
222
223    #[tokio::test]
224    async fn prose_on_the_first_line_stops_before_the_server_starts() {
225        let (said, opening) = opening_of(&["fix the failing test", "and push"]).await;
226
227        assert_eq!(opening, Opening::NotAClient);
228        assert_eq!(said, vec!["fix the failing test".to_string()]);
229    }
230}