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}