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);