diff --git a/README.md b/README.md index 3911bacf8..d9425ee8e 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ The `install` command auto-detects installed coding agents and configures their CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and the final session shuts it down. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues. +The one opt-*out* is `CBM_IN_PROCESS`, for hosts where the daemon cannot be reached at all: a sandbox that permits filesystem access but denies networking makes `AF_UNIX` `bind()` and `connect()` fail with `EPERM`, so the handshake can never complete. Setting it serves that MCP session **read-only** and in-process over stdio, reading the same `CBM_CACHE_DIR` indexes. Writes are refused rather than taken uncoordinated — there is no daemon to hold the cross-session lease — so indexing stays outside the sandbox. See [Configuration](docs/CONFIGURATION.md). + The detached daemon does not depend on an MCP frontend's stderr. It keeps owner-only durable records under the canonical `${CBM_CACHE_DIR}/logs` directory (default `~/.cache/codebase-memory-mcp/logs`): | File | Contents | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 862d591c3..6a64ac87b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -149,6 +149,7 @@ These environment variables affect runtime behavior: | `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the cache directory used for indexes, `_config.db`, and UI `config.json`. | | `CBM_DIAGNOSTICS` | `false` | Enable periodic `snapshot.json` and retained `trajectory.ndjson` below a fresh owner-private directory in the system temp directory. The daemon records the randomized paths in the `diagnostics.start` discovery record (a single JSON line) in `${CBM_CACHE_DIR}/logs/cbm-daemon.log`; that one record is emitted even when `CBM_LOG_LEVEL` suppresses ordinary logging, so the paths always remain discoverable. | | `CBM_DOWNLOAD_URL` | GitHub releases | Override the update download URL. | +| `CBM_IN_PROCESS` | *(unset)* | Serve an MCP session **read-only** and in-process over stdio, without starting or connecting to the coordination daemon. Set it to any value other than `0`. Intended for hosts whose sandbox denies socket syscalls outright — a macOS seatbelt profile that permits filesystem access but applies `(deny network*)` makes both `bind()` and `connect()` fail with `EPERM`, including for `AF_UNIX`, so the daemon handshake can never complete. The session reads the same indexes under `CBM_CACHE_DIR` that a daemon-backed session uses. **Writes are refused, not merely uncoordinated:** with no daemon there is no cross-session mutation lease, so `index_repository` and any other mutating tool return an error rather than write the shared cache behind a concurrent daemon session's back. Index from outside the sandbox instead (`codebase-memory-mcp cli index_repository`), where the daemon owns the lease and the watcher. There is also no UI and no exact-build admission barrier. Leave it unset unless the daemon genuinely cannot be reached. | | `CBM_LOG_LEVEL` | role-aware | Set the log level to `debug`, `info`, `warn`, `error`, or `none` (or `0`-`4`). Thin MCP/CLI/hook frontends default to `warn`; the detached daemon and supervised index workers default to `info`. Physical workers retain INFO liveness records because their private logs drive the supervisor's no-progress timeout. Frontend messages use that session's stderr; detached daemon events use `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. | | `CBM_RUNTIME_DIR` | `%LOCALAPPDATA%` (Windows), `/private/tmp` (macOS), `/tmp` (other) | Parent directory for the daemon/CLI rendezvous directory, which CBM creates inside it as `cbm-daemon-` (`cbm-daemon-` on Windows). Set it when the default ancestry cannot pass the private-directory check — see below. `CBM_CACHE_DIR` does **not** move the rendezvous. | | `CBM_WORKERS` | auto-detected | Override the indexing worker count. | diff --git a/scripts/test_mcp_in_process.py b/scripts/test_mcp_in_process.py new file mode 100644 index 000000000..844bae7f4 --- /dev/null +++ b/scripts/test_mcp_in_process.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Integration test for CBM_IN_PROCESS (daemon-free, read-only stdio MCP). + +Spawns the MCP server binary with CBM_IN_PROCESS=1 and asserts that it + + 1. completes an initialize + tools/list handshake, + 2. creates no daemon rendezvous inside CBM_RUNTIME_DIR, and + 3. refuses a mutating tool call, writing no index database. + +(2) and (3) are the real assertions. The in-process path exists for hosts whose +sandbox denies socket syscalls outright, so "it answered" is not enough — it must +have answered without ever attempting a rendezvous. A regression that quietly +routes the session back through the coordination daemon still answers on an +ordinary host, and only (2) catches it. + +(3) guards the correctness hazard: with no daemon there is no cross-session +mutation lease, and mcp_project_mutation_begin() treats an *unset* guard as +permission, so a missing guard would let an in-process index_repository write the +shared cache while a daemon session mutates the same project. Asserting that no +database appears is what distinguishes a refusing guard from an absent one — an +error message alone could be printed after a partial write. + +Both CBM_RUNTIME_DIR and CBM_CACHE_DIR are redirected to fresh temporary +directories, so the test neither joins nor disturbs a developer's running daemon +or indexes. + +Usage: + python3 scripts/test_mcp_in_process.py [/path/to/binary] + +Exit codes: + 0 - PASS + 1 - FAIL +""" + +import json +import os +import shutil +import subprocess +import sys +import tempfile + +TIMEOUT_S = 60 +DB_SUFFIXES = (".db", ".db-wal", ".db-shm") + +MESSAGES = ( + b'{"jsonrpc":"2.0","id":1,"method":"initialize",' + b'"params":{"protocolVersion":"2025-11-25","capabilities":{}}}\n' + b'{"jsonrpc":"2.0","method":"notifications/initialized"}\n' + b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n' + b'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"index_repository",' + b'"arguments":{"repo_path":"%s"}}}\n' +) + + +def fail(message, output=None): + print(f"FAIL: {message}") + if output is not None: + print(f"Server output was:\n{output!r}") + sys.exit(1) + + +def files_under(root): + return sorted( + os.path.relpath(os.path.join(d, f), root) + for d, _dirs, fs in os.walk(root) + for f in fs + ) + + +def main(): + if len(sys.argv) >= 2: + binary = sys.argv[1] + else: + # Default: look for build artifact relative to this script's directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + repo_root = os.path.dirname(script_dir) + binary = os.path.join(repo_root, "build", "c", "codebase-memory-mcp") + + if not os.path.isfile(binary): + fail(f"binary not found at {binary}") + + if not os.access(binary, os.X_OK): + fail(f"binary not executable: {binary}") + + workdir = tempfile.mkdtemp(prefix="cbm-in-process-") + runtime_dir = os.path.join(workdir, "runtime") + cache_dir = os.path.join(workdir, "cache") + repo_dir = os.path.join(workdir, "repo") + for d in (runtime_dir, cache_dir, repo_dir): + os.mkdir(d, 0o700) + with open(os.path.join(repo_dir, "a.c"), "w") as fh: + fh.write("int main(void) { return 0; }\n") + + env = dict(os.environ) + env["CBM_IN_PROCESS"] = "1" + env["CBM_RUNTIME_DIR"] = runtime_dir + env["CBM_CACHE_DIR"] = cache_dir + + try: + proc = subprocess.Popen( + [binary], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=env, + ) + + try: + stdout_data, _ = proc.communicate( + input=MESSAGES % repo_dir.encode(), timeout=TIMEOUT_S + ) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + fail( + f"server did not respond within {TIMEOUT_S}s " + f"(CBM_IN_PROCESS did not bypass the coordination daemon)" + ) + + output = stdout_data.decode("utf-8", errors="replace") + + responses = [] + for line in output.splitlines(): + line = line.strip() + if not line: + continue + try: + responses.append(json.loads(line)) + except json.JSONDecodeError: + pass + + by_id = {obj["id"]: obj for obj in responses if "id" in obj} + + # 1. Handshake + if 1 not in by_id: + fail("missing initialize response (id:1)", output) + if "result" not in by_id[1]: + fail(f"initialize returned an error: {by_id[1].get('error')}", output) + if 2 not in by_id: + fail("missing tools/list response (id:2)", output) + if "tools" not in output: + fail("tools/list response body missing 'tools' key", output) + + # 2. No rendezvous: what distinguishes in-process from daemon-backed. + strays = files_under(runtime_dir) + if strays: + fail( + "CBM_IN_PROCESS still created a daemon rendezvous in " + f"CBM_RUNTIME_DIR: {strays}" + ) + + # 3. Mutations refused, and refused BEFORE any write. + if 3 not in by_id: + fail("missing index_repository response (id:3)", output) + call = by_id[3].get("result") or {} + if not call.get("isError"): + fail( + "index_repository was NOT refused in CBM_IN_PROCESS mode — an " + "unset mutation guard reads as permission, so this would write " + f"the shared cache with no lease. Response: {by_id[3]}", + ) + dbs = [f for f in files_under(cache_dir) if f.endswith(DB_SUFFIXES)] + if dbs: + fail( + "index_repository reported an error but still wrote an index " + f"database under CBM_CACHE_DIR: {dbs}" + ) + + print("PASS") + sys.exit(0) + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/src/main.c b/src/main.c index 202aa6a18..18b220bad 100644 --- a/src/main.c +++ b/src/main.c @@ -2677,6 +2677,47 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo return ui_result; } +/* In-process sessions are read-only, and these two are what enforce it. + * + * Without a mutation guard the enforcement would be absent rather than lax: + * mcp_project_mutation_begin() is `!srv->mutation_begin || srv->mutation_begin(...)`, + * so an unset guard fails OPEN and every mutation proceeds. The daemon + * (daemon/application.c), the UI (ui/http_server.c) and the local CLI above all + * install one; an in-process session has no daemon to acquire a cross-session + * lease from, so it must refuse the mutation instead of taking it unguarded. + * Otherwise an in-process index_repository would write the shared cache under + * CBM_CACHE_DIR while a daemon session on the same machine mutates the same + * project — the exact race the lease exists to prevent. + * + * Leaving the try-guard unset is deliberate and load-bearing: with + * mutation_begin set and mutation_try_begin NULL, mcp_project_mutation_try_begin() + * also returns false, so opportunistic writes during a read are refused too. */ +static bool main_in_process_mutation_refused(void *context, const char *project) { + (void)context; + (void)project; + return false; +} + +static void main_in_process_mutation_end(void *context, const char *project) { + (void)context; + (void)project; +} + +/* Reject indexing with a message that says why, rather than letting the + * mutation guard report it as "blocked by an active index" — nothing is + * blocking, the mode simply cannot coordinate a write. Mirrors + * http_read_only_index_rejected in ui/http_server.c. */ +static char *main_in_process_index_rejected(void *context, const char *repo_path, + const char *args_json) { + (void)context; + (void)repo_path; + (void)args_json; + return cbm_mcp_text_result("indexing is unavailable in CBM_IN_PROCESS mode: it needs the " + "coordination daemon's cross-session lease. Run " + "`codebase-memory-mcp cli index_repository` outside the sandbox.", + true); +} + int main(int argc, char **argv) { /* Must remain the first statement: see allocator binding contract above. */ cbm_alloc_init(); @@ -3107,6 +3148,64 @@ int main(int argc, char **argv) { return result; } + /* In-process MCP: serve the stdio JSON-RPC loop directly, with no + * coordination daemon and therefore no AF_UNIX rendezvous at all. + * + * This exists for hosts whose sandbox denies socket syscalls outright — + * e.g. a macOS seatbelt profile that is `(allow default)` for the + * filesystem but `(deny network*)`, which covers network-bind and + * network-outbound and so makes both bind() and connect() EPERM. There the + * daemon handshake can never complete: presence is inferred from a file + * lock (permitted), so an EPERM connect is misread as "daemon still + * starting" and the whole MAIN_MCP_STARTUP_TIMEOUT_MS budget is spent + * retrying it before the client gives up. + * + * Opt-in via CBM_IN_PROCESS so default behaviour is unchanged. The store is + * resolved from CBM_CACHE_DIR exactly as a daemon session does + * (cbm_mcp_server_new(NULL), as in daemon/application.c and + * ui/http_server.c), so this reads the same index the daemon builds. + * + * The session is READ-ONLY, and deliberately so. Without a daemon there is + * no cross-session mutation lease, no index executor and no exact-build + * admission, so a write here could not be coordinated against a daemon + * session touching the same project. See the guards above main() for how + * that is enforced and why an unset guard would have been worse than a + * refusing one. Indexing stays with the daemon, outside the sandbox. */ + if (role == CBM_DAEMON_PROCESS_MCP_CLIENT) { + char inproc_buf[MAIN_PATH_CAP]; + const char *inproc = + cbm_safe_getenv("CBM_IN_PROCESS", inproc_buf, sizeof(inproc_buf), NULL); + if (inproc && inproc[0] && strcmp(inproc, "0") != 0) { + cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); + cbm_mcp_server_t *inproc_srv = cbm_mcp_server_new(NULL); + if (!inproc_srv) { + (void)fprintf(stderr, "codebase-memory-mcp: cannot create in-process MCP server\n"); + return EXIT_FAILURE; + } + cbm_mcp_server_set_tool_profile(inproc_srv, tool_profile); + /* Read-only: no daemon means no cross-session lease, index executor + * or exact-build admission, so every write path must refuse rather + * than proceed unguarded. background_tasks off also stops + * maybe_auto_index() from indexing on the initialize path. */ + cbm_mcp_server_set_background_tasks(inproc_srv, false); + cbm_mcp_server_set_index_executor(inproc_srv, main_in_process_index_rejected, NULL); + cbm_mcp_server_set_project_mutation_guard(inproc_srv, main_in_process_mutation_refused, + main_in_process_mutation_end, NULL); + char inproc_root[MAIN_PATH_CAP]; + char inproc_allowed[MAIN_PATH_CAP]; + const char *inproc_allowed_ptr = NULL; + if (main_session_context(NULL, inproc_root, inproc_allowed, &inproc_allowed_ptr)) { + (void)cbm_mcp_server_set_session_context(inproc_srv, inproc_root, + inproc_allowed_ptr); + } + setup_signal_handlers(); + cbm_log_info("mcp.in_process", "reason", "CBM_IN_PROCESS", "daemon", "bypassed"); + int inproc_rc = cbm_mcp_server_run(inproc_srv, stdin, stdout); + cbm_mcp_server_free(inproc_srv); + return inproc_rc < 0 ? EXIT_FAILURE : EXIT_SUCCESS; + } + } + cbm_daemon_ipc_endpoint_t *endpoint = main_daemon_endpoint_new(); if (!endpoint) { /* #1582: this is where an ownership/ancestry refusal lands, and it was