Skip to main content

fno_agents/
protocol.rs

1//! Unix-socket wire format (Wave 3, task 3.1).
2//!
3//! ## Framing (Claude's Discretion #6)
4//!
5//! Length-prefixed JSON: a **4-byte little-endian `u32` length** prefix, then
6//! that many bytes of UTF-8 JSON. The binary prefix is chosen over LSP-style
7//! `Content-Length:\r\n\r\n` for tightness; the trade-off (less human-readable
8//! on the wire) is acceptable because the daemon is not a debugging surface and
9//! every frame is structured JSON regardless. Frames over [`MAX_FRAME_BYTES`]
10//! are rejected before allocation, so a corrupt or hostile length prefix cannot
11//! drive an unbounded allocation.
12//!
13//! ## Two namespaces, one socket
14//!
15//! Methods are namespaced by a `<namespace>.<verb>` prefix:
16//!
17//! - `agent.*` — `fno-agents` client (spawn / ask / list / stop / rm /
18//!   reconcile / status).
19//! - `channel.*` — the Phase 5 channel server (register_channel /
20//!   unregister_channel / push_to_channel).
21//!
22//! The split is namespace-only: same socket, same Unix-permission gate,
23//! different `method` prefixes. [`Namespace::of`] classifies a method so the
24//! daemon can route without string-matching at every call site.
25//!
26//! ## Drive (Wave 4 seam)
27//!
28//! Interactive drive upgrades a connection to a WebSocket after the initial
29//! `agent.drive` request. Wave 3 lands the request/response transport only; the
30//! upgrade handshake and binary PTY frames are Wave 4. [`Namespace`] reserves no
31//! special drive variant because the upgrade is signalled by the `agent.drive`
32//! method, handled by the daemon, not by a distinct frame type.
33
34use serde::{de, ser::SerializeStruct, Deserialize, Deserializer, Serialize, Serializer};
35use serde_json::Value;
36use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
37
38/// Hard cap on a single frame's JSON body. A length prefix larger than this is
39/// rejected before any allocation (Failure Modes: "reject malformed JSON-RPC
40/// frames ... never crash the daemon"). 16 MiB comfortably covers the largest
41/// legitimate payload (a 64KB ask plus envelope) with headroom.
42pub const MAX_FRAME_BYTES: u32 = 16 * 1024 * 1024;
43
44/// Wire/transport errors.
45#[derive(Debug, thiserror::Error)]
46pub enum ProtocolError {
47    #[error("io: {0}")]
48    Io(#[from] std::io::Error),
49    #[error("frame exceeds max size: {0} > {MAX_FRAME_BYTES}")]
50    FrameTooLarge(u32),
51    #[error("malformed json frame: {0}")]
52    Json(#[from] serde_json::Error),
53    #[error("connection closed before a full frame was read")]
54    UnexpectedEof,
55}
56
57/// A request from a client to the daemon. `id` correlates the response on a
58/// multiplexed connection; `method` is `<namespace>.<verb>`; `params` is an
59/// opaque JSON object the handler interprets.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub struct Request {
62    pub id: u64,
63    pub method: String,
64    #[serde(default)]
65    pub params: Value,
66}
67
68impl Request {
69    pub fn new(id: u64, method: impl Into<String>, params: Value) -> Self {
70        Request {
71            id,
72            method: method.into(),
73            params,
74        }
75    }
76}
77
78/// The success-or-error payload of a [`Response`]. Making this a sum type means
79/// "exactly one of result / error" is unrepresentable-as-violated: there is no
80/// `{result: None, error: None}` nor `{result: Some, error: Some}` state to
81/// guard against. The flat `{id, result | error}` wire shape is preserved by
82/// `Response`'s hand-written [`Serialize`]/[`Deserialize`] below.
83#[derive(Debug, Clone, PartialEq)]
84pub enum ResponsePayload {
85    /// Success: the `result` value.
86    Ok(Value),
87    /// Failure: the structured `error`.
88    Err(RpcError),
89}
90
91/// A response to a [`Request`]. Carries exactly one of result / error via
92/// [`ResponsePayload`]. The wire shape stays flat (`{id, result}` or
93/// `{id, error}`) for cross-language parity.
94#[derive(Debug, Clone, PartialEq)]
95pub struct Response {
96    pub id: u64,
97    pub payload: ResponsePayload,
98}
99
100impl Response {
101    pub fn ok(id: u64, result: Value) -> Self {
102        Response {
103            id,
104            payload: ResponsePayload::Ok(result),
105        }
106    }
107
108    pub fn err(id: u64, code: ErrorCode, message: impl Into<String>) -> Self {
109        Response {
110            id,
111            payload: ResponsePayload::Err(RpcError {
112                code,
113                message: message.into(),
114            }),
115        }
116    }
117
118    /// True if this response carries an error.
119    pub fn is_err(&self) -> bool {
120        matches!(self.payload, ResponsePayload::Err(_))
121    }
122
123    /// The success value, or `None` if this is an error response.
124    pub fn result(&self) -> Option<&Value> {
125        match &self.payload {
126            ResponsePayload::Ok(v) => Some(v),
127            ResponsePayload::Err(_) => None,
128        }
129    }
130
131    /// The structured error, or `None` if this is a success response.
132    pub fn error(&self) -> Option<&RpcError> {
133        match &self.payload {
134            ResponsePayload::Err(e) => Some(e),
135            ResponsePayload::Ok(_) => None,
136        }
137    }
138}
139
140impl Serialize for Response {
141    /// Emit the flat `{id, result}` / `{id, error}` shape. Only the populated
142    /// arm is written, matching the pre-sum-type `skip_serializing_if` behavior.
143    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
144    where
145        S: Serializer,
146    {
147        let mut st = serializer.serialize_struct("Response", 2)?;
148        st.serialize_field("id", &self.id)?;
149        match &self.payload {
150            ResponsePayload::Ok(v) => st.serialize_field("result", v)?,
151            ResponsePayload::Err(e) => st.serialize_field("error", e)?,
152        }
153        st.end()
154    }
155}
156
157impl<'de> Deserialize<'de> for Response {
158    /// Parse the flat wire shape, enforcing the exactly-one invariant: a frame
159    /// with both or neither of result / error is a malformed response.
160    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
161    where
162        D: Deserializer<'de>,
163    {
164        // Distinguish "result field present but null" (a valid JSON-RPC success
165        // carrying a null value) from "result field absent". A plain
166        // `#[serde(default)] Option<Value>` collapses both to `None` because
167        // serde maps JSON null -> None, which would make `{"id":1,"result":null}`
168        // hit the (None, None) reject arm. `deserialize_with` only runs when the
169        // field is present, so an explicit null becomes `Some(Value::Null)` while
170        // a truly absent field still defaults to `None`.
171        fn present_value<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
172        where
173            D: Deserializer<'de>,
174        {
175            Value::deserialize(deserializer).map(Some)
176        }
177
178        #[derive(Deserialize)]
179        struct Wire {
180            id: u64,
181            #[serde(default, deserialize_with = "present_value")]
182            result: Option<Value>,
183            #[serde(default)]
184            error: Option<RpcError>,
185        }
186        let w = Wire::deserialize(deserializer)?;
187        let payload = match (w.result, w.error) {
188            (Some(_), Some(_)) => {
189                return Err(de::Error::custom(
190                    "response carries both `result` and `error`",
191                ))
192            }
193            (Some(r), None) => ResponsePayload::Ok(r),
194            (None, Some(e)) => ResponsePayload::Err(e),
195            (None, None) => {
196                return Err(de::Error::custom(
197                    "response carries neither `result` nor `error`",
198                ))
199            }
200        };
201        Ok(Response { id: w.id, payload })
202    }
203}
204
205/// Structured error in a [`Response`].
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
207pub struct RpcError {
208    pub code: ErrorCode,
209    pub message: String,
210}
211
212/// Stable machine-readable error codes. Clients map these to exit codes; the
213/// design's per-verb exit codes (13/14/15/18, ...) are applied client-side from
214/// these. Serialized snake_case for cross-language parity.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(rename_all = "snake_case")]
217pub enum ErrorCode {
218    /// Frame/JSON could not be parsed.
219    MalformedFrame,
220    /// Method has no handler / unknown namespace.
221    UnknownMethod,
222    /// Required params missing or wrong type.
223    InvalidParams,
224    /// Named agent does not exist.
225    AgentNotFound,
226    /// Agent already exists (spawn name collision).
227    AgentExists,
228    /// Operation rejected for the agent's current status.
229    InvalidStatus,
230    /// Capacity/concurrency cap hit (drive max_concurrent, watcher cap, ...).
231    Busy,
232    /// Lock acquisition timed out.
233    LockTimeout,
234    /// Spawn failed pre-launch (binary missing, cwd inaccessible, ...).
235    SpawnFailed,
236    /// channel.* against an unknown cc_session_id / channel id.
237    ChannelUnknown,
238    /// Catch-all internal fault; daemon stays up, surfaces this.
239    Internal,
240}
241
242/// Method namespace, derived from the `<namespace>.<verb>` method prefix.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub enum Namespace {
245    /// `agent.*` — the `fno-agents` client surface.
246    Agent,
247    /// `channel.*` — the Phase 5 channel server surface.
248    Channel,
249    /// Anything else (rejected with [`ErrorCode::UnknownMethod`]).
250    Unknown,
251}
252
253impl Namespace {
254    /// Classify a method string by its prefix before the first `.`.
255    pub fn of(method: &str) -> Namespace {
256        match method.split_once('.') {
257            Some(("agent", _)) => Namespace::Agent,
258            Some(("channel", _)) => Namespace::Channel,
259            _ => Namespace::Unknown,
260        }
261    }
262
263    /// The verb portion after the namespace prefix (`agent.spawn` -> `spawn`).
264    pub fn verb(method: &str) -> Option<&str> {
265        method.split_once('.').map(|(_, v)| v)
266    }
267}
268
269// ---------------------------------------------------------------------------
270// Async frame codec.
271// ---------------------------------------------------------------------------
272
273/// Read one length-prefixed frame's raw JSON bytes. Returns
274/// [`ProtocolError::UnexpectedEof`] if the connection closes between frames
275/// (clean disconnect) or mid-frame (truncated). The caller treats a clean EOF
276/// as "client hung up", not a daemon fault.
277pub async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>, ProtocolError> {
278    let mut len_buf = [0u8; 4];
279    match reader.read_exact(&mut len_buf).await {
280        Ok(_) => {}
281        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
282            return Err(ProtocolError::UnexpectedEof)
283        }
284        Err(e) => return Err(e.into()),
285    }
286    let len = u32::from_le_bytes(len_buf);
287    if len > MAX_FRAME_BYTES {
288        return Err(ProtocolError::FrameTooLarge(len));
289    }
290    let mut body = vec![0u8; len as usize];
291    reader
292        .read_exact(&mut body)
293        .await
294        .map_err(|e| match e.kind() {
295            std::io::ErrorKind::UnexpectedEof => ProtocolError::UnexpectedEof,
296            _ => ProtocolError::Io(e),
297        })?;
298    Ok(body)
299}
300
301/// Write `body` as one length-prefixed frame and flush. Rejects a body larger
302/// than [`MAX_FRAME_BYTES`] before writing so a buggy handler cannot emit an
303/// un-readable frame.
304pub async fn write_frame<W: AsyncWrite + Unpin>(
305    writer: &mut W,
306    body: &[u8],
307) -> Result<(), ProtocolError> {
308    let len: u32 = body
309        .len()
310        .try_into()
311        .map_err(|_| ProtocolError::FrameTooLarge(u32::MAX))?;
312    if len > MAX_FRAME_BYTES {
313        return Err(ProtocolError::FrameTooLarge(len));
314    }
315    writer.write_all(&len.to_le_bytes()).await?;
316    writer.write_all(body).await?;
317    writer.flush().await?;
318    Ok(())
319}
320
321/// Read and deserialize one [`Request`].
322pub async fn read_request<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Request, ProtocolError> {
323    let body = read_frame(reader).await?;
324    Ok(serde_json::from_slice(&body)?)
325}
326
327/// Serialize and write one [`Request`].
328pub async fn write_request<W: AsyncWrite + Unpin>(
329    writer: &mut W,
330    req: &Request,
331) -> Result<(), ProtocolError> {
332    let body = serde_json::to_vec(req)?;
333    write_frame(writer, &body).await
334}
335
336/// Read and deserialize one [`Response`].
337pub async fn read_response<R: AsyncRead + Unpin>(
338    reader: &mut R,
339) -> Result<Response, ProtocolError> {
340    let body = read_frame(reader).await?;
341    Ok(serde_json::from_slice(&body)?)
342}
343
344/// Serialize and write one [`Response`].
345pub async fn write_response<W: AsyncWrite + Unpin>(
346    writer: &mut W,
347    resp: &Response,
348) -> Result<(), ProtocolError> {
349    let body = serde_json::to_vec(resp)?;
350    write_frame(writer, &body).await
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use serde_json::json;
357
358    #[test]
359    fn namespace_classification() {
360        assert_eq!(Namespace::of("agent.spawn"), Namespace::Agent);
361        assert_eq!(
362            Namespace::of("channel.register_channel"),
363            Namespace::Channel
364        );
365        assert_eq!(Namespace::of("bogus.method"), Namespace::Unknown);
366        assert_eq!(Namespace::of("noseparator"), Namespace::Unknown);
367        assert_eq!(Namespace::verb("agent.spawn"), Some("spawn"));
368        assert_eq!(Namespace::verb("nope"), None);
369    }
370
371    #[tokio::test]
372    async fn request_roundtrips_over_duplex() {
373        let (mut a, mut b) = tokio::io::duplex(4096);
374        let req = Request::new(7, "agent.spawn", json!({"name": "worker-A"}));
375        write_request(&mut a, &req).await.unwrap();
376        let got = read_request(&mut b).await.unwrap();
377        assert_eq!(got, req);
378    }
379
380    #[tokio::test]
381    async fn response_ok_and_err_roundtrip() {
382        let (mut a, mut b) = tokio::io::duplex(4096);
383        let ok = Response::ok(7, json!({"status": "live"}));
384        write_response(&mut a, &ok).await.unwrap();
385        let got = read_response(&mut b).await.unwrap();
386        assert_eq!(got, ok);
387        assert!(!got.is_err());
388
389        let err = Response::err(8, ErrorCode::AgentNotFound, "no such agent");
390        write_response(&mut a, &err).await.unwrap();
391        let got = read_response(&mut b).await.unwrap();
392        assert!(got.is_err());
393        assert_eq!(got.error().unwrap().code, ErrorCode::AgentNotFound);
394    }
395
396    #[tokio::test]
397    async fn frame_too_large_is_rejected_not_allocated() {
398        // Hand-write a length prefix exceeding the cap; the reader must reject
399        // it without trying to allocate gigabytes.
400        let (mut a, mut b) = tokio::io::duplex(64);
401        let writer = tokio::spawn(async move {
402            let bogus_len = (MAX_FRAME_BYTES + 1).to_le_bytes();
403            a.write_all(&bogus_len).await.unwrap();
404            a.flush().await.unwrap();
405            // keep `a` alive so the reader sees the prefix
406            a
407        });
408        let err = read_frame(&mut b).await.unwrap_err();
409        assert!(matches!(err, ProtocolError::FrameTooLarge(_)));
410        let _a = writer.await.unwrap();
411    }
412
413    #[tokio::test]
414    async fn clean_eof_is_distinguished_from_io_error() {
415        let (a, mut b) = tokio::io::duplex(64);
416        drop(a); // client hangs up with no bytes
417        let err = read_frame(&mut b).await.unwrap_err();
418        assert!(matches!(err, ProtocolError::UnexpectedEof));
419    }
420
421    #[test]
422    fn response_wire_shape_is_flat() {
423        // The sum-type refactor must not change the bytes on the wire: a success
424        // serializes to {id, result}, an error to {id, error}, nothing else.
425        let ok = Response::ok(7, json!({"status": "live"}));
426        assert_eq!(
427            serde_json::to_value(&ok).unwrap(),
428            json!({"id": 7, "result": {"status": "live"}})
429        );
430        let err = Response::err(8, ErrorCode::AgentNotFound, "no such agent");
431        assert_eq!(
432            serde_json::to_value(&err).unwrap(),
433            json!({"id": 8, "error": {"code": "agent_not_found", "message": "no such agent"}})
434        );
435    }
436
437    #[test]
438    fn response_rejects_both_or_neither_payload() {
439        // The exactly-one invariant is now enforced at deserialize time, so a
440        // malformed frame from a buggy/hostile peer is a parse error, not a
441        // half-populated Response.
442        let both = json!({"id": 1, "result": {}, "error": {"code": "internal", "message": "x"}});
443        assert!(serde_json::from_value::<Response>(both).is_err());
444        // Truly absent result AND error (not merely null) is the rejected case.
445        let neither = json!({"id": 1});
446        assert!(serde_json::from_value::<Response>(neither).is_err());
447    }
448
449    #[test]
450    fn response_accepts_explicit_null_result() {
451        // A present-but-null result is a valid JSON-RPC success and must NOT be
452        // confused with an absent field (Gemini HIGH on PR #341). It parses to
453        // Ok(Null) and roundtrips.
454        let parsed: Response =
455            serde_json::from_value(json!({"id": 7, "result": null})).expect("null result parses");
456        assert!(!parsed.is_err());
457        assert_eq!(parsed.result(), Some(&Value::Null));
458        assert_eq!(
459            serde_json::to_value(&parsed).unwrap(),
460            json!({"id": 7, "result": null})
461        );
462    }
463
464    #[tokio::test]
465    async fn malformed_json_body_surfaces_json_error() {
466        let (mut a, mut b) = tokio::io::duplex(4096);
467        write_frame(&mut a, b"{not json").await.unwrap();
468        let err = read_request(&mut b).await.unwrap_err();
469        assert!(matches!(err, ProtocolError::Json(_)));
470    }
471}