From 6d63110dc5d6b28a69976432a9b27ba098a3c499 Mon Sep 17 00:00:00 2001 From: pmaxvsbobo Date: Sun, 6 Sep 2026 06:39:14 +0000 Subject: [PATCH 1/2] feat(mcp): add CBM_IN_PROCESS to serve stdio MCP without the daemon On hosts whose sandbox denies socket syscalls, an MCP client can never complete the daemon handshake. A macOS seatbelt profile that permits filesystem access but applies `(deny network*)` makes both bind() and connect() fail with EPERM, AF_UNIX included, so there is no reachable rendezvous and no alternative transport. The failure is slow and misreported rather than immediate. Daemon presence is inferred from a POSIX file lock, which such a profile still permits, so a live daemon reads as COORDINATED. connect() then returns EPERM, but cbm_daemon_runtime_connect_result_t (daemon/runtime.h) carries no errno, so cbm_daemon_bootstrap_classify_failed_connect() cannot distinguish "denied" from "still starting" and returns RESERVED, which is waitable. The bootstrap loop retries every BOOTSTRAP_RETRY_NS until MAIN_MCP_STARTUP_TIMEOUT_MS expires -- roughly 30,000 blind retries of an instantaneous EPERM -- and only then reports failure. Hosts that impose their own 30s MCP handshake budget see a timeout just before the error arrives, which hides the cause. A complete socket-free stdio server already existed but was unreachable from main(): cbm_mcp_server_run() (mcp.h) has no socket, sockaddr_un or IPC reference anywhere in src/mcp/mcp.c, and its only caller was tests/test_mcp.c. This routes MCP clients to it behind an opt-in CBM_IN_PROCESS gate, so default behaviour is byte-for-byte unchanged. The store is resolved with cbm_mcp_server_new(NULL) exactly as daemon/application.c and ui/http_server.c already do, so an in-process session reads the same CBM_CACHE_DIR indexes a daemon builds. Background tasks stay at the standalone default documented in mcp.h: with no config store attached, maybe_auto_index() resolves auto_index=false and returns without doing synchronous work on the initialize path. Trade-off, documented in both README.md and docs/CONFIGURATION.md: no cross-session coordination -- no shared watchers, no shared indexing jobs, no UI, no exact-build admission barrier. Measured on macOS with the sandbox active (sandbox_check() == 1): without CBM_IN_PROCESS: no response, rc=1 after 31.1s with CBM_IN_PROCESS: initialize result in 4.6s scripts/test_mcp_in_process.py asserts the handshake completes AND that no rendezvous appears under CBM_RUNTIME_DIR, since "it answered" alone would still pass on an ordinary host if the session silently fell back to the daemon. Verified to fail against an unpatched binary. Signed-off-by: pmaxvsbobo --- README.md | 2 + docs/CONFIGURATION.md | 1 + scripts/test_mcp_in_process.py | 138 +++++++++++++++++++++++++++++++++ src/main.c | 46 +++++++++++ 4 files changed, 187 insertions(+) create mode 100644 scripts/test_mcp_in_process.py diff --git a/README.md b/README.md index b41939a8f..3b2560bff 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 in-process over stdio against the same `CBM_CACHE_DIR` indexes, at the cost of every cross-session guarantee above. 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..0ff4ec55d 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 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 and writes the same indexes under `CBM_CACHE_DIR` that a daemon-backed session uses. Because there is no daemon, there is no cross-session coordination: no shared watchers, no shared indexing jobs, 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..3757feb7b --- /dev/null +++ b/scripts/test_mcp_in_process.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Integration test for CBM_IN_PROCESS (daemon-free stdio MCP). + +Spawns the MCP server binary with CBM_IN_PROCESS=1 and asserts that it + + 1. completes an initialize + tools/list handshake, and + 2. creates no daemon rendezvous inside CBM_RUNTIME_DIR. + +(2) is the real assertion. 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 this check catches it. + +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 = 30 + +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' +) + + +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 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") + os.mkdir(runtime_dir, 0o700) + os.mkdir(cache_dir, 0o700) + + 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, 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} + + 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) + + # The assertion that distinguishes in-process from daemon-backed: no + # rendezvous directory, and no socket anywhere beneath it. + strays = [] + for root, _dirs, files in os.walk(runtime_dir): + for name in files: + strays.append(os.path.relpath(os.path.join(root, name), runtime_dir)) + if strays: + fail( + "CBM_IN_PROCESS still created a daemon rendezvous in " + f"CBM_RUNTIME_DIR: {sorted(strays)}" + ) + + 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 8a575c44f..8cd1e5e6b 100644 --- a/src/main.c +++ b/src/main.c @@ -2947,6 +2947,52 @@ 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. + * Background tasks stay at their standalone default per mcp.h: with no + * config store attached, maybe_auto_index() resolves auto_index=false and + * returns without doing synchronous work. */ + 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); + 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 From 838fb7090f3a08920363e9ee793824b4d6202bef Mon Sep 17 00:00:00 2001 From: pmaxvsbobo Date: Sun, 6 Sep 2026 23:30:04 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(mcp):=20make=20CBM=5FIN=5FPROCESS=20rea?= =?UTF-8?q?d-only=20=E2=80=94=20refuse=20mutations=20instead=20of=20taking?= =?UTF-8?q?=20them=20unguarded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a real hazard, and it was worse than "no lease is acquired": mcp_project_mutation_begin() is return !srv->mutation_begin || srv->mutation_begin(...) so an UNSET guard fails OPEN and the mutation proceeds. The first version of this branch set no mutation guard, no try guard and no index executor, and left background_tasks at its default true — so an in-process index_repository wrote the shared cache under CBM_CACHE_DIR with no cross-session lease at all, racing any daemon session mutating the same project. Every other construction site in the tree already gets this right: daemon/application.c installs the guard, the try guard, a config store and an index executor; ui/http_server.c installs a refusing index executor plus both guards; and main.c's own local-CLI path, in the same function this patch edits, installs both guards. This mirrors them. Without a daemon there is nothing to acquire a lease FROM, so the honest answer is that an in-process session cannot coordinate a write and must refuse it. Indexing stays with the daemon, outside the sandbox — which is the actual use case anyway: the sandboxed session only needs to read an index built elsewhere. - background_tasks off, so maybe_auto_index() cannot index on initialize - a mutation guard that refuses, and the try guard deliberately left NULL: 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 - an index executor that rejects with a message naming the reason, rather than letting the guard report it as "blocked by an active index" when nothing is blocking Test extended to cover it, and the assertion was verified to fail against a build with the three calls removed. That build did not merely skip the guard — it returned {"status":"indexed"}, isError:false, having written the cache. So the test now asserts both that the call is refused AND that no .db appears under CBM_CACHE_DIR: an error message alone could be emitted after a partial write, and only the second check distinguishes a refusing guard from an absent one. Signed-off-by: pmaxvsbobo --- README.md | 2 +- docs/CONFIGURATION.md | 2 +- scripts/test_mcp_in_process.py | 80 +++++++++++++++++++++++++--------- src/main.c | 59 +++++++++++++++++++++++-- 4 files changed, 117 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 3b2560bff..d0062bcb9 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ 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 in-process over stdio against the same `CBM_CACHE_DIR` indexes, at the cost of every cross-session guarantee above. See [Configuration](docs/CONFIGURATION.md). +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`): diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0ff4ec55d..6a64ac87b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -149,7 +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 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 and writes the same indexes under `CBM_CACHE_DIR` that a daemon-backed session uses. Because there is no daemon, there is no cross-session coordination: no shared watchers, no shared indexing jobs, no UI, and no exact-build admission barrier. Leave it unset unless the daemon genuinely cannot be reached. | +| `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 index 3757feb7b..844bae7f4 100644 --- a/scripts/test_mcp_in_process.py +++ b/scripts/test_mcp_in_process.py @@ -1,20 +1,28 @@ #!/usr/bin/env python3 -"""Integration test for CBM_IN_PROCESS (daemon-free stdio MCP). +"""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, and - 2. creates no daemon rendezvous inside CBM_RUNTIME_DIR. + 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) is the real assertion. 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 this check catches it. +(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. +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] @@ -31,13 +39,16 @@ import sys import tempfile -TIMEOUT_S = 30 +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' ) @@ -48,6 +59,14 @@ def fail(message, output=None): 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] @@ -66,8 +85,11 @@ def main(): workdir = tempfile.mkdtemp(prefix="cbm-in-process-") runtime_dir = os.path.join(workdir, "runtime") cache_dir = os.path.join(workdir, "cache") - os.mkdir(runtime_dir, 0o700) - os.mkdir(cache_dir, 0o700) + 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" @@ -84,7 +106,9 @@ def main(): ) try: - stdout_data, _ = proc.communicate(input=MESSAGES, timeout=TIMEOUT_S) + stdout_data, _ = proc.communicate( + input=MESSAGES % repo_dir.encode(), timeout=TIMEOUT_S + ) except subprocess.TimeoutExpired: proc.kill() proc.wait() @@ -107,6 +131,7 @@ def main(): 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]: @@ -116,16 +141,29 @@ def main(): if "tools" not in output: fail("tools/list response body missing 'tools' key", output) - # The assertion that distinguishes in-process from daemon-backed: no - # rendezvous directory, and no socket anywhere beneath it. - strays = [] - for root, _dirs, files in os.walk(runtime_dir): - for name in files: - strays.append(os.path.relpath(os.path.join(root, name), runtime_dir)) + # 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: {sorted(strays)}" + 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") diff --git a/src/main.c b/src/main.c index 8cd1e5e6b..cee3ce6dd 100644 --- a/src/main.c +++ b/src/main.c @@ -2522,6 +2522,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(); @@ -2963,9 +3004,13 @@ int main(int argc, char **argv) { * 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. - * Background tasks stay at their standalone default per mcp.h: with no - * config store attached, maybe_auto_index() resolves auto_index=false and - * returns without doing synchronous work. */ + * + * 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 = @@ -2978,6 +3023,14 @@ int main(int argc, char **argv) { 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;