Skip to main content

oxibrain_cli/cmd/
serve.rs

1//! `oxibrain serve` — start the MCP server (DESIGN §12.4).
2//!
3//! Default transport is stdio (what Claude Desktop expects). `--socket <path>`
4//! listens on a Unix-domain socket for the daemon topology (§4.3): several apps
5//! share one brain through the single-writer store actor (P8).
6//! `--socket <path> --require-token` gates each connection behind a token
7//! handshake (§11.2). `--http <addr>` serves loopback HTTP.
8//!
9//! `--daemon` writes a PID file to `<dir>/.oxibrain.pid` so external supervisors
10//! (launchd) can manage the process. When `--socket` is omitted **and**
11//! `--daemon` is set, the daemon binds the Oxi Foundation default socket
12//! (`$OXIBRAIN_SOCKET` if set, otherwise `$HOME/.oxi/brain/oxibrain.sock`).
13//! Without `--daemon`, an omitted `--socket` keeps stdio as the transport.
14//! The binary never forks — backgrounding is the supervisor's job (§15). All
15//! socket/HTTP listeners shut down gracefully on SIGINT/SIGTERM.
16
17use anyhow::Context;
18use oxibrain::{Brain, BrainConfig};
19#[cfg(unix)]
20use oxibrain_client::discovery::default_socket_path;
21use oxibrain_ports::BrainError;
22#[cfg(unix)]
23use std::os::unix::fs::{FileTypeExt, PermissionsExt};
24use std::path::Path;
25
26pub async fn run(
27    dir: &Path,
28    socket: Option<std::path::PathBuf>,
29    http: Option<String>,
30    require_token: bool,
31    daemon: bool,
32    ui_dir: Option<std::path::PathBuf>,
33) -> anyhow::Result<()> {
34    let brain = match Brain::open(BrainConfig::at(dir)).await {
35        Ok(b) => b,
36        Err(BrainError::Locked { holder }) => {
37            // §4.3: "fails fast with a clear error if a daemon holds the lock,
38            // and prints the command to attach instead."
39            anyhow::bail!(
40                "store is locked — another oxibrain process owns it ({holder}).\n\
41                 If a daemon is already running, connect to it (e.g. via its socket) \
42                 instead of starting a second one.\n\
43                 To start a new daemon, ensure no other oxibrain process is running."
44            );
45        }
46        Err(e) => return Err(e.into()),
47    };
48
49    // Write the PID file in daemon mode. RAII: removed on drop when `run`
50    // returns (after graceful shutdown or error). The advisory lock inside the
51    // Brain is the real single-writer guard (P8); the PID file is informational.
52    let _pid = if daemon {
53        let pid = oxibrain_mcp::PidFile::acquire(dir)
54            .map_err(|e| anyhow::anyhow!("write PID file: {e}"))?;
55        tracing::info!(
56            "daemon PID {} → {}",
57            std::process::id(),
58            pid.path().display()
59        );
60        Some(pid)
61    } else {
62        None
63    };
64
65    if let Some(addr_str) = http {
66        let addr: std::net::SocketAddr = addr_str
67            .parse()
68            .map_err(|e| anyhow::anyhow!("invalid --http address '{addr_str}': {e}"))?;
69        return oxibrain_mcp::serve_http(brain, addr, ui_dir).await;
70    }
71
72    // Resolve the socket path: explicit --socket wins; otherwise, in daemon
73    // mode, fall back to the Oxi Foundation default ($OXIBRAIN_SOCKET or
74    // $HOME/.oxi/brain/oxibrain.sock). Stdio stays the fallback for
75    // non-daemon invocations with no --socket.
76    let socket_path = match socket {
77        Some(p) => Some(p),
78        #[cfg(unix)]
79        None if daemon => Some(resolve_default_socket()?),
80        None => None,
81    };
82
83    match socket_path {
84        #[cfg(unix)]
85        Some(path) => {
86            prepare_socket_path(&path)?;
87            if require_token {
88                oxibrain_mcp::serve_socket_auth(brain, &path).await
89            } else {
90                tracing::warn!(
91                    "serving on socket without --require-token: relying on filesystem \
92                     permissions alone (DESIGN §11.2). Pass --require-token for token auth."
93                );
94                oxibrain_mcp::serve_socket(brain, &path).await
95            }
96        }
97        #[cfg(not(unix))]
98        Some(_) => anyhow::bail!("--socket is only supported on Unix"),
99        None => {
100            if require_token {
101                anyhow::bail!("--require-token requires --socket");
102            }
103            oxibrain_mcp::serve_stdio(brain).await
104        }
105    }
106}
107
108/// Resolve the canonical Oxi Foundation default socket path.
109///
110/// Prefers `$OXIBRAIN_SOCKET` when set (the explicit override described in
111/// `doc/spec/oxi-foundation-v1.md` §1), otherwise falls back to
112/// `$HOME/.oxi/brain/oxibrain.sock`. Surfaces a clear error when neither is
113/// available so the operator can fix their environment instead of guessing.
114#[cfg(unix)]
115fn resolve_default_socket() -> anyhow::Result<std::path::PathBuf> {
116    if let Some(p) = default_socket_path() {
117        return Ok(p);
118    }
119    anyhow::bail!(
120        "no default oxibrain socket: neither $OXIBRAIN_SOCKET nor $HOME is set. \
121         Specify --socket explicitly or export one of these environment variables."
122    );
123}
124
125/// Prepare a socket path for binding: create the parent directory with
126/// owner-only permissions and reconcile any pre-existing socket file.
127///
128/// Three checks run before the listener loop ever starts:
129///
130/// 1. The parent directory is created with `0o700` permissions so the socket
131///    cannot be reached by users other than the daemon owner. This is the
132///    "filesystem permissions" mode described in DESIGN §11.2.
133/// 2. If a file already exists at the target path and it is *not* a socket
134///    (regular file, directory, symlink to anything else), we bail — the
135///    operator pointed us at the wrong path.
136/// 3. If a *socket* file already exists, we probe it by trying
137///    `connect()`: success means a competing daemon owns it (refuse);
138///    `ECONNREFUSED`/`ENOENT` means it is stale (remove before binding).
139///    A probe-fail with `EACCES`/`EPERM` means we cannot reach the listener
140///    even though one is there — we still refuse rather than delete, since
141///    removing a file we cannot read is unsafe.
142///
143/// The canonical `Brain::open` call in `run` also obtained the advisory
144/// lock on the store (P8), so two daemons can never hold the same store at
145/// once. The PID file is informational; the socket probe here is what stops
146/// a *different-store* daemon from silently stealing another daemon's
147/// socket path.
148#[cfg(unix)]
149fn prepare_socket_path(path: &Path) -> anyhow::Result<()> {
150    use std::fs;
151    use std::io::ErrorKind;
152
153    if let Some(parent) = path.parent() {
154        if !parent.as_os_str().is_empty() && !parent.exists() {
155            fs::create_dir_all(parent)
156                .with_context(|| format!("create socket parent {}", parent.display()))?;
157            // Tighten the directory we just created to owner-only. We MUST
158            // propagate this error: if `create_dir_all` succeeded under a
159            // permissive umask and `set_permissions` failed silently, the
160            // socket directory would stay accessible to other users, which
161            // breaks the "filesystem permissions" mode (DESIGN §11.2).
162            //
163            // `set_permissions` follows symlinks: if `parent` is a symlink
164            // to a directory, the permissions of the *target* directory are
165            // modified. This is the intended behavior — operators point the
166            // daemon at a path they own.
167            fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
168                .with_context(|| format!("tighten socket parent {} to 0o700", parent.display()))?;
169        } else if parent.exists() {
170            // Pre-existing parent: deliberately do not chmod — that would
171            // surprise the operator — but warn if the mode is too broad so
172            // they can tighten it themselves if they want owner-only
173            // isolation.
174            match std::fs::metadata(parent) {
175                Ok(meta) => {
176                    let mode = meta.permissions().mode() & 0o777;
177                    if mode & 0o077 != 0 {
178                        tracing::warn!(
179                            "socket parent {} already exists with mode {mode:o};                              not chmod-ing (operator-controlled). Other users may                              be able to reach the socket — use --require-token or                              restrict the directory manually.",
180                            parent.display(),
181                        );
182                    }
183                }
184                Err(e) => tracing::warn!("could not stat socket parent {}: {e}", parent.display(),),
185            }
186        }
187    }
188
189    if let Ok(meta) = std::fs::symlink_metadata(path) {
190        let ft = meta.file_type();
191        if !(ft.is_socket() || ft.is_fifo()) {
192            anyhow::bail!("{} exists and is not a socket; cannot bind", path.display());
193        }
194        // Probe: try to connect. A successful connect means a live owner
195        // exists — refuse rather than clobber.
196        match futures_probe(path) {
197            Ok(()) => anyhow::bail!(
198                "{} is held by a live daemon; refusing to bind.                 If that daemon has crashed, remove the socket manually after                 verifying no process is listening.",
199                path.display()
200            ),
201            Err(e)
202                if e.kind() == ErrorKind::NotFound || e.kind() == ErrorKind::ConnectionRefused =>
203            {
204                // Stale: the socket file is on disk but no listener is
205                // accepting. Safe to remove.
206                fs::remove_file(path)
207                    .with_context(|| format!("remove stale socket {}", path.display()))?;
208            }
209            Err(other) => {
210                // Permission denied, IO error, etc. Refuse to remove — the
211                // file is owned by someone else and we cannot safely touch it.
212                anyhow::bail!(
213                    "{} could not be probed ({}); refusing to bind to avoid                     clobbering an unreachable owner",
214                    path.display(),
215                    other
216                );
217            }
218        }
219    }
220    Ok(())
221}
222
223/// Synchronous wrapper around `tokio::net::UnixStream::connect` so
224/// `prepare_socket_path` stays callable from non-async sites.
225///
226/// Spins up a tiny current-thread runtime on a dedicated thread for the
227/// one-shot connect. The cost is a single thread creation + tiny rt per
228/// daemon start, which is negligible.
229#[cfg(unix)]
230fn futures_probe(path: &Path) -> std::io::Result<()> {
231    use std::io::{Error, ErrorKind};
232    let path = path.to_path_buf();
233    let handle = std::thread::Builder::new()
234        .name("oxibrain-socket-probe".into())
235        .spawn(move || -> std::io::Result<()> {
236            let rt = tokio::runtime::Builder::new_current_thread()
237                .enable_all()
238                .build()
239                .map_err(|e| Error::new(ErrorKind::Other, format!("probe rt: {e}")))?;
240            rt.block_on(async move {
241                let stream = tokio::net::UnixStream::connect(&path).await?;
242                drop(stream);
243                Ok::<(), std::io::Error>(())
244            })
245        })
246        .map_err(|e| Error::new(ErrorKind::Other, format!("probe thread: {e}")))?;
247    handle
248        .join()
249        .map_err(|_| Error::new(ErrorKind::Other, "probe thread panicked"))?
250}
251
252#[cfg(not(unix))]
253fn prepare_socket_path(_path: &Path) -> anyhow::Result<()> {
254    Ok(())
255}