From 7f3e30e1e1839344059aec87b58a6964e8d5c902 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 14 Aug 2026 20:04:08 +0200 Subject: [PATCH] fix(daemon): let a shipped build relocate the rendezvous via CBM_RUNTIME_DIR The daemon/CLI rendezvous directory is created under %LOCALAPPDATA% (Windows) or /tmp -- /private/tmp on macOS -- and every ancestor of it must pass the private-directory walk. That ancestry is not always acceptable, and when it is not, EVERY invocation fails, `config list` included, so the settings surface cannot be reached either: codebase-memory-mcp: secure daemon endpoint could not be created #1623 narrowed the Windows side of this by admitting AppContainer package and capability SIDs on ancestors, and named the remainder explicitly: a live local group, Authenticated Users inherited from a secondary volume root, and orphaned unresolvable SIDs still refuse, and "those need CBM_RUNTIME_DIR or a separate change". #1621 is the POSIX shape of the same dead end -- /private/tmp/cbm-daemon- refused with no way to move it. There was no way to move it in a shipped build. The only relocation hook, CBM_TEST_DAEMON_RUNTIME_PARENT, is compiled out unless CBM_ENABLE_TEST_SEAMS is defined, so a test build started while the shipped build did not; CBM_CACHE_DIR is no help either, because it moves the cache and never the rendezvous. CBM_RUNTIME_DIR names the parent directory the rendezvous is created under. It does NOT relax the check: the directory it names goes through exactly the same validation as the default -- ancestors owned by you or root, not world-writable, no allow-ACL; the rendezvous directory itself still forced to owner-only -- and a value that fails is refused rather than silently replaced by the default. The operator only chooses an ancestry that passes. cbm_safe_getenv never truncates, so no half of an over-long value can become a runtime parent. The override is resolved in cbm_daemon_bootstrap_endpoint_new(), the one function every product endpoint goes through: the daemon, the MCP client, the local CLI, the index worker, and the install/update/uninstall activation path in cli.c. No call site can silently keep the default, and the detached daemon inherits the value with the rest of its environment. An explicit parent still wins, so the compile-time test seam and the lifecycle guards' isolated namespace behave exactly as before. Approach and variable name from #1576 by Leonardo trindade miranda, resolved one layer lower so the activation path is covered too. Refs #1574 Refs #1621 Co-Authored-By: Leonardo trindade miranda Signed-off-by: Martin Vogel --- docs/CONFIGURATION.md | 38 +++++++++++++++ src/daemon/bootstrap.c | 33 ++++++++++++- tests/test_daemon_bootstrap.c | 88 ++++++++++++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index fae2af19e..0ee8058d1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -121,8 +121,46 @@ These environment variables affect runtime behavior: | `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_LOG_LEVEL` | `info` | Set the log level to `debug`, `info`, `warn`, `error`, or `none` (or `0`-`4`). Thin-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. | +### Relocating the daemon rendezvous directory + +Before it is used, the rendezvous directory and every ancestor of it are checked: +each ancestor must be owned by you or by root, must not be world-writable (unless +it is the standard root-owned sticky directory such as `/tmp`), and must carry no +allow-ACL — on Windows, no ACE granting mutation rights to another identity. The +rendezvous directory itself is then forced to owner-only (`0700`, no extended ACL +/ an owner-only DACL). + +That ancestry is not always acceptable in the default location. A Windows profile +that has acquired a capability-SID ACE with `WRITE_DAC` / `WRITE_OWNER` / `DELETE` +on `%LOCALAPPDATA%` — something an installed packaged app can add — fails the walk, +and so can an unusual `/tmp` or home directory on POSIX. When that happens *every* +command fails, `config list` included, so the settings surface cannot be reached +either: + +```text +codebase-memory-mcp: secure daemon endpoint could not be created +``` + +`CBM_RUNTIME_DIR` points the rendezvous at an ancestry you choose: + +```bash +export CBM_RUNTIME_DIR="$HOME/cbm-runtime" # any directory you own +``` + +```powershell +$env:CBM_RUNTIME_DIR = "D:\cbm-runtime" +``` + +The check is not relaxed for the directory you name: it goes through exactly the +same validation as the default, and a value that fails it is refused rather than +silently ignored. Because the rendezvous is how sessions find each other, every +process that should share one daemon must see the same value — set it in the +environment of your MCP client and your shell alike, or a CLI invocation without +it will coordinate through the default location instead. + Environment used by daemon-owned components—such as diagnostics, daemon logging, and process-wide indexing resource limits—is captured from the first daemon-backed session that starts the daemon. Later sessions join the existing process and cannot replace those values. To change them, close every daemon-backed session, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and one-shot CLI commands use their own current environment without starting the daemon. diff --git a/src/daemon/bootstrap.c b/src/daemon/bootstrap.c index 1fd5d7370..ee61261d3 100644 --- a/src/daemon/bootstrap.c +++ b/src/daemon/bootstrap.c @@ -208,12 +208,43 @@ bool cbm_daemon_process_role_requires_client(cbm_daemon_process_role_t role) { return role == CBM_DAEMON_PROCESS_MCP_CLIENT || role == CBM_DAEMON_PROCESS_HOOK_CLIENT; } +/* #1574/#1621: the rendezvous directory is created under %LOCALAPPDATA% + * (Windows) or /tmp — /private/tmp on macOS — and that ancestry is not always + * acceptable to the private-directory walk. A profile that carries a + * mutation-granting ACE for an untrusted identity (an AppContainer capability + * SID, for instance) fails it, and the binary then cannot start at all: every + * command needs this endpoint, `config list` included, so the operator cannot + * even reconfigure their way out. The only relocation hook was + * CBM_TEST_DAEMON_RUNTIME_PARENT, compiled out unless CBM_ENABLE_TEST_SEAMS is + * defined, so a test build started while the shipped build did not. CBM_CACHE_DIR + * does not help either — it moves the cache, never the rendezvous. + * + * CBM_RUNTIME_DIR does NOT relax the check. The directory it names goes through + * exactly the same validation as the default; the operator only chooses an + * ancestry that passes, and a value that fails is refused rather than ignored. + * cbm_safe_getenv never truncates: a value too long for the buffer is reported + * as absent, so no half a path can ever become a runtime parent. */ +static const char *bootstrap_runtime_parent_override(char *buffer, size_t capacity) { + const char *value = cbm_safe_getenv("CBM_RUNTIME_DIR", buffer, capacity, NULL); + return value && value[0] != '\0' ? value : NULL; +} + cbm_daemon_ipc_endpoint_t *cbm_daemon_bootstrap_endpoint_new(const char *runtime_parent) { char key[CBM_DAEMON_KEY_SIZE]; if (!cbm_daemon_rendezvous_key(key)) { return NULL; } - return cbm_daemon_ipc_endpoint_new(key, runtime_parent); + /* An explicit parent keeps precedence: it carries the compile-time test + * seam and the lifecycle guards' isolated namespace. The override is + * resolved HERE, the one function every product endpoint goes through + * (daemon, MCP client, local CLI, index worker, activation), so no call + * site can silently keep the default. */ + char override_parent[BOOTSTRAP_PATH_CAP]; + const char *parent = + runtime_parent + ? runtime_parent + : bootstrap_runtime_parent_override(override_parent, sizeof(override_parent)); + return cbm_daemon_ipc_endpoint_new(key, parent); } bool cbm_daemon_bootstrap_launch_spec_init(const char *executable_path, diff --git a/tests/test_daemon_bootstrap.c b/tests/test_daemon_bootstrap.c index d0e557a03..29f6a6f08 100644 --- a/tests/test_daemon_bootstrap.c +++ b/tests/test_daemon_bootstrap.c @@ -89,6 +89,15 @@ static bool bootstrap_endpoint_fixture_start(bootstrap_endpoint_fixture_t *fixtu return written > 0 && written < (int)sizeof(fixture->runtime_dir); } +/* Compare against canonical parents only: the endpoint canonicalizes its parent + * before building the runtime path (/var/folders/... becomes /private/var/... on + * macOS), so a raw prefix compare would miss a correct relocation. */ +static bool bootstrap_path_has_parent(const char *path, const char *parent) { + size_t length = parent ? strlen(parent) : 0; + return path && length > 0 && strncmp(path, parent, length) == 0 && + (path[length] == '/' || path[length] == '\\'); +} + static void bootstrap_endpoint_fixture_finish(bootstrap_endpoint_fixture_t *fixture) { cbm_daemon_ipc_endpoint_free(fixture->endpoint); if (fixture->runtime_dir[0] != '\0') { @@ -220,8 +229,7 @@ static bool bootstrap_fake_spawn(void *opaque, const cbm_daemon_bootstrap_launch bootstrap_fake_ops_t *fake = opaque; /* Client bootstrap must only ever spawn the EPHEMERAL two-argument * shape; the permanent shape belongs exclusively to `daemon start`. */ - bool exact = spec && spec->argc == 2U && spec->argv[0] && - spec->argv[1] && !spec->argv[2] && + bool exact = spec && spec->argc == 2U && spec->argv[0] && spec->argv[1] && !spec->argv[2] && strcmp(spec->argv[1], CBM_DAEMON_INTERNAL_ARG) == 0 && spec->detached && !spec->inherit_standard_handles && !spec->use_shell && atomic_load(&fake->handoff_count) > 0 && atomic_load(&fake->lock_held) == 1; @@ -365,6 +373,81 @@ TEST(daemon_bootstrap_uses_one_stable_per_account_endpoint) { PASS(); } +/* #1574/#1621: the shipped build must be able to relocate the rendezvous when + * the default ancestry (%LOCALAPPDATA%, /private/tmp) cannot pass the + * private-directory walk — otherwise every command fails, `config list` + * included, and the operator cannot reconfigure their way out. CBM_RUNTIME_DIR + * moves WHERE the rendezvous lives; it never relaxes HOW it is checked, so a + * value that cannot be a private runtime parent must be refused rather than + * silently replaced by the default. An explicit parent — the compile-time test + * seam, the lifecycle guards' isolated namespace — keeps precedence over it. */ +TEST(daemon_bootstrap_runtime_dir_env_relocates_rendezvous) { + char override_parent[BOOTSTRAP_TEST_PATH_CAP] = {0}; + char canonical_override[BOOTSTRAP_TEST_PATH_CAP] = {0}; + char canonical_explicit[BOOTSTRAP_TEST_PATH_CAP] = {0}; + char relocated_runtime[BOOTSTRAP_TEST_PATH_CAP] = {0}; + char explicit_runtime[BOOTSTRAP_TEST_PATH_CAP] = {0}; + char unusable[BOOTSTRAP_TEST_PATH_CAP] = {0}; + int written = snprintf(override_parent, sizeof(override_parent), + "%s/cbm-bootstrap-runtime-env-XXXXXX", cbm_tmpdir()); + if (written <= 0 || written >= (int)sizeof(override_parent) || !cbm_mkdtemp(override_parent)) { + FAIL("could not create the override runtime parent"); + } + written = snprintf(unusable, sizeof(unusable), "%s/absent/nested", override_parent); + bool prepared = + written > 0 && written < (int)sizeof(unusable) && + cbm_canonical_path(override_parent, canonical_override, sizeof(canonical_override)) != 0 && + cbm_setenv("CBM_RUNTIME_DIR", override_parent, 1) == 0; + + /* NULL parent == every product call site: daemon, MCP client, local CLI, + * index worker, activation. */ + cbm_daemon_ipc_endpoint_t *relocated = + prepared ? cbm_daemon_bootstrap_endpoint_new(NULL) : NULL; + const char *relocated_dir = relocated ? cbm_daemon_ipc_endpoint_runtime_dir(relocated) : NULL; + if (relocated_dir) { + (void)snprintf(relocated_runtime, sizeof(relocated_runtime), "%s", relocated_dir); + } + + /* Same environment, explicit parent: the caller still wins. */ + bootstrap_endpoint_fixture_t fixture = {0}; + bool explicit_started = prepared && bootstrap_endpoint_fixture_start(&fixture, "runtime-env"); + bool explicit_canonical = + explicit_started && + cbm_canonical_path(fixture.parent, canonical_explicit, sizeof(canonical_explicit)) != 0; + if (explicit_started) { + (void)snprintf(explicit_runtime, sizeof(explicit_runtime), "%s", fixture.runtime_dir); + } + + /* A named parent that cannot pass validation is refused, never ignored. */ + bool unusable_set = prepared && cbm_setenv("CBM_RUNTIME_DIR", unusable, 1) == 0; + cbm_daemon_ipc_endpoint_t *refused = + unusable_set ? cbm_daemon_bootstrap_endpoint_new(NULL) : NULL; + + /* Restore before asserting: a failed assertion returns immediately, and a + * leaked CBM_RUNTIME_DIR would follow every later suite in this process. */ + (void)cbm_unsetenv("CBM_RUNTIME_DIR"); + cbm_daemon_ipc_endpoint_free(refused); + cbm_daemon_ipc_endpoint_free(relocated); + if (relocated_runtime[0] != '\0') { + (void)cbm_rmdir(relocated_runtime); + } + if (explicit_started) { + bootstrap_endpoint_fixture_finish(&fixture); + } + (void)cbm_rmdir(override_parent); + + ASSERT_TRUE(prepared); + ASSERT_TRUE(explicit_started); + ASSERT_TRUE(explicit_canonical); + ASSERT_TRUE(unusable_set); + ASSERT_NOT_NULL(relocated); + ASSERT_TRUE(bootstrap_path_has_parent(relocated_runtime, canonical_override)); + ASSERT_TRUE(bootstrap_path_has_parent(explicit_runtime, canonical_explicit)); + ASSERT_FALSE(bootstrap_path_has_parent(explicit_runtime, canonical_override)); + ASSERT_NULL(refused); + PASS(); +} + TEST(daemon_bootstrap_launches_only_exact_detached_hidden_role) { cbm_daemon_bootstrap_launch_spec_t spec; ASSERT_TRUE(cbm_daemon_bootstrap_launch_spec_init("/tmp/cbm exact", &spec)); @@ -774,6 +857,7 @@ SUITE(daemon_bootstrap) { RUN_TEST(daemon_bootstrap_internal_roles_never_take_client_leases); RUN_TEST(daemon_bootstrap_rejects_ambiguous_internal_daemon_argv); RUN_TEST(daemon_bootstrap_uses_one_stable_per_account_endpoint); + RUN_TEST(daemon_bootstrap_runtime_dir_env_relocates_rendezvous); RUN_TEST(daemon_bootstrap_launches_only_exact_detached_hidden_role); RUN_TEST(daemon_bootstrap_permanent_daemon_argv_is_byte_exact); RUN_TEST(daemon_bootstrap_daemon_ctl_token_routes_after_cli);