Skip to main content

smooth_operator_server/
local.rs

1//! The **local deployment flavor** — an embeddable, zero-config server.
2//!
3//! This is the third deployment target alongside `deploy/k8s` (Kubernetes +
4//! Postgres + NATS) and `deploy/sst` (AWS serverless): a self-contained server
5//! with **everything in-memory** and **auth off**, meant to run on a developer
6//! laptop or to be **embedded in-process** by a host (e.g. the smooth daemon).
7//!
8//! It needs no external services — no Postgres, no Redis, no NATS, no AWS — and
9//! no secrets. It is exactly the default-flavor server the binary already boots
10//! when no env is set ([`ServerConfig::from_env`](crate::config::ServerConfig::from_env)
11//! defaults to in-memory storage, in-memory backplane, loopback bind, and admin
12//! disabled), factored here so a host can boot it from code in a few lines
13//! rather than by shelling out and setting env vars.
14//!
15//! ## In-process embed
16//!
17//! ```no_run
18//! # async fn demo() -> anyhow::Result<()> {
19//! // Boot a fully in-memory server on the default loopback addr, in the
20//! // background, and get a handle to its real bound address + a shutdown switch.
21//! let server = smooth_operator_server::local::LocalServer::builder()
22//!     .seed_kb(true) // optional: load the demo knowledge docs
23//!     .spawn()
24//!     .await?;
25//!
26//! println!("local operator on ws://{}/ws", server.addr());
27//! // ... use it (connect a client, run turns) ...
28//!
29//! server.shutdown().await; // graceful stop + join
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! ## Run to completion
35//!
36//! ```no_run
37//! # async fn demo() -> anyhow::Result<()> {
38//! // Or just run it in the foreground until killed (what the binary's no-env
39//! // path effectively does):
40//! smooth_operator_server::local::serve_local("127.0.0.1:8787").await?;
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! ## What "local flavor" pins
46//!
47//! Independent of ambient env, the local flavor always uses:
48//!
49//! - **storage** = in-memory ([`InMemoryStorageAdapter`](smooth_operator_adapter_memory::InMemoryStorageAdapter)),
50//! - **backplane** = in-memory (single-process; no Redis/NATS),
51//! - **auth** = none ([`NoAuthVerifier`](smooth_operator::auth::NoAuthVerifier)) — `/admin` is open, `/ws` boots,
52//! - **widget auth** = permissive ([`PermissiveWidgetAuth`](smooth_operator::widget_auth::PermissiveWidgetAuth)),
53//! - **bind** = a caller-supplied addr (default `127.0.0.1:8787`).
54//!
55//! The LLM gateway is still read from `SMOOAI_GATEWAY_URL` / `SMOOAI_GATEWAY_KEY`
56//! (so a key in the environment enables live turns); with no key, `send_message`
57//! returns a clean protocol `error` exactly as the keyless test path does.
58
59use std::net::SocketAddr;
60use std::sync::Arc;
61
62use anyhow::{Context, Result};
63use tokio::net::TcpListener;
64use tokio::task::JoinHandle;
65
66use smooth_operator::auth::{AuthVerifier, NoAuthVerifier};
67use smooth_operator::tool_provider::ToolProvider;
68
69use crate::config::{ServerConfig, StorageBackend};
70use crate::server::{build_state, router};
71use crate::state::AppState;
72
73/// The default address the local flavor binds when the caller gives none —
74/// loopback on the canonical WebSocket port, matching
75/// [`config::DEFAULT_BIND`](crate::config::DEFAULT_BIND) +
76/// [`config::DEFAULT_PORT`](crate::config::DEFAULT_PORT).
77pub const DEFAULT_LOCAL_ADDR: &str = "127.0.0.1:8787";
78
79/// Builder for the [local deployment flavor](self): a fully in-memory,
80/// auth-off, single-process server, embeddable in-process.
81///
82/// All knobs are optional — `LocalServer::builder().spawn().await` boots the
83/// default flavor (in-memory everything, loopback `:8787`, no auth, no seed).
84/// Construct with [`LocalServer::builder`].
85#[derive(Clone)]
86pub struct LocalServerBuilder {
87    addr: SocketAddr,
88    seed_kb: bool,
89    config: Option<ServerConfig>,
90    auth: Option<Arc<dyn AuthVerifier>>,
91    tool_provider: Option<Arc<dyn ToolProvider>>,
92    serve_widget: bool,
93    widget_token: Option<String>,
94}
95
96impl std::fmt::Debug for LocalServerBuilder {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("LocalServerBuilder")
99            .field("addr", &self.addr)
100            .field("seed_kb", &self.seed_kb)
101            .field("config", &self.config)
102            // Never print the verifier's secrets — just its mode label.
103            .field("auth", &self.auth.as_ref().map(|a| a.mode()))
104            .field("tool_provider", &self.tool_provider.is_some())
105            .field("serve_widget", &self.serve_widget)
106            .finish()
107    }
108}
109
110impl Default for LocalServerBuilder {
111    fn default() -> Self {
112        Self {
113            addr: DEFAULT_LOCAL_ADDR
114                .parse()
115                .expect("DEFAULT_LOCAL_ADDR is a valid SocketAddr"),
116            seed_kb: false,
117            config: None,
118            auth: None,
119            tool_provider: None,
120            serve_widget: false,
121            widget_token: None,
122        }
123    }
124}
125
126impl LocalServerBuilder {
127    /// Bind on the given address instead of the default `127.0.0.1:8787`.
128    ///
129    /// Use port `0` for an ephemeral port (read the real one back from
130    /// [`LocalServer::addr`] after [`spawn`](Self::spawn)).
131    #[must_use]
132    pub fn addr(mut self, addr: SocketAddr) -> Self {
133        self.addr = addr;
134        self
135    }
136
137    /// Seed the knowledge base with the demo docs on boot (default `false`).
138    /// Handy for an embedded demo / smoke test with grounded answers.
139    #[must_use]
140    pub fn seed_kb(mut self, seed: bool) -> Self {
141        self.seed_kb = seed;
142        self
143    }
144
145    /// Install a custom [`AuthVerifier`] for the local flavor.
146    ///
147    /// Without this, the local flavor runs auth-off ([`NoAuthVerifier`]) — fine
148    /// for pure loopback. Pass a
149    /// [`LocalTokenVerifier`](smooth_operator::auth::LocalTokenVerifier) to gate
150    /// stray local processes with a shared secret (recommended when binding
151    /// beyond loopback, e.g. over a tailnet).
152    #[must_use]
153    pub fn auth(mut self, auth: Arc<dyn AuthVerifier>) -> Self {
154        self.auth = Some(auth);
155        self
156    }
157
158    /// Install a host [`ToolProvider`] so the runner merges its per-turn tools
159    /// into every turn alongside the built-ins (the `#68` injection seam). The
160    /// local flavor uses this to add an OS-sandboxed shell + egress-routed tools
161    /// — the isolation the cloud flavor gets from its container/network sandbox
162    /// instead.
163    #[must_use]
164    pub fn tools(mut self, provider: Arc<dyn ToolProvider>) -> Self {
165        self.tool_provider = Some(provider);
166        self
167    }
168
169    /// Serve the official `@smooai/smooth-operator` widget from this server: a
170    /// host page at `/` and the bundle at `/chat-widget.iife.js`. `token` is
171    /// injected into the page so the widget connects to this server's
172    /// `/ws?token=…` (pair it with a matching [`auth`](Self::auth) verifier);
173    /// pass `None` for a no-auth local server.
174    #[must_use]
175    pub fn serve_widget(mut self, token: Option<String>) -> Self {
176        self.serve_widget = true;
177        self.widget_token = token;
178        self
179    }
180
181    /// Override the full [`ServerConfig`] (e.g. to point at a gateway / model).
182    ///
183    /// The local flavor still **forces** in-memory storage and the caller's bind
184    /// addr regardless of what this config says — the storage/bind fields are
185    /// overwritten — so the "no external services" guarantee always holds. Use
186    /// this to set the gateway URL / key / model / limits for live turns.
187    #[must_use]
188    pub fn config(mut self, config: ServerConfig) -> Self {
189        self.config = Some(config);
190        self
191    }
192
193    /// Build the [`AppState`] for the local flavor: in-memory storage +
194    /// in-memory backplane (the [`build_state`] defaults) with a no-op
195    /// [`NoAuthVerifier`] explicitly installed so `/admin` is reachable in-process
196    /// without configuring `AUTH_MODE`. The gateway config is honored for live
197    /// turns; with no key, `send_message` errors cleanly.
198    fn build(&self) -> AppState {
199        // Start from the caller's config (or the env-independent defaults) and
200        // pin the local-flavor invariants: in-memory storage + the caller's addr.
201        let mut config = self.config.clone().unwrap_or_else(local_config);
202        config.storage = StorageBackend::Memory;
203        config.bind = self.addr.ip().to_string();
204        config.port = self.addr.port();
205        config.seed_kb = self.seed_kb;
206
207        // `build_state` gives in-memory storage + in-memory backplane + permissive
208        // widget auth. Install the caller's verifier, or default to the no-op one
209        // so the admin API is reachable in-process without an `AUTH_MODE=none`
210        // env handshake.
211        let auth = self
212            .auth
213            .clone()
214            .unwrap_or_else(|| Arc::new(NoAuthVerifier::default()) as Arc<dyn AuthVerifier>);
215        let mut state = build_state(config).with_auth(auth);
216        if let Some(provider) = &self.tool_provider {
217            state = state.with_tools(Arc::clone(provider));
218        }
219        if self.serve_widget {
220            state = state.with_widget(self.widget_token.clone());
221        }
222        state
223    }
224
225    /// Bind and spawn the server in a background task, returning a [`LocalServer`]
226    /// handle carrying the **real** bound address (resolved even for port `0`)
227    /// and a graceful-shutdown switch.
228    ///
229    /// # Errors
230    /// Returns an error if binding the TCP listener fails (e.g. the port is in
231    /// use). Serving errors after a successful bind surface when the handle is
232    /// awaited / shut down.
233    pub async fn spawn(self) -> Result<LocalServer> {
234        let listener = TcpListener::bind(self.addr)
235            .await
236            .with_context(|| format!("binding local smooth-operator server on {}", self.addr))?;
237        let addr = listener.local_addr().context("local addr")?;
238
239        let app = router(self.build());
240        let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
241
242        let join = tokio::spawn(async move {
243            axum::serve(listener, app)
244                .with_graceful_shutdown(async move {
245                    // Resolve on an explicit shutdown signal; if the sender is
246                    // dropped (handle gone) we also stop, so we never leak a task.
247                    let _ = shutdown_rx.await;
248                })
249                .await
250                .context("serving local smooth-operator connections")
251        });
252
253        Ok(LocalServer {
254            addr,
255            shutdown_tx: Some(shutdown_tx),
256            join,
257        })
258    }
259}
260
261/// A running [local-flavor](self) server: its bound address + a graceful
262/// shutdown switch.
263///
264/// Dropping the handle without calling [`shutdown`](Self::shutdown) signals the
265/// server to stop (the shutdown channel closes) and detaches the background
266/// task. Call [`shutdown`](Self::shutdown) to stop **and** await a clean exit.
267#[must_use = "the server stops when the handle is dropped; hold it for the server's lifetime"]
268pub struct LocalServer {
269    addr: SocketAddr,
270    shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
271    join: JoinHandle<Result<()>>,
272}
273
274impl LocalServer {
275    /// Start building a local-flavor server. See [`LocalServerBuilder`].
276    pub fn builder() -> LocalServerBuilder {
277        LocalServerBuilder::default()
278    }
279
280    /// The real address the server bound on — already resolved, so this returns
281    /// the concrete ephemeral port when the builder asked for port `0`.
282    #[must_use]
283    pub fn addr(&self) -> SocketAddr {
284        self.addr
285    }
286
287    /// The `ws://<addr>/ws` URL clients connect to.
288    #[must_use]
289    pub fn ws_url(&self) -> String {
290        format!("ws://{}/ws", self.addr)
291    }
292
293    /// Signal graceful shutdown and await the server task's clean exit.
294    ///
295    /// # Errors
296    /// Returns an error if the server task panicked or its serve loop errored.
297    pub async fn shutdown(mut self) -> Result<()> {
298        // Trigger graceful shutdown, then await the serve loop.
299        if let Some(tx) = self.shutdown_tx.take() {
300            let _ = tx.send(());
301        }
302        match (&mut self.join).await {
303            Ok(result) => result,
304            Err(join_err) => Err(anyhow::anyhow!("local server task failed: {join_err}")),
305        }
306    }
307}
308
309impl Drop for LocalServer {
310    fn drop(&mut self) {
311        // Best-effort: if the handle is dropped without `shutdown`, signal the
312        // serve loop to stop so the background task doesn't outlive the handle.
313        if let Some(tx) = self.shutdown_tx.take() {
314            let _ = tx.send(());
315        }
316    }
317}
318
319/// An [`ServerConfig`] for the local flavor: the env-independent defaults
320/// (loopback bind, default gateway URL/model/limits) with **in-memory storage**
321/// pinned. The gateway URL/key are still read from the environment via
322/// [`ServerConfig::from_env`] so a key present in the host's env enables live
323/// turns; absent, `send_message` errors cleanly.
324#[must_use]
325pub fn local_config() -> ServerConfig {
326    let mut config = ServerConfig::from_env();
327    config.storage = StorageBackend::Memory;
328    config
329}
330
331/// Run a [local-flavor](self) server to completion (blocks) on `addr`.
332///
333/// Convenience for the foreground / one-command case: boots a fully in-memory,
334/// auth-off server bound to `addr` and serves until the process is killed. For
335/// an embedded server you can stop programmatically, use
336/// [`LocalServer::builder`] + [`LocalServer::shutdown`] instead.
337///
338/// # Errors
339/// Returns an error if `addr` doesn't parse, the bind fails, or serving fails.
340pub async fn serve_local(addr: &str) -> Result<()> {
341    let addr: SocketAddr = addr
342        .parse()
343        .with_context(|| format!("parsing local bind address '{addr}'"))?;
344    let server = LocalServer::builder().addr(addr).spawn().await?;
345    let local = server.addr();
346    println!("smooth-operator-server (local flavor) listening on ws://{local}/ws");
347    tracing::info!(%local, endpoint = "/ws", "smooth-operator-server (local flavor) listening");
348
349    // Take ownership of the join handle and await it to completion. We can't
350    // call the consuming `shutdown` here (we want to run forever), so await the
351    // task directly via a small accessor.
352    server.run_to_completion().await
353}
354
355impl LocalServer {
356    /// Await the server task to completion (blocks). Used by [`serve_local`] for
357    /// the run-forever foreground case. The handle is consumed; graceful
358    /// shutdown then only happens on process exit / task error.
359    async fn run_to_completion(mut self) -> Result<()> {
360        // Keep the shutdown sender alive (don't fire it) so the server runs until
361        // the task itself ends (process killed / serve error).
362        match (&mut self.join).await {
363            Ok(result) => result,
364            Err(join_err) => Err(anyhow::anyhow!("local server task failed: {join_err}")),
365        }
366        // `self` (and thus `shutdown_tx`) drops here; the loop is already done.
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[tokio::test]
375    async fn spawn_binds_ephemeral_and_reports_real_addr() {
376        // Port 0 → the OS picks a port; the handle must report the real one.
377        let server = LocalServer::builder()
378            .addr("127.0.0.1:0".parse().unwrap())
379            .spawn()
380            .await
381            .expect("spawn local server");
382        let addr = server.addr();
383        assert_ne!(addr.port(), 0, "ephemeral port must be resolved: {addr}");
384        assert!(server.ws_url().starts_with("ws://127.0.0.1:"));
385        server.shutdown().await.expect("clean shutdown");
386    }
387
388    #[tokio::test]
389    async fn build_uses_in_memory_storage_and_no_auth() {
390        let state = LocalServerBuilder::default()
391            .config(ServerConfig {
392                // Even if a caller hands a Postgres config, the local flavor pins
393                // in-memory so the no-external-services guarantee holds.
394                storage: StorageBackend::Postgres,
395                ..local_config()
396            })
397            .build();
398        assert_eq!(state.config.storage, StorageBackend::Memory);
399        // The no-op verifier is installed (admin reachable in-process).
400        assert_eq!(state.auth.mode(), "none");
401    }
402
403    #[test]
404    fn auth_seam_installs_a_custom_verifier() {
405        use smooth_operator::auth::LocalTokenVerifier;
406        let state = LocalServerBuilder::default()
407            .auth(Arc::new(LocalTokenVerifier::new("s3cret")))
408            .build();
409        assert_eq!(
410            state.auth.mode(),
411            "local-token",
412            "custom verifier overrides the default"
413        );
414    }
415
416    #[test]
417    fn tools_seam_installs_a_provider() {
418        use async_trait::async_trait;
419        use smooth_operator::tool_provider::{ToolProvider, ToolProviderContext};
420        use smooth_operator_core::Tool;
421
422        struct EmptyProvider;
423        #[async_trait]
424        impl ToolProvider for EmptyProvider {
425            async fn tools_for(&self, _ctx: &ToolProviderContext) -> Vec<Arc<dyn Tool>> {
426                Vec::new()
427            }
428        }
429        let state = LocalServerBuilder::default()
430            .tools(Arc::new(EmptyProvider))
431            .build();
432        assert!(state.tool_provider.is_some(), "host ToolProvider installed");
433    }
434
435    #[test]
436    fn serve_widget_opts_into_the_widget_routes_with_token() {
437        let state = LocalServerBuilder::default()
438            .serve_widget(Some("tok-123".into()))
439            .build();
440        assert!(state.serve_widget, "widget routes opted in");
441        assert_eq!(state.widget_token.as_deref(), Some("tok-123"));
442        // Building the router with serve_widget set mounts `/` + the bundle route.
443        let _ = crate::server::router(state);
444    }
445
446    #[test]
447    fn no_widget_by_default() {
448        let state = LocalServerBuilder::default().build();
449        assert!(
450            !state.serve_widget,
451            "widget off by default (K8s/Lambda never serve it)"
452        );
453        assert_eq!(state.widget_token, None);
454    }
455}