objectiveai_sdk/cli/command/command_executor/websocket.rs
1use std::pin::Pin;
2
3use futures::{SinkExt, Stream, StreamExt};
4use tokio_tungstenite::tungstenite;
5use tokio_tungstenite::tungstenite::client::IntoClientRequest;
6
7use crate::cli::command::{AgentArguments, CommandExecutor, CommandRequest, CommandResponse};
8
9/// Execute commands against a remote cli daemon over WebSocket —
10/// connection per command on the daemon's `/execute` route.
11///
12/// The daemon runs each request IN-PROCESS (no child binary) with the
13/// envelope's [`AgentArguments`] applied as a per-request config
14/// override (`mcp_session_id` is ignored server-side; the daemon's
15/// filesystem layout and secret are never overridable). This is what
16/// lets a consumer — notably the viewer — run on a different machine
17/// than the cli: the only local requirement is network reach to the
18/// daemon's published `ws://` address.
19///
20/// Wire contract (one connection per `execute`):
21/// - client sends TWO text messages: first the [`AuthEnvelope`]
22/// (always — `{"signature": null}` against a secretless daemon),
23/// then the [`ExecuteEnvelope`], then only reads;
24/// - the daemon sends one text message per stream item — exactly the
25/// cli's stdout JSONL line shapes (a `T` JSON, or a
26/// [`crate::cli::Error`] `{"type":"error",...}` line) — then closes;
27/// - dropping the stream drops the connection, which cancels the
28/// in-process run on the daemon.
29///
30/// Auth is the first-message preamble shared by every daemon WebSocket
31/// route (headers are never used — browser WebSocket clients can't set
32/// them): when the daemon has a `DAEMON_SECRET`, the [`AuthEnvelope`]
33/// must carry the pre-derived `sha256=<hex(SHA256(secret))>` — set it
34/// verbatim via [`Self::signature`] — or the daemon closes the
35/// connection without a word.
36pub struct WebSocketExecutor {
37 /// Full connect URL of the daemon's execute route, e.g.
38 /// `ws://127.0.0.1:49152/execute`.
39 url: String,
40 /// Optional auth signature, sent in the [`AuthEnvelope`] preamble
41 /// on every connection.
42 signature: Option<String>,
43}
44
45impl WebSocketExecutor {
46 pub fn new(url: impl Into<String>) -> Self {
47 Self {
48 url: url.into(),
49 signature: None,
50 }
51 }
52
53 /// Attach the daemon auth signature (the pre-derived
54 /// `sha256=<hex(SHA256(DAEMON_SECRET))>`), sent verbatim in the
55 /// [`AuthEnvelope`] preamble on every connection. Without it the
56 /// daemon must be running without a secret.
57 pub fn signature(mut self, signature: impl Into<String>) -> Self {
58 self.signature = Some(signature.into());
59 self
60 }
61}
62
63/// The FIRST client→daemon message on EVERY daemon WebSocket
64/// connection (`/execute` and `/listen` alike) — always sent, even to
65/// a secretless daemon (`{"signature": null}`). Headers are never
66/// used for auth: browser WebSocket clients can't set them. A daemon
67/// holding a `DAEMON_SECRET` verifies the signature and closes the
68/// connection on a missing/invalid one; a secretless daemon consumes
69/// the envelope and ignores the value.
70#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
71#[schemars(rename = "cli.command.command_executor.AuthEnvelope")]
72pub struct AuthEnvelope {
73 /// The pre-derived `sha256=<hex(SHA256(DAEMON_SECRET))>`, or
74 /// `null` when the client has none.
75 pub signature: Option<String>,
76}
77
78/// The one client→daemon message on an `/execute` connection: the
79/// typed request (as its serde JSON) plus the optional per-request
80/// identity override. The daemon deserializes this exact shape.
81#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
82#[schemars(rename = "cli.command.command_executor.ExecuteEnvelope")]
83pub struct ExecuteEnvelope {
84 /// Per-request identity override, applied onto the daemon's own
85 /// config for this run only (same semantics as the other
86 /// executors' per-call override; `mcp_session_id` is ignored).
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 #[schemars(extend("omitempty" = true))]
89 pub agent_arguments: Option<AgentArguments>,
90 /// The serde JSON of a `cli::command::Request`.
91 pub request: serde_json::Value,
92}
93
94#[derive(Debug, thiserror::Error)]
95pub enum Error {
96 /// The URL failed to build into a client upgrade request, or the
97 /// connection/upgrade itself failed.
98 #[error("connect daemon execute websocket: {0}")]
99 Connect(tungstenite::Error),
100 /// The established connection failed mid-stream.
101 #[error("daemon execute websocket: {0}")]
102 Ws(tungstenite::Error),
103 /// Serializing the request or decoding a response message failed.
104 #[error("decode daemon execute message: {0}")]
105 Json(serde_json::Error),
106 /// Structured error emitted by the daemon on the stream.
107 #[error("{0}")]
108 Cli(crate::cli::Error),
109 /// `execute_one` was called but the stream produced no items.
110 #[error("daemon execute stream produced no items")]
111 Empty,
112}
113
114/// Per-message untagged decode. `Err` is listed first so serde tries it
115/// before `Ok` — `cli::Error`'s `type:"error"` constant short-circuits
116/// every non-error wire shape, then `Ok(T)` is the fallthrough.
117#[derive(serde::Deserialize)]
118#[serde(untagged)]
119enum Line<T> {
120 Err(crate::cli::Error),
121 Ok(T),
122}
123
124impl<T> From<Line<T>> for Result<T, Error> {
125 fn from(line: Line<T>) -> Self {
126 match line {
127 Line::Err(e) => Err(Error::Cli(e)),
128 Line::Ok(t) => Ok(t),
129 }
130 }
131}
132
133impl CommandExecutor for WebSocketExecutor {
134 type Error = Error;
135 type Stream<T>
136 = Pin<Box<dyn Stream<Item = Result<T, Error>> + Send>>
137 where
138 T: Send + 'static;
139
140 async fn execute<R, T>(
141 &self,
142 request: R,
143 agent_arguments: Option<&AgentArguments>,
144 ) -> Result<Self::Stream<T>, Error>
145 where
146 R: CommandRequest + Send + serde::Serialize,
147 T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
148 {
149 let auth = AuthEnvelope {
150 signature: self.signature.clone(),
151 };
152 let auth = serde_json::to_string(&auth).map_err(Error::Json)?;
153 let envelope = ExecuteEnvelope {
154 agent_arguments: agent_arguments.cloned(),
155 request: serde_json::to_value(&request).map_err(Error::Json)?,
156 };
157 let envelope = serde_json::to_string(&envelope).map_err(Error::Json)?;
158
159 let upgrade = self
160 .url
161 .as_str()
162 .into_client_request()
163 .map_err(Error::Connect)?;
164 let (mut ws, _response) = tokio_tungstenite::connect_async(upgrade)
165 .await
166 .map_err(Error::Connect)?;
167 // The auth preamble, then the one request envelope.
168 ws.send(tungstenite::Message::Text(auth))
169 .await
170 .map_err(Error::Ws)?;
171 ws.send(tungstenite::Message::Text(envelope))
172 .await
173 .map_err(Error::Ws)?;
174
175 // Carry the socket in the unfold state; dropping the stream
176 // drops the connection, which cancels the daemon-side run.
177 let stream = futures::stream::unfold(Some(ws), |ws| async move {
178 let mut ws = ws?;
179 loop {
180 match ws.next().await {
181 Some(Ok(tungstenite::Message::Text(text))) => {
182 let item = match serde_json::from_str::<Line<T>>(&text) {
183 Ok(line) => line.into(),
184 Err(e) => Err(Error::Json(e)),
185 };
186 return Some((item, Some(ws)));
187 }
188 // Control / non-text frames: not part of the item
189 // stream (tungstenite answers pings internally).
190 Some(Ok(tungstenite::Message::Close(_))) | None => return None,
191 Some(Ok(_)) => continue,
192 // Yield the transport error, then end the stream —
193 // `None` state short-circuits the next poll.
194 Some(Err(e)) => return Some((Err(Error::Ws(e)), None)),
195 }
196 }
197 });
198
199 Ok(Box::pin(stream))
200 }
201
202 async fn execute_one<R, T>(
203 &self,
204 request: R,
205 agent_arguments: Option<&AgentArguments>,
206 ) -> Result<T, Error>
207 where
208 R: CommandRequest + Send + serde::Serialize,
209 T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
210 {
211 let mut stream = self.execute::<R, T>(request, agent_arguments).await?;
212 stream.next().await.ok_or(Error::Empty)?
213 }
214}