Skip to main content

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}