objectiveai_sdk/cli/command/command_executor/sse.rs
1use std::pin::Pin;
2
3use eventsource_stream::Eventsource;
4use futures::{Stream, StreamExt};
5
6use crate::cli::command::{AgentArguments, CommandExecutor, CommandRequest, CommandResponse};
7
8/// Execute commands against a remote cli daemon over plain HTTP —
9/// one POST per command to the daemon's `/execute` route, with the
10/// result streamed back as Server-Sent Events.
11///
12/// The daemon runs each request IN-PROCESS (no child binary) with the
13/// [`AgentArguments`] headers applied as a per-request config
14/// override (`mcp_session_id` has no header; 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 `http://` address.
19///
20/// Wire contract (one request per `execute`):
21/// - client POSTs the `cli::command::Request` serde JSON as the raw
22/// body — nothing wraps it — with the auth signature in the
23/// `X-OBJECTIVEAI-SIGNATURE` header and the [`AgentArguments`]
24/// identity in the `X-OBJECTIVEAI-AGENT-INSTANCE-HIERARCHY` /
25/// `-AGENT-ID` / `-AGENT-FULL-ID` / `-AGENT-REMOTE` /
26/// `-RESPONSE-ID` / `-RESPONSE-IDS` request headers — the same
27/// names the api stamps on outbound calls, one header per `Some`
28/// field (`mcp_session_id` has no header). A missing header
29/// DELETES that config field for the run — the daemon never
30/// inherits its own resident value;
31/// - the daemon replies `text/event-stream`, one SSE `data:` event per
32/// stream item — exactly the cli's stdout JSONL line shapes (a `T`
33/// JSON, or a [`crate::cli::Error`] `{"type":"error",...}` line) —
34/// then ends the response body;
35/// - the response body ending IS the end-of-stream marker (the HTTP
36/// equivalent of the old WebSocket `Close`); dropping the stream
37/// aborts the request, which cancels the in-process run on the daemon.
38///
39/// Auth is the `X-OBJECTIVEAI-SIGNATURE` header (the same header the
40/// daemon's SSE watcher routes use): when the daemon has a
41/// `DAEMON_SECRET`, the header must carry the pre-derived
42/// `sha256=<hex(SHA256(secret))>` — set it verbatim via
43/// [`Self::signature`] — or the daemon answers `401 Unauthorized`.
44pub struct SseCommandExecutor {
45 /// Full URL of the daemon's execute route, e.g.
46 /// `http://127.0.0.1:49152/execute`.
47 url: String,
48 /// Optional auth signature, sent as the `X-OBJECTIVEAI-SIGNATURE`
49 /// header on every request.
50 signature: Option<String>,
51}
52
53impl SseCommandExecutor {
54 pub fn new(url: impl Into<String>) -> Self {
55 Self {
56 url: url.into(),
57 signature: None,
58 }
59 }
60
61 /// Attach the daemon auth signature (the pre-derived
62 /// `sha256=<hex(SHA256(DAEMON_SECRET))>`), sent as the
63 /// `X-OBJECTIVEAI-SIGNATURE` header on every request. Without it the
64 /// daemon must be running without a secret.
65 pub fn signature(mut self, signature: impl Into<String>) -> Self {
66 self.signature = Some(signature.into());
67 self
68 }
69}
70
71/// The first client→daemon frame on the `/laboratory` host-channel
72/// WebSocket (the daemon's ONE remaining WebSocket) — the auth preamble,
73/// always sent, even to a secretless daemon (`{"signature": null}`). A
74/// daemon holding a `DAEMON_SECRET` verifies the signature and closes
75/// the connection on a missing/invalid one; a secretless daemon consumes
76/// the envelope and ignores the value. (The HTTP routes — `/execute`,
77/// `/listen`, and the SSE watchers — authenticate by the
78/// `X-OBJECTIVEAI-SIGNATURE` header instead.)
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
80#[schemars(rename = "cli.command.command_executor.AuthEnvelope")]
81pub struct AuthEnvelope {
82 /// The pre-derived `sha256=<hex(SHA256(DAEMON_SECRET))>`, or
83 /// `null` when the client has none.
84 pub signature: Option<String>,
85}
86
87
88#[derive(Debug, thiserror::Error)]
89pub enum Error {
90 /// Building/sending the request, or connecting, failed.
91 #[error("connect daemon execute: {0}")]
92 Connect(reqwest::Error),
93 /// The daemon answered a non-success status (e.g. `401` on a
94 /// missing/invalid signature).
95 #[error("daemon execute http {0}: {1}")]
96 Http(reqwest::StatusCode, String),
97 /// The SSE response body failed mid-stream.
98 #[error("daemon execute sse stream: {0}")]
99 Sse(String),
100 /// Serializing the request or decoding a response event failed.
101 #[error("decode daemon execute message: {0}")]
102 Json(serde_json::Error),
103 /// Structured error emitted by the daemon on the stream.
104 #[error("{0}")]
105 Cli(crate::cli::Error),
106 /// `execute_one` was called but the stream produced no items.
107 #[error("daemon execute stream produced no items")]
108 Empty,
109}
110
111/// A transport error's FULL cause chain — `Display` alone hides the
112/// interesting part (reqwest's "error decoding response body" says
113/// nothing without the hyper cause underneath, e.g. "connection reset"
114/// vs "connection closed before message completed").
115fn error_chain(e: &dyn std::error::Error) -> String {
116 let mut message = e.to_string();
117 let mut source = e.source();
118 while let Some(cause) = source {
119 message.push_str(": ");
120 message.push_str(&cause.to_string());
121 source = cause.source();
122 }
123 message
124}
125
126/// Per-event untagged decode. `Err` is listed first so serde tries it
127/// before `Ok` — `cli::Error`'s `type:"error"` constant short-circuits
128/// every non-error wire shape, then `Ok(T)` is the fallthrough.
129#[derive(serde::Deserialize)]
130#[serde(untagged)]
131enum Line<T> {
132 Err(crate::cli::Error),
133 Ok(T),
134}
135
136impl<T> From<Line<T>> for Result<T, Error> {
137 fn from(line: Line<T>) -> Self {
138 match line {
139 Line::Err(e) => Err(Error::Cli(e)),
140 Line::Ok(t) => Ok(t),
141 }
142 }
143}
144
145impl CommandExecutor for SseCommandExecutor {
146 type Error = Error;
147 type Stream<T>
148 = Pin<Box<dyn Stream<Item = Result<T, Error>> + Send>>
149 where
150 T: Send + 'static;
151
152 async fn execute<R, T>(
153 &self,
154 request: R,
155 agent_arguments: Option<&AgentArguments>,
156 ) -> Result<Self::Stream<T>, Error>
157 where
158 R: CommandRequest + Send + serde::Serialize,
159 T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
160 {
161 let mut req = reqwest::Client::new()
162 .post(&self.url)
163 .header("Accept", "text/event-stream")
164 .json(&request);
165 if let Some(signature) = &self.signature {
166 req = req.header("X-OBJECTIVEAI-SIGNATURE", signature);
167 }
168 if let Some(args) = agent_arguments {
169 if let Some(v) = &args.agent_instance_hierarchy {
170 req = req.header("X-OBJECTIVEAI-AGENT-INSTANCE-HIERARCHY", v);
171 }
172 if let Some(v) = &args.agent_id {
173 req = req.header("X-OBJECTIVEAI-AGENT-ID", v);
174 }
175 if let Some(v) = &args.agent_full_id {
176 req = req.header("X-OBJECTIVEAI-AGENT-FULL-ID", v);
177 }
178 if let Some(v) = &args.agent_remote {
179 req = req.header("X-OBJECTIVEAI-AGENT-REMOTE", v);
180 }
181 if let Some(v) = &args.response_id {
182 req = req.header("X-OBJECTIVEAI-RESPONSE-ID", v);
183 }
184 if let Some(v) = &args.response_ids {
185 req = req.header("X-OBJECTIVEAI-RESPONSE-IDS", v);
186 }
187 }
188 let response = req.send().await.map_err(Error::Connect)?;
189 if !response.status().is_success() {
190 let status = response.status();
191 let body = response.text().await.unwrap_or_default();
192 return Err(Error::Http(status, body.trim().to_string()));
193 }
194
195 // The SSE body owns the connection; dropping the returned stream
196 // drops the body, which aborts the request and cancels the
197 // daemon-side run. `eventsource-stream` parses without
198 // reconnecting (the command stream is finite — body end = done).
199 let events = response.bytes_stream().eventsource();
200 let stream = events.map(|event| match event {
201 Ok(event) => match serde_json::from_str::<Line<T>>(&event.data) {
202 Ok(line) => line.into(),
203 Err(e) => Err(Error::Json(e)),
204 },
205 // `EventStreamError::Transport` does NOT expose its inner
206 // reqwest error via `source()` — unwrap it by hand so the
207 // hyper cause chain (reset vs premature close vs framing)
208 // actually reaches the message.
209 Err(eventsource_stream::EventStreamError::Transport(e)) => {
210 Err(Error::Sse(format!("transport: {}", error_chain(&e))))
211 }
212 Err(e) => Err(Error::Sse(error_chain(&e))),
213 });
214
215 Ok(Box::pin(stream))
216 }
217
218 async fn execute_one<R, T>(
219 &self,
220 request: R,
221 agent_arguments: Option<&AgentArguments>,
222 ) -> Result<T, Error>
223 where
224 R: CommandRequest + Send + serde::Serialize,
225 T: CommandResponse + serde::Serialize + serde::de::DeserializeOwned + Send + 'static,
226 {
227 let mut stream = self.execute::<R, T>(request, agent_arguments).await?;
228 stream.next().await.ok_or(Error::Empty)?
229 }
230}