Why: the activation rendezvous is per OS account, so an install or update
run under another HOME / CBM_CACHE_DIR (a sandbox, a second profile) meets
the account's live daemon at the same endpoint. The daemon's
activation-shutdown handler verifies only the requester's build, never its
cache, so the CLI's scope decision is the only thing that tells "my daemon"
from "someone else's". That decision drained the cohort unless the active
cache was positively DIFFERENT: a busy session group (another activation or
a session start held it, so the active identity was never read) and an
unreadable active cache both fell through to "ours", printed "Stopping
active CBM sessions" and shut the daemon and every session behind it down.
Fix (user decision 2026-09-28, "unknown = foreign, keep it"): only a
positive match may drain. cli_activation_resolve_scope returns
CLI_ACTIVATION_SCOPE_ACTIVE only for an OK admission (exact identity, cache
included, or an empty group) or for a conflict whose cache fingerprint
equals this activation's. BUSY and a blank active cache now return the new
CLI_ACTIVATION_SCOPE_UNCONFIRMED, handled like a foreign cohort: no lock is
taken, no drain request is sent, the mutation proceeds, and the audit log
records "quiesce_skipped: active cohort ownership unconfirmed (...)". A
read-only status probe names the daemon that was left (pid, version) and
the notice says how to stop it: close its sessions, then run
`codebase-memory-mcp daemon stop`. The confirmed paths are unchanged: a
normal self-upgrade still drains its own daemon, a clearly foreign cache is
still left alone.
Proof (product binaries; private CBM_RUNTIME_DIR; an own permanent test
daemon started from the same build under a host HOME/cache; a sandbox
HOME/cache on the same runtime dir; the cohort maintenance gate held by a
second process, i.e. a busy session group):
- origin/main: "Stopping active CBM sessions and operations for
install...", the test daemon was stopped (daemon status: not running),
and the install still failed (rc=1 after 23 s, "could not be stopped
safely").
- this change: "Leaving the running CBM daemon untouched (pid 76713,
version dev): this install could not confirm that it serves the cache
this install targets (the session group was busy, so its cache identity
could not be read)." The test daemon kept running with the same pid and
the install completed (rc=0, 10 s).
A lifetime record whose cache field is zeroed is refused (UNSAFE) by both
builds with the daemon kept; that path never drained and is unchanged.
Tests (cli suite; fixture daemon in a private runtime dir; sandbox HOME):
- cli_install_foreign_home_unreadable_cohort_cache_keeps_host_daemon
(the new seam cbm_cli_set_activation_scope_cache_unreadable_for_test
blanks the observed active cache fingerprint)
- cli_install_foreign_home_busy_cohort_keeps_host_daemon (the maintenance
gate is held through the product's own lock-directory handle)
Both were RED 3x on origin/main for the right reason (the host daemon was
drained: ASSERT(host_serving)), are green with the fix, and turn RED again
when the fix is reverted. Controls stay green:
cli_install_into_host_namespace_still_drains_host_cohort (confirmed same
cache: drained, as before) and
cli_install_{skip_binary,binary}_into_foreign_home_never_drains_host_cohort
(clearly different cache: kept, as before).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The cache directory holds two internal SQLite stores next to the project
indexes: the user's settings (_config.db) and the cross-repo store
(_cross_repo.db). cbm_list_indexes, cbm_remove_indexes and
count_db_indexes matched every "*.db" there. As a result:
- install --reset-indexes and uninstall (when index deletion is confirmed)
deleted the user's config (every `config set` value silently reverted
to its default) and the cross-repo store along with the indexes;
- the "Found N index(es)" listing and count over-reported by up to two.
pass_cross_repo.c already excluded both stores by exact filename, but in
its own copy of the check. Centralise the rule in foundation/str_util:
cbm_is_internal_cache_db() (exact names, so a real project whose name
starts with "_" or contains "config" is still an index) and
cbm_is_project_index_db(). Every enumeration of the cache directory's
.db files (list, count, remove, cross-repo target scan) now calls it, and
cbm_config_open() builds its path from the same CBM_CONFIG_DB_FILENAME
constant.
Proof on a real isolated cache (one indexed Go repo, `config set
auto_index true`, a _cross_repo.db present):
before: "Found 3 index(es)", "Removed 3 index(es)", cache left empty,
`config get auto_index` -> false
after: "Found 1 index(es)", "Removed 1 index(es)", _config.db and
_cross_repo.db kept, `config get auto_index` -> true
Test: cli::cli_index_enumeration_skips_internal_cache_dbs (RED on main,
green with the fix, RED again on revert).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Upgrading from v0.10.8 to v0.11.0 on a machine with OpenCode reported all
three OpenCode subagent profiles as "preserved modified profile" plus an
op=agent_install error each, and the activation stopped ("one or more agent
configurations failed; the published/current executable was kept"), although
nobody had edited those files.
Root cause: #1933 (v0.11.0) added the tool_search / tool_search_regex
permissions to the rendered OpenCode profile. Install and uninstall recognise
a profile as ours only when it matches the current rendering or one of the
known released renderings (the other access mode, the v0.9.1-rc.1 Codex
shape, the pre-tier Verify file). The v0.10.8 OpenCode shape was not in that
set, so cbm_text_migrate_owned_document classified the untouched v0.10.8
files as user-modified. Reproduced end to end: install with the v0.10.8
release binary, then install with v0.11.0/main, into an isolated HOME; the
three profiles are 1729/1880/1910 bytes, the same sizes as in the report.
cbm_render_graph_profile_opencode_v0108() renders the pre-#1933 shape
(both access modes), and install/uninstall now list it as a released
rendering, so those files are upgraded in place and uninstall removes them
as owned. A profile the user actually edited is still preserved. The
released-list assembly that install and uninstall duplicated moves into one
helper, and the two legacy renderers share one implementation (cli.c drops
three raw free sites; the memory-core baseline is tightened 173 -> 170).
The Codex and OpenCode op=mcp_install errors in the same report do not
reproduce from a pristine v0.10.8 config and depend on the reporter's own
config.toml / opencode.json; they are not addressed here.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
`codebase-memory-mcp cli <tool> --flag value` builds the tool's JSON
arguments from typed flags. Array-typed parameters such as
`semantic_query` used to accept only repeated flags; the JSON spelling
shown in the MCP schema (`--semantic-query '["a","b"]'`) was stored as a
single opaque string element (#2150). `cli_add_typed` now expands such a
literal; this pins the edge cases of that contract so it cannot regress:
- a well-formed array of strings contributes its elements
- an explicit empty array `[]` contributes nothing (not the keyword "[]")
- a malformed literal stays one literal string, without an error
- non-string items are copied as typed values, not stringified
- literals and repeated flags mix, accumulating in argument order
- repeated literals accumulate
Signed-off-by: Hubert Tarnacki <hubert.tarnacki@gmail.com>
On 0.9.0, `update -y` in a non-interactive shell auto-confirmed "Delete
these indexes and continue with update?", removed every project index,
and only then failed at the ui/standard variant chooser: no new binary
and no indexes. The behaviour is already gone on main: 9ecabe0a moved
self-update out of the product, so a release `update` only hands off to
install.sh; install.sh runs `install -y --force`, which keeps existing
indexes unless the user passes the explicit --reset-indexes (#607); and
the variant chooser is gone, with --ui/--standard accepted as no-ops
(#1544). The only coverage of the hand-off was a Windows-only test that
checks the binary, not the indexes.
Add a portable regression test that drives the exact release dispatch
(activation seam off) with `update -y` and `update -y --standard`
against an isolated HOME/CBM_CACHE_DIR holding three .db indexes, and
asserts exit 0 with every index byte-for-byte intact. A generic -y must
never be read as consent to delete indexes. Re-injecting the 0.9.0
prompt-then-delete step into the hand-off turns it RED (kept == 0).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A cross-repo-intelligence run whose targets resolved to nothing but the
source project -- a self-only target list, or ["*"] in a store that holds
only the source -- returned status "success" with projects_scanned:0.
That output is indistinguishable from "these services share no routes",
which is exactly the silent-zero result #1133 reports. Worse, the run had
already passed the pre-write validation and went on to delete the source's
existing CROSS_* edges, with no target to rebuild them from.
Root cause: cbm_cross_repo_match_cancellable validated that every resolved
target exists but never checked that at least one resolved target differs
from the source; the self entry was only skipped later, inside the match
loop, after delete_cross_edges had run.
Count the non-self targets during validation and fail before any write
when there are none, with a new result flag (no_targets) so the MCP/CLI
frontend reports a specific error ("resolved zero target projects ...
existing cross-repo edges were left unchanged") and exits non-zero.
The other halves of #1133 are already resolved on main: the
--target-projects '["*"]' flag form has parsed as a JSON array since
1a342553 (guarded here by an index_repository-specific CLI test), and
cross-repo mode has run in-process ahead of the index supervisor since
0e00ef57, so a cross-repo request can no longer surface the contained
worker-crash envelope with a 0-byte log (worker-log survival: 87717b0a).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
CLI_USAGE described outer --json but did not mention tool-level
--format json. Add a help-only pointer that tools which accept format
support --format tree|json, and distinguish payload JSON from the full
MCP envelope. No new flags, env vars, or output-default changes.
Fixes#2102
Signed-off-by: Lowpower <3133734+Lowpower@users.noreply.github.com>
Nine findings from the 2026-09-16 graph-quality probe over the eleven bench corpora, each with a
test that fails without its fix and a before/after measurement on the real corpus.
Deterministic resolution (O9). Two indexes of one kernel tree differed in 5,821 IMPORTS and 632
CALLS edges. Three order/load dependencies carried it: the `#include` target was the first by-name
hit whose path ends with the include path — a bucket in worker merge order (include/linux/device.h
vs tools/virtio/linux/device.h); the registry broke suffix_match score ties by bucket position; the
LSP budget-share rule measured wall time. Include targets now prefer the File node, the least nested
path, then the lexicographic path; registry ties prefer the least nested QN, then the smaller QN; the
budget share is measured in thread CPU time like the budget itself. Two kernel indexes are now
identical (8,529,723 nodes, 1,974,940 CALLS, zero churn), as are two elasticsearch indexes.
Findability. search_graph's BM25 path honours the `label` filter and ranks an exact-name definition
above test methods that repeat the token (15/15 probe queries).
Coverage. The parse-coverage gap tree subtracts definitions the walker recovered inside an error
region (multi-line only); kernel/sched/core.c reports 476 flagged lines instead of 7,674.
Precision. Config-language callers (Makefile, CMake, YAML) no longer bind unique_name into code
(kernel Makefile -> C CALLS 195 -> 0). JS-family token names ({}, 1, (x: T) => U) are not
definitions; computed, quoted and escaped member keys stay (TypeScript 204 junk -> 0, 1,782 real
names kept). Names are cut at their first line (elasticsearch YAML 8,306 -> 0); a comment inside an
argument list is not an argument, so no Route node is named by a Java block comment; the wildcard
route `/*` is still a route. HTML/Vue/Svelte/Astro embedded member calls stay unbound.
Recall. A Python call through a self- or cls-rooted attribute keeps a specific unique_name member
target (Django +751, elasticsearch +3,288 CALLS, no new callee names).
Robustness. Edge properties never carry a split UTF-8 sequence (the 120-byte expression cut and the
JSON escaper both trim a partial code point). CLI array flags accept a JSON-array literal. Cypher
seeds a single-hop pattern from its selective end (kernel `MATCH (a)-[:CALLS]->(b) WHERE b.name =
'_printk'`: execution limit -> 7.9 s).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The macos-15-intel leg killed the cli suite at its 900s wall clock while
reporting 7537 passed, 0 failed. Two separate things were wrong.
The suite was misclassified. cli spends 497s of the 900s default on macos-14,
the FASTEST macOS runner, while macos-15-intel in the same matrix is 2.4-3.6x
slower on comparable suites (daemon_runtime 842s vs 349s, stack_overflow_b
277s vs 76s). 497s at that ratio cannot fit. daemon_runtime, at 842s on that
same runner, passes only because it was already in SLOW_SUITES. cli belongs
there too -- it is deterministic and simply large, which is the only thing that
list is allowed to mean.
The drain test was also waiting on a clock instead of on the system. Profiling
the suite per test (315 tests, 300s wall / 166s cpu) put
cli_install_into_host_namespace_still_drains_host_cohort at 42s wall for 19s of
cpu -- 23s idle, the largest single idle block, where the next worst was 11s.
The existing cli_install_force_quiesces_active_cohort_before_replacing_binary
drains a cohort too and idles 0.5s, which is what pointed at the difference.
The cost was the host_serving probe. Its generous budget exists for the
POSITIVE question -- is this daemon still up? -- where a slow reply on a loaded
runner must not be misread as drained; that fixed a real flake and is kept.
Asked in the negative it inverts: no reply is coming, so the full 15s is spent
establishing silence and ASSERT_FALSE is decided by the timeout expiring rather
than by the daemon's behaviour. The drain is already proven positively in that
test -- install returns 0 only after the activation completed, and the host
child exits CLI_SCOPE_HOST_DRAINED -- so the probe only has to confirm it.
Also shortens the drained child's teardown budget, which retried service_free /
lease_release / manager_free against 10s on a path where the activation had
already torn the service down. Behaviour at the deadline is unchanged; it is
reached sooner when nothing is wedged. Measured at only -3s on its own, kept
because it is correct and free.
Local, same machine, ASan+UBSan: the drain test 42.0s -> 25.8s (-38%), the
suite 300s -> 279s, 315 passed before and after -- no test removed, no
assertion weakened. PR #2188, which runs the same leg without these two tests,
passed macos-15-intel in 37m34s, so the suite was not already over budget on
main.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
cbm_remove_indexes unlinked each *.db and *.db.tmp but never the SQLite
sidecars (<db>-wal/-shm/-journal), so `cbm update`, `cbm uninstall`, and
`install --reset-indexes` (the four callers of the single deletion
chokepoint) left orphan -wal/-shm files behind. Call the existing
wide-path-safe cbm_remove_db_sidecars() helper on both the live and staged
db paths after the .db unlink; it is idempotent and ENOENT-tolerant, so an
orphan -wal that outlived its .db is still cleaned. Sidecars are not
indexes, so the removed-count is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The four namespace-scope tests fork a real host daemon and, in the parent,
waited on it with two UNBOUNDED calls: read() for the child's one-byte
readiness signal in cli_scope_fixture_start, and waitpid(host, 0) in
cli_scope_fixture_finish. If the forked child stalls before it can signal —
a fork-time sanitizer allocator stall is the classic cause, the same hazard
the earlier posix_spawn work addressed — the parent blocks forever and the
WHOLE cli suite hangs to the CI wall-clock kill (the observed rc=124 at 900 s
on the ubuntu-24.04-arm gcc shard, which passed on every other #2115 shard).
Attribution: the production drain path is already bounded (DRAIN 15 s, CONTROL
2 s) and a foreign-namespace install takes the non-blocking scope probe and
skips draining entirely, so this is not a production deadlock — it is a
test-fixture determinism gap, amplified by ASan on a loaded arm runner.
Fix, test-only:
- cli_scope_wait_ready(): poll the ready pipe to a generous, bounded deadline
(90 s, comfortably past the child's own 45 s cohort-admission deadline plus
service start), so a wedged child yields a clean ASSERT_TRUE(ready) failure
instead of an unbounded read().
- cli_scope_reap_host(): reap with WNOHANG to a bound (60 s), then SIGKILL and
reap, so a wedged child is force-killed rather than hanging finish(); a
killed child leaves host_exit == -1, which fails the caller's assertion
cleanly. This also prevents a stuck child from holding cohort locks that
would poison later tests.
- cli_scope_host_serving(): raise the status probe deadline from 5 s to 15 s so
a slow-but-alive daemon on a loaded runner is not misread as "drained" (the
flaky ASSERT(host_serving) failure this fixture showed); it still fails
cleanly when the daemon is genuinely gone or reports stopping.
None of these decides a healthy test — the child signals in well under a
second on a healthy runner; the bounds only convert an otherwise unbounded
hang into a deterministic pass/fail.
The scoping contract still binds: with cli_activation_resolve_scope forced to
always return ACTIVE, the three "must-not-drain" tests go RED at
ASSERT(host_serving) and the host-namespace "must-drain" test stays GREEN.
cli suite: 313 passed locally (macOS, twice); 30-round pre-fix and 8-round
post-fix stress of the four tests were clean; a fault-injected never-ready
child fails all four cleanly in ~37 s with no suite hang.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
cli_fd_capture_begin/end use POSIX fd redirection (dup/dup2/tmpfile) and
are called only by the uninstall tests, which are themselves #ifndef
_WIN32. Defined at top level, the helpers were compiled but unused on
Windows, tripping -Werror,-Wunused-function on the test-windows CLANG64
leg. Wrap the struct and both helpers in #ifndef _WIN32 to match their
callers. Test-only; no production change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
On Windows, install registers the install directory in the persistent
HKCU\Environment\Path, but uninstall never removed it — so every
install/uninstall cycle left its entry behind and the user PATH grew
without bound (a real test host reached ~8000 chars, emptying the
effective PATH and breaking tooling).
The reported non-ASCII "mojibake" is historical, not a current-code bug.
Both install paths already write the value through the UTF-16 registry
API: install.ps1 (shipped in each release archive) runs `cbm install`,
which calls RegSetValueExW(L"Path", ...) — git history shows no ANSI
(RegSetValueExA / SetEnvironmentVariableA) predecessor — and
setup-windows.ps1 writes via .NET [Environment]::SetEnvironmentVariable
(Unicode) with an ASCII-only install dir. The only path that handles a
non-ASCII directory is the wide-correct C path, so current code cannot
produce a garbled entry; the garbage the reporter saw was left by an
older binary. No code change is needed for the encoding itself.
The real defect is the missing uninstall-side removal. This adds
cli_remove_windows_user_path(), the counterpart to
cli_ensure_windows_user_path(): it opens the same key, walks the
';'-separated segments, and drops exactly the ones equal to the install
dir using the same case- and trailing-separator-insensitive comparison
the install `present` scan uses, so it removes precisely what install
would have de-duplicated. Every other segment is preserved
byte-for-byte, the value kind (REG_SZ / REG_EXPAND_SZ) is preserved, and
a registry error is a warning rather than a hard failure so a user is
never blocked from finishing an uninstall over a cosmetic PATH edit.
Removing the entry on uninstall also stops accumulation on reinstall: a
garbled historical entry defeats the install de-dup and gets re-added,
so taking our own entry back out keeps the value bounded.
Testing:
- tests/test_cli.c: a hermetic Windows unit test drives the append and
remove logic against a GUID-scoped scratch key via the existing
CBM_TEST_WINDOWS_USER_PATH_RUN_ID seam, never the developer's live
HKCU\Environment\Path. A non-ASCII install dir doubles as a mojibake
guard (a broken UTF-8->UTF-16 round-trip would fail the match and leak
a duplicate). It asserts append, idempotent reinstall, exact removal
from the tail and the middle, idempotent removal, and byte-for-byte
survival of unrelated entries.
- tests/test_smoke_fixture_contract.sh: a macOS-runnable source contract
that fails when the removal is absent and passes with it, proving the
wiring binds (verified RED-on-revert).
The Windows-only registry code is #ifdef _WIN32; its compile and the
behavioral unit test are verified by CI's Windows leg (the local Windows
VM is down). Pre-existing mojibaked entries from old binaries are a
separate one-time cleanup, out of scope here, and setup-windows.ps1's
own lack of an uninstall mode is noted as a possible follow-up.
Fixes#2117
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
install wrote only part of the OpenHands config. Register the server globally
under ~/.openhands/settings.json -> mcp_config {transport:stdio, command, enabled}
(owned-entry, unrelated content preserved), and add "codebase-memory-mcp" to
each ~/.openhands/agent-profiles/<p>.json mcp_server_refs array (null -> [it],
existing -> appended once). uninstall removes exactly what it added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The rendezvous directory the CLI's activation guard talks to is per OS
account (service.h), not per HOME/CBM_CACHE_DIR: every daemon owned by
one user meets at the same endpoint, and only the cache fingerprint
carried in the cohort identity separates one HOME/CBM_CACHE_DIR
namespace from another. `cli_activation_production_reserve` used
`cbm_version_cohort_reserve_for_mutation`, which treats any lifetime-
lock holder at that shared endpoint as the daemon this activation must
drain, with no cache-fingerprint awareness at all. An `install` run
against a second HOME (a sandbox, a second profile, `--skip-binary`
into an alternate CBM_CACHE_DIR) therefore reached the LIVE host
daemon at the shared endpoint and drained it — disconnecting every one
of the host's real MCP clients — even though nothing the sandboxed
install touched belonged to the host's cache namespace.
The fix adds `cli_activation_resolve_scope`, a non-blocking probe via
the already cache-fingerprint-aware `cbm_version_cohort_acquire`, run
before the blind drain path. It distinguishes three cases from the
resolved target of THIS activation:
- nothing to quiesce at all: a `--skip-binary` install that also
resets no index publishes nothing, so no session anywhere needs to
stop (`quiesce_required` on `cli_activation_guard_scoped`, plumbed
from `cbm_cmd_install`'s own binary/index-reset decision);
- the active cohort's cache fingerprint doesn't match this
activation's target cache: a foreign namespace, left untouched;
- same cache fingerprint (or no active cohort): this activation
really does replace that daemon, so the existing drain/quiesce
path runs unchanged, including the barrier wait when another
activation already holds maintenance.
Verified against a real forked daemon (a live runtime service with one
committed client) standing in for the host: an install into a foreign
HOME/CBM_CACHE_DIR (`--skip-binary` and a full binary install) leaves
that daemon serving with its client still attached, while an install
that targets the host's own namespace still drains it and the
activation audit still names the client it disconnected.
New/updated tests in tests/test_cli.c (cli suite):
- cli_install_skip_binary_into_foreign_home_never_drains_host_cohort
- cli_install_binary_into_foreign_home_never_drains_host_cohort
- cli_install_into_host_namespace_still_drains_host_cohort (regression)
- cli_install_skip_binary_unchanged_in_host_namespace_quiesces_nothing
- cli_activation_quiesce_does_not_wait_on_bootstrap_startup updated to
carry a real cache fingerprint for its participant, now that scope
is fingerprint-gated
src/mcp/index_supervisor.{c,h}: CBM_CLI_ENABLE_TEST_API-gated setter
so the fixture's forked daemon can carry the same build fingerprint
the guard captures from the supervisor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The hook resolves a payload cwd inside a linked git worktree to that
worktree own indexed project (and a subdir of it), never a sibling
checkout or nothing, and an unindexed worktree gets a deliberate
"no indexed graph project matched" notice rather than a silent 0 bytes.
Main-checkout resolution is unchanged. These regression tests use a real
linked worktree and lock the payload-cwd -> project invariant.
Fixes#1752
Signed-off-by: Brian McNaboe <bmcnaboe@gmail.com>
Review of the previous head found the global rule unsound for one caller:
cbm_mkdir_p also creates <repo_path>/.codebase-memory inside the checkout
being indexed, and git creates symlinks owned by whoever cloned, so a
repository shipping `.codebase-memory -> ~/.ssh` would have been followed
by the artifact writer. "User-owned" says nothing about "user-intended"
once the path root is a checkout.
cbm_mkdir_p keeps the strict walk (root-owned links only). The new
cbm_mkdir_p_ex(path, mode, policy) with CBM_MKDIR_FOLLOW_OWNED also
follows a symlink owned by the invoking user, and only callers whose
paths are rooted in the user's own configuration opt in: the installer's
agent roots, skills, instruction files and hook directories, the
JSON-like config editor's parent creation, the cache root, <cache>/logs,
the UI config directory and the DB parent. The artifact directory inside
the checkout and the ~/.local/bin creations stay strict; the activation
transaction re-walks the latter with its own no-follow policy anyway.
What a follow lands on is now checked too: the opened target must be a
directory owned by root or the invoking user and not world-writable
unless sticky, so a link swapped between the fstatat and the openat
cannot steer the walk into a directory an attacker controls or one where
anyone can pre-plant entries. This applies to root-owned links as well;
macOS /private/tmp is root-owned and sticky and still passes. Refusal
restores the first openat's errno.
Tests: the plain walk refuses an own link that the opted-in walk
follows; targets that are world-writable without the sticky bit are
refused; as root, a foreign-owned target and a user-owned link are both
refused, each followed by restoring ownership and walking again; the
identical `.codebase-memory -> <user-owned dir>` link is refused by the
plain walk and followed once opted in; cbm_artifact_export refuses a
checked-in artifact-dir link and leaves its target empty; the installer
test gains the euid-0 leg with home, link and target owned by a non-root
uid. Under euid 0 the tests demote their links to a non-root owner, since
root-owned links are trusted by both walks.
Refs #1722
Signed-off-by: TheStranjer <791672+TheStranjer@users.noreply.github.com>
Every config editor (JSON-like, TOML, YAML, text) opens with O_NOFOLLOW
and refuses symlinks, so a link planted at ~/.cursor/mcp.json cannot
point a privileged writer at another file. Users keep exactly those
files as symlinks into a dotfiles checkout; the blanket refusal made
install and uninstall fail on them and, before the previous commit,
turned uninstall into data loss (#1954).
Decision C: a symlink the INVOKING user owns, resolving to a regular
file the same user owns, is followed — as an explicit opt-in of the
agent-configuration writers only. cbm_install_agent_configs and the
uninstall activation register the user's configuration roots (HOME,
XDG_CONFIG_HOME) for the duration of their work; a link whose own
directory is outside every registered root, or any link seen by another
editor caller, stays refused, so repository paths never follow.
One shared helper (src/cli/config_edit_path.c) runs the race-safe
sequence: lstat(link) → owner == invoking uid (a root process follows
root-owned links only) → realpath → the target's parent directory
opened as a dirfd (O_DIRECTORY|O_NOFOLLOW; owned by the invoking user
or root, not writable by others unless sticky) → openat(dirfd, base,
O_NOFOLLOW) → fstat: regular file, same owner, link count 1, and the
same (dev, ino) as stat(realpath). Each editor reads through that
validated descriptor, stages its temp file with openat(dirfd, …,
O_CREAT|O_EXCL|O_NOFOLLOW), runs its pre-publish snapshot comparison
against the resolved path, and commits with renameat on the pinned
dirfd, so the link itself is never replaced. Every other link is refused
with an observable reason (surfaced in the uninstall diagnostics):
another owner, root over a non-root link, dangling, a directory or
special target, a hard-linked or swapped target, a foreign or
world-writable parent. Whether the target must also stay under a root
is a pending user decision behind CBM_CONFIG_EDIT_TARGET_UNDER_ROOT
(default: anywhere the user owns). Windows never follows reparse points.
Tests: each editor suite gains a same-owner link edited in place under
the opt-in (link preserved and still pointing at the same target, temp
file staged beside the target, bytes identical to the same edit on a
plain file, no stray temp files) and keeps its refusal cases; the
JSON-like suite adds no-opt-in, outside-root, dangling, directory,
identity-skew and world-writable-parent refusals. The foreign-owner and
root-run refusals move the observer through a test seam (a test cannot
create a foreign-owned link without root; a real root run demotes the
link first). test_cli.c drives `uninstall -y` end to end through a
symlinked ~/.cursor/mcp.json.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A single agent configuration the editors refuse to touch (the reporter's
~/.cursor/mcp.json was a symlink into a dotfiles checkout) aborted the
uninstall activation BEFORE index and binary removal. `uninstall -y`
exited 1 and left a 300 MB executable plus the whole cache behind — the
data-loss half of #1954 — while the transcript still read "removed".
Agent-config failures are now collected instead of gating: every cleanup
runs, the indexes and the executable go, and the command ends with one
list of the files that could not be cleaned (agent, operation, path,
observed target) and a non-zero exit. The existing per-error stderr line
is unchanged.
Two tests encoded the old hostage contract ("binary must survive failed
cleanup") and move to the new one. The new test drives `uninstall -y`
against a sandbox HOME with a dangling ~/.cursor/mcp.json link and
asserts binary + index gone, exit non-zero, and the file named on
stderr; on main it fails at ASSERT(binary_gone). (#1954)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The markerless stale-rendezvous recovery test reset the activation runtime
parent to NULL instead of restoring g_cli_suite_runtime_parent, so every
later cli test ran the activation probe against the developer's real
/tmp/cbm-daemon-<uid>. Restore the suite value per the contract at
tests/test_cli.c:61-65 and the sibling at :1310.
Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
cli_uninstall_quiesces_active_cohort_before_removing_binary_and_index moved
HOME and CBM_CACHE_DIR into a temporary directory but left PATH alone. Agent
detection reads PATH first, so a real agent binary on the developer's machine
was found even though HOME pointed at the empty fixture. Uninstall then tried
to edit that agent's config file inside the fixture, failed because the fixture
never created one, and stopped before removing the binary and the index. The
test measures exactly that removal, so it failed.
On this machine the agent is Goose, at /Users/<me>/go/bin/goose. Same tree and
same built runner, only PATH differs:
goose on PATH 291 passed, 1 failed
goose off PATH 292 passed, 0 failed
With this change the suite reports 292 passed, 0 failed while goose is still on
PATH, so the fix is the redirect and not a quieter environment.
This only stops the suite from depending on what the developer has installed.
The abort it exposed is a real defect and is reported in #1954: one agent config
that cannot be edited stops teardown for every other agent and for the
executable and indexes.
Eleven other tests that call cli_activation_save_env leave PATH open the same
way. They pass today, so I left them alone rather than change what they
exercise without evidence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Joshua Richter <jrichter5781@gmail.com>
With the library default back at INFO, the branch's log and progress-sink
tests were still handing the process a WARN level on their way out —
main's `/* restore */` lines had been rewritten to WARN — so every suite
that ran after them in the same process lost its INFO records (the CI
shard that runs `log` before `index_format` is exactly that order).
test_log.c keeps main's tests and restores verbatim; the two branch
tests (the CBM_LOG_LEVEL opt-in and the startup policy) save the level on
entry and hand it back. The progress-sink tests pin the quiet level they
exercise explicitly and restore it, instead of assuming the process
entered at WARN.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Fix-forward after rebasing onto main (545 commits, 26 conflicted-file
resolutions). Where main had shipped a behaviour in the meantime, main
wins; the PR keeps its output contract on top of it:
- search_code: the scan runs under main's supervised, deadline- and
output-bounded runner and feeds the branch's streaming classifier from
the runner's output file; the POSIX wrappers map grep's no-match to 0,
so every non-zero exit fails closed (no exit-1 special case).
- trace_path: strategy/confidence precede args in header and rows (#1542),
in both the tree table and the json legs.
- cypher: relationship expansion is never capped (a cap falsified
aggregates) and unlabeled MATCH scans every candidate; `truncated` is
raised only by a real ceiling — a clamped hop range is probed one hop
beyond the cap and reports truncated only when a candidate exists there.
The plain BFS and the trail BFS both probe one row past max_results so
saturation is observable instead of silent.
- search_code full mode and the final json use main's U+FFFD sanitizer
instead of '?' replacement.
- search_graph json keeps an empty `groups` array for semantic-only calls;
list_projects json echoes offset/limit next to next_offset and accepts
main's include_details spelling for the stats projection.
- discovery budget: main added get_file_outline, compare_graphs, manage_adr
set_sections and the debug/include_details parameters; their descriptions
are trimmed to the lean style and the ceiling moves from 15 to 18 KiB.
Tests that asserted a JSON default now request format:"json"; the
fail-closed search test uses an unreadable regular file (a missing or
non-regular indexed path is deliberately not a scan operand on main); the
non-regular fixture gives its real sources File nodes, as every indexed
project has; list_projects total counts listable projects (the 0-byte
ghost is never a row, so it is not a page either).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>