Commit Graph
2276 Commits
Author SHA1 Message Date
Martin Vogel c0bd4bbf8d Merge pull request #1603 from DeusData/fix/vt-evidence-marker
fix(ci): publish VT evidence under the marker the gate actually writes
v0.10.4
2026-08-14 06:11:37 +02:00
Martin Vogel 07240cf45c fix(ci): publish VT evidence under the marker the gate actually writes
The v0.10.4 release (run 31755436916) failed at the very last step of `verify`,
after all 69 other jobs had passed — full test matrix, all eight builds, smoke
and soak:

  publish-vt-evidence: wrong evidence marker in binaries/vt-results.tsv

The results format was bumped to `cbm-virustotal-results-v2` in #1596, but
publish-vt-evidence.sh still demanded `-v1`. It was the only straggler: the
gate, the selector, the notes renderer and all three contract fixtures were
already on v2.

It survived because that PR had removed the script's only caller, leaving it
dead code that nothing exercised. Restoring the full-surface scan restored the
caller too, and the stale expectation surfaced in the worst possible place — at
the end of a real release rather than in a dry-run, since `verify` does not run
in dry-runs.

Also adds the guard that would have caught it: every marker publish-vt-evidence.sh
validates must be one the gate actually writes. Revert-checked — reintroducing
v1 fails with "publisher expects marker never written by the gate:
cbm-virustotal-results-v1".

The scan itself passed. This is purely the evidence-publishing step.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-14 04:40:07 +02:00
Martin Vogel d150ebe4fc Break-glass merge PR #1596 2026-08-14 01:51:40 +02:00
Martin Vogel 7b4533b67d fix(ci): finish the three-candidate wiring and restore full-surface VT scanning
Two things, both found by the dry-run and by sweeping what it exposed.

1. The three-candidate change was incomplete. The dry-run failed at
   stage-release-candidates.py, which carries its OWN copy of the transform
   validation that the previous commit only fixed in the selector:

     stage-release-candidates: candidate linux-amd64/debug-stripped has
     invalid transform: 'strip-debug'

   Sweeping for that assumption found it in six places, not two:
   stage-release-candidates.py, select-release-candidates.py,
   verify-release-selection.py, append-vt-notes.sh and two contract-test
   fixtures — as hardcoded 16s, `len(TARGETS) * 2`, two-entry VARIANTS tuples
   and two-key truth tables. All are now derived from len(VARIANTS).

   Field prefixes needed care: the variant NAME keeps its hyphen because it is
   the on-disk directory, while the evidence columns use underscores, so
   `debug-stripped` reads `debug_stripped_sha256`. Every lookup now goes through
   an explicit FIELD_KEY map instead of interpolating the variant directly.

   The selection contract test now covers the truth table EXHAUSTIVELY: three
   variants x two tolerated classifications is exactly eight combinations, and
   there are exactly eight targets, so every case is exercised once.

2. Full-surface VirusTotal scanning is restored. This PR had moved scanning
   upstream to the candidates and deleted the post-package pass, which silently
   narrowed coverage from everything we ship to executables only. The 42 runtime
   files across the 14 containers — install.sh, install.ps1, LICENSE,
   THIRD_PARTY_NOTICES.md, the MCPB manifest.json and the unpacked UI assets —
   were still extracted, structurally verified and strings-audited, but no
   longer scanned at all. install.sh and install.ps1 are the highest-consequence
   non-executable bytes we publish; users pipe them straight into a shell.

   The verify job scans every extracted object again, under the same policy.
   Re-submitting the selected executables alongside them is close to free
   because VirusTotal is content-addressed and answers for identical bytes from
   its own record — the same property that made the analysis-id equality check
   untenable two commits ago.

   The gate-chain contract asserted the opposite ("duplicate post-package
   VirusTotal path remains"). That assertion is inverted: the pass is required,
   and it is not a duplicate, since it covers a strictly larger set. README and
   SECURITY.md updated from "archive containers are checksummed rather than
   redundantly rescanned" to state the full covered surface.

All five release/VT contract tests pass.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-14 00:21:31 +02:00
Martin Vogel 98d7dbab01 feat(ci): add a third release candidate (debug-stripped) as an extra VT draw
Release evidence from run 31744302624 shows the tolerated Microsoft `!ml`
verdict is close to a coin flip per byte image rather than a property of the
code. Across the eight targets the stripped and unstripped candidates of the
SAME linker output disagreed on four, and in both directions:

  linux-amd64    stripped microsoft-ml   unstripped clean
  darwin-arm64   stripped clean          unstripped microsoft-ml
  linux-arm64    stripped clean          unstripped microsoft-ml

If the classifier were keying on something intrinsic to our code the siblings
would agree; they do not. So each variant is close to an independent draw, and
5 of 16 candidates drew the flag.

Two draws is not always enough. On that run linux-amd64-portable came back
microsoft-ml on BOTH candidates, leaving no clean binary to ship for that
target. A third independent draw at a ~31% observed per-candidate hit rate takes
the both/all-flagged case from roughly 1-in-10 per target to roughly 1-in-30.

The third candidate is `--strip-debug` (Apple: `-S`): debug information removed,
symbol table kept. Behaviourally identical to the other two — same linker
output, only metadata differs — but a distinct byte image, which is all
VirusTotal needs to scan it as its own file. Verified on the real v0.10.4
candidates: linux-amd64 gives three distinct hashes (294,634,656 /
294,623,208 / 293,746,096 bytes) and darwin-arm64 likewise, with the ad-hoc
signature verifying after strip.

Selection is unchanged in spirit and now ordered: smallest artifact first
(stripped, debug-stripped, unstripped), take the first CLEAN one, and only if
every candidate drew the tolerated verdict ship the smallest flagged one. A
hard verdict on any candidate still blocks the release before selection.

Cost is 24 objects per release instead of 16.

Derivation enforces that all three hashes differ — identical candidates would be
one draw wearing three hats, and the selector would believe it had alternatives
it does not have. Public claims in README, SECURITY.md and docs/index.html
updated from "both stripped and unstripped" to the three candidates.

All three release contract tests pass, including the native derivation test
which exercises the real strip and codesign path on this host.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 23:52:21 +02:00
Martin Vogel db26e9615e fix(ci): stop gating the release on how many VirusTotal engines answered
The build+VT dry-run (run 31744302624) blocked all eight targets on:

  BLOCKED: objects/3eb486a4... completed with only 48/75 decisive engines (< 50)

That object is the windows-arm64 STRIPPED candidate with ZERO detections. It
was classified "hard" — the same bucket as a real malware verdict — purely
because 48 engines answered instead of 50. Its own sibling scanned clean with
66.

How many engines return a decisive result is VirusTotal fleet availability on
the day, not a property of our binary. These artifacts are ~300 MB and many
engines skip or time out at that size, so the count sits near the floor and
moves run to run: on this run one object landed at 48 and the other fifteen
spread 59-68. A floor there makes shipping a lottery decided by someone else's
infrastructure, which is exactly what our CI-determinism rule forbids, and it is
not something we can influence by fixing anything on our side.

Classification now depends only on what engines FOUND:
  hard        - two or more engines, any non-Microsoft engine, any label that is
                not `!ml`, or anything suspicious. Still blocks the release.
  microsoft-ml- exactly one Microsoft `!ml`. Tolerated and disclosed.
  clean       - nothing flagged.

The decisive-engine count is still recorded per candidate in the results
manifest and printed as a NOTE when it is below the reference value, so the
evidence is unchanged; only its power to block is gone. The selector keeps
rejecting an incoherent response (total < completed).

Public claims updated to match in README.md, SECURITY.md and docs/index.html —
all three promised "at least 50 decisive engines" as a publication requirement,
which would no longer have been true.

Contract test updated: a clean verdict now stands regardless of engine count,
and the below-reference NOTE must still be reported. All three release contract
tests pass (vt-gate-policy, release-gate-chain, candidate-derivation).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 23:45:06 +02:00
Martin Vogel 4cf9579833 docs(ci): state the real reason the VT verdict binds to bytes, not to an id
The previous commit framed accepting VirusTotal's own analysis id as giving up
freshness. That was the wrong way round.

VirusTotal recognising our bytes is desirable: the evidence this pipeline
publishes is the hash-keyed file report — append-vt-notes.sh asserts the URL is
exactly .../gui/file/<sha256>/detection — so the report we gate on is the same
one a reader gets by looking that SHA-256 up themselves. Requiring a freshly
minted analysis id would have contradicted the evidence we publish next to it,
and made our verdict unreproducible by anyone outside the release job.

Comment-only; no behaviour change. The build+VT dry-run on 094929fd exercises
identical logic.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 23:07:09 +02:00
Martin Vogel 094929fddc fix(ci): bind the VirusTotal verdict to the bytes, not to the analysis id
The dry-run for this branch (run 31729540899) failed in `select-package`, the
new job, after every build had completed and before smoke — the exact shape of
failure that would have taken a release down:

  BLOCKED: VirusTotal response analysis id does not match the submitted
  analysis: objects/70fbbc37...

That object is the linux-arm64 UNSTRIPPED candidate, 295,476,856 bytes.
VirusTotal is content-addressed, and for bytes it already holds it may answer
with its own canonical analysis rather than the one this upload created. A large
release binary is exactly the artifact most likely to be already on file, so
requiring the ids to be equal would have recurred on most releases rather than
being a one-off.

The equality check was guarding the right idea at the wrong level. What has to
hold is that a verdict describes THESE bytes, and parse_completed already
enforces that a few lines below against meta.file_info.sha256 and
file_info.size — a strictly stronger binding than the id. It is also what keeps
a tuple's two candidates apart: stripped and unstripped differ in hash by
construction (70fbbc37... vs 28b3890f... on linux-arm64), so neither can be read
as the other whatever ids VirusTotal hands out. The wrong-hash and wrong-size
contract cases pin that and still block.

The property deliberately given up is freshness: an older cached analysis of
identical bytes is now accepted. For identical bytes that is the same content
risk, and it buys a gate that is deterministic instead of one that fails
whenever VirusTotal recognises our own binary. The submitted id is still
recorded in the results manifest as evidence.

The selection policy is unchanged and needs no change: stripped clean ships
stripped; stripped microsoft-ml with a clean unstripped ships the clean one;
both microsoft-ml ships stripped; a hard verdict on either sibling still blocks
the release for analysis.

Contract test updated to assert the passing direction, and revert-checked: with
the fix reverted it fails with the production message, with it applied it passes.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 23:03:38 +02:00
Martin Vogel 973cde86a3 Merge pull request #1595 from DeusData/fix/spawn-sanitizer-define
fix(subprocess+tsan): widen the spawn-retry window where it was silently disabled
2026-08-13 21:22:43 +02:00
Martin Vogel b8c94d8276 docs(subprocess): correct the retry comments the budget change left behind
Three comments still described the original design: "Three attempts over ~30ms
total" (it has been six attempts over ~630ms since the backoff landed), and a
hard-coded list of waits plus a "~0.6s ceiling" rationale that is now only the
unsanitized half of the story.

Rewritten so the prose derives from CBM_SPAWN_RETRY_ATTEMPTS instead of
restating it, which is what let the first one go stale. Comment-only; no
behaviour change. subprocess suite 31/0/0, clang-format clean.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 19:37:51 +02:00
Martin Vogel 3904e59372 ci: select release binaries before smoke testing
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 19:16:19 +02:00
Martin Vogel 0a163d4f75 build(tsan): define CBM_SANITIZED_BUILD on the ThreadSanitizer leg
The previous commit widened the spawn-retry budget for sanitized builds, and it
did nothing on TSan — the leg it was written for. `subprocess_run_spawn_failure`
failed again on the very PR that was meant to fix it.

CBM_SANITIZED_BUILD comes from SANITIZED_DEFINE, which keys off $(SANITIZE).
TSan does not use that variable — it has its own TSAN_SANITIZE — and CFLAGS_TSAN
never included SANITIZED_DEFINE. So the macro was undefined on that leg and
every sanitized-budget branch compiled to its NATIVE value while running an
instrumented, several-times-slower binary.

The comment above SANITIZED_DEFINE already describes this exact failure for
trap-UBSan: "the build system is the single source of truth for is this binary
instrumented; compiler-specific probes miss clang's feature-check spelling and
every non-ASan sanitizer". That lesson was recorded and the TSan leg was never
wired up to it. Nor would compiler probes have saved this: clang spells thread
instrumentation __has_feature(thread_sanitizer), not __SANITIZE_THREAD__.

CFLAGS_TSAN now defines it unconditionally, which is honest — that flag set
exists solely to build an instrumented binary.

Checked the neighbours: MSan (scripts/msan.sh passes SANITIZE=) and the diag
lane (passes SANITIZE= too) both already get the define. TSan was the only gap.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 19:14:17 +02:00
Martin Vogel 0d481573c8 Merge pull request #1590 from DeusData/fix/orphan-marker-selfheal
fix(config): self-heal an orphan managed marker on removal (#1558)
2026-08-13 19:10:17 +02:00
Martin Vogel 5a7c71d807 Merge pull request #1587 from DeusData/fix/ui-config-keys
fix(cli): make the UI listener switch reachable from `config` (#1558)
2026-08-13 18:46:52 +02:00
Martin Vogel a92b6be9e3 fix(subprocess): give sanitized builds a wider spawn-retry window
The exponential backoff shipped in v0.10.3 (10/20/40/80/160/320ms, ~0.6s) fixed
the ordinary case. `subprocess_run_spawn_failure` then kept failing on
`test-tsan (macos-14)` — twice on the same SHA, on a PR whose entire diff was a
shell contract plus one line in test.sh, so causation was impossible.

ThreadSanitizer runs several times slower and holds far more process state, so
the pressure window it creates is simply longer than 0.6s. The budget was right
for a normal build and short for a sanitized one.

Raising it for everyone would be the wrong fix: an unsanitized machine that is
genuinely out of capacity should fail fast rather than hang for seconds. So the
extra patience is scoped to the builds that need it — three more doublings,
about 5s, under CBM_SANITIZED_BUILD or __SANITIZE_*__ only. Same shape as the
daemon announce backstop in test_daemon_frontend.c, and for the same reason.

Also fixes a bug in the backoff itself: the shift was capped at a hard-coded 6,
so with a budget of 9 the final three waits would have flattened to 320ms each
instead of continuing to double — the extra attempts would have bought a third
of the intended time. The cap now follows the budget.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 18:40:46 +02:00
Martin Vogel 7abdd8dc50 Merge pull request #1589 from DeusData/fix/destructive-ordering-contract
test(install): pin that index deletion runs after every abortable step (#1558)
2026-08-13 18:40:27 +02:00
Martin Vogel ecee22ab02 Merge pull request #1592 from DeusData/fix/pin-mcp-publisher-download
ci(release): pin and verify the mcp-publisher download on every platform
2026-08-13 17:42:35 +02:00
Martin Vogel a975f6b61f fix(config): self-heal an orphan managed marker on removal (#1558)
A duplicated install left a Codex config.toml carrying a CLOSING
`# <<< codebase-memory-mcp SessionStart <<<` with no opener. Every later
install then failed that client with op=legacy_hook_cleanup and aborted the
whole activation, so one bad file made the installer permanently unusable for
that client. Reported by @PsyTech-Rob.

We are the only writer of these markers, which makes the imbalance our own
residue. Refusing to touch a file we cannot parse is the right default in
general — it is the wrong default for a mess we made.

Removal now strips the stray line. The bounds come from the marker scan that
already ran, so nothing is guessed and no surrounding content is touched; the
user's own keys either side survive verbatim.

A WRITE still refuses on an imbalance, deliberately: with only one marker there
is no defensible region to replace, and guessing could destroy user content.
That asymmetry is the whole design — heal where the answer is unambiguous,
refuse where it is not.

Both orientations are tested (orphan opener, orphan closer) and revert-checked:
without the fix each returns the same -1 that aborted the reporter's install.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 17:24:51 +02:00
Martin Vogel a23db3c27e Merge pull request #1591 from DeusData/fix/silent-client-startup-exit
fix(mcp): never let a client-path startup failure die silently (#1582)
2026-08-13 16:53:58 +02:00
Martin Vogel 811ecd3f2c fix(cli): make the UI listener switch reachable from config (#1558)
`ui_enabled` governs a loopback HTTP listener on 127.0.0.1:9749, and the only
way to turn it off was hand-editing ~/.cache/codebase-memory-mcp/config.json.
The key was absent from CONFIG_KEYS, so `config list` could not show it and
`config set` rejected it outright. A reporter spent two debugging sessions
finding the switch, and described the surface as looking impossible to turn off.

A network surface a user cannot discover how to disable is not acceptable
whatever its default, and "documented in the source" is not discoverable.

ui_enabled and ui_port now appear in `config list`, and get/set/reset work on
them. They live in a separate file (cbm_ui_config_load/save) rather than the
key-value store the other keys use, so this routes rather than merely listing
them — set validates (true/false; a port in 1..65535) and reports that the
daemon must restart for the change to take effect.

The test asserts DISCOVERABILITY rather than the current values: both keys must
be present in the table that `config list` and `config set` walk, because a key
missing there is a key the user can neither find nor change.

Reported by @PsyTech-Rob.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 16:48:43 +02:00
Martin Vogel 9fbcd726fd Merge pull request #1588 from DeusData/fix/refusal-detail-propagation
fix(cli): show the check that refused, instead of pointing at nothing (#1537)
2026-08-13 16:47:54 +02:00
Martin Vogel bb2b990338 Merge pull request #1586 from DeusData/feat/install-clients-selector
feat(cli): let install target chosen clients (#1558)
2026-08-13 16:04:52 +02:00
Martin VogelandAndrew Hundt 70b2994425 ci(release): verify every platform from one pinned checksums file
The previous commit pinned the linux/amd64 asset hash directly, which
verified the asset this job needs but hard-coded the platform: the
uname-based selection was replaced by a fixed filename, so moving the job
to another runner OS or architecture would have needed a code change and
a second pinned hash.

Pin the SHA-256 of the release's own checksums file instead, and verify
whichever asset the runner selects against it. Upstream publishes that
file for the whole release, covering linux, darwin and windows on both
amd64 and arm64, so one pinned value now covers every platform and the
uname-based selection is restored.

Match the asset by exact filename, since a substring match would also
accept the .sbom.json and .sigstore.json lines for the same asset, and
fail closed when an asset is absent from the checksums file rather than
installing it unverified.

Verified end to end against the pinned release: the checksums file
matches its pinned hash; asset selection resolves to a listed asset for
linux/amd64, linux/arm64, darwin/amd64 and darwin/arm64; the linux/amd64
asset verifies and extracts to a statically linked x86-64 ELF; and
appending one byte to the downloaded asset makes verification FAIL, so
the gate binds rather than passing vacuously.

Co-Authored-By: Andrew Hundt <ATHundt@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 15:59:41 +02:00
Martin VogelandAndrew Hundt c794c69a73 ci(release): pin and verify the mcp-publisher download
The publish-mcp-registry job fetched mcp-publisher from the `latest`
release and piped curl directly into tar. Whatever upstream published at
that moment therefore executed inside the job that holds the MCP Registry
publish credential, with no opportunity to verify it first.

Pin the release to v1.8.1, download to a file, verify its SHA-256 against
the checksum published in registry_1.8.1_checksums.txt for that same
release, and only extract once the hash matches. The job runs on
ubuntu-latest, so the linux/amd64 asset replaces the uname-derived
selection.

Reported by Andrew Hundt in #1245.

Verified: fetched the pinned asset (7,339,841 bytes, matching the release
asset size), confirmed its SHA-256 against upstream's checksums file, and
extracted a valid statically linked x86-64 ELF.

Co-Authored-By: Andrew Hundt <ATHundt@gmail.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 15:19:46 +02:00
Martin Vogel 6b2650c0b1 Merge pull request #1443 from astandrik/codex/fix-1432-codex-hook-reconcile
fix(cli): reconcile normalized Codex hooks
2026-08-13 14:40:30 +02:00
Martin Vogel 7cdda21ef4 fix(mcp): never let a client-path startup failure die silently (#1582)
@gotspatel's Claude Desktop log showed the entire failure as:

    Server transport closed unexpectedly, this is likely due to the process
    exiting early

for what was a specific, nameable refusal. v0.10.3 added a JSON-RPC error for
bootstrap failures (#1539), but every EARLIER exit on the client path still
wrote to stderr only — and no MCP client surfaces stderr. So the guarantee "a
server that cannot start always says why" held for exactly one failure mode.

The worst of them was the endpoint-creation exit, which is precisely where an
ownership or ancestry refusal lands: the very failure most likely to stop a
fresh install was the one that said nothing at all.

Client-path exits now report through the same path as bootstrap failures, and
the endpoint failure carries the validation detail — so instead of a closed
transport the client receives the directory and the rule that refused it.

The reporter was extracted rather than duplicated (main_report_client_failure),
so a future exit added on this path gets the behaviour by calling one function
instead of remembering to hand-roll a JSON-RPC frame.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 14:35:06 +02:00
Martin Vogel 1d92d37f9e test(install): pin that index deletion runs after every abortable step (#1558)
On 0.9.0 -> 0.10.2, `update -y` auto-confirmed deleting other projects' indexes,
removed two .db files (~59 MB), and THEN aborted on an interactive prompt it
could not answer in a non-interactive shell. The run failed; the data was
already gone. Reported by @PsyTech-Rob.

The current code is already correct: both activation flows prompt early but
delete late, after the binary swap and agent configuration, and both return
before the deletion when configuration fails. #1538 also removed the specific
prompt that aborted. So this commit fixes nothing — it stops the fix from being
undone, which is the part that was missing. The reporter made exactly this
point: removing one bad prompt addressed the instance, not the rule.

Contract: in each flow that deletes indexes, cbm_remove_indexes() must appear
after the agent-configuration call. It keys on the CALL rather than a local
result variable, because the two flows spell that result differently, and it
fails loudly if a flow is renamed rather than passing vacuously.

Verified by reverting: moving the deletion ahead of configuration reproduces

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
#1558's shape and the contract goes red.
2026-08-13 14:04:51 +02:00
Martin Vogel ab568590c2 fix(cli): show the check that refused, instead of pointing at nothing (#1537)
v0.10.3 split the activation refusal into two messages so a reservation failure
would stop blaming "active CBM sessions" for something no session caused. That
half worked. The other half did not: the new message tells the reader to "check
the errors above", and nothing is above.

The detail that names the failing component is recorded on the daemon side
(ipc_validation_detail) and was only ever read by `daemon status`. So the CLI
refusal replaced a message that blamed the WRONG thing with one that blamed
NOTHING, and two reporters — #1537 on macOS, #1416 on Linux — were left with a
refusal, a reboot that changed nothing, and no way forward.

The refusal now prints the check that actually refused:

  error: activation could not reserve exclusive access; no activation was
  committed.
  error: this is NOT a running-session problem — nothing needs to be closed.
  The check that refused was: <path>: ancestor '.cache' is not a usable
  private-directory parent

The test asserts the PROPERTY rather than the wording: the message must never
point at evidence it does not show. A test pinned to the current text would
have passed happily while the dangling pointer was live, which is exactly how
this shipped.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 14:01:03 +02:00
Martin Vogel 0374af1473 feat(cli): let install target chosen clients (#1558)
`install` configured EVERY detected client. Someone who wanted Claude and Codex
had to revert the OpenCode and Cursor integrations by hand — and the next
`install`/`update` silently recreated them, so the choice could not be made to
stick. Reported by @PsyTech-Rob.

`--clients=claude,codex` restricts it. Omitting the flag keeps today's
behaviour, so nothing changes for anyone not asking for this.

The vocabulary is what makes the flag usable rather than decorative. 26 clients
ship here, several with tokens nobody would guess — factory-droid, mistral-vibe,
copilot-cli, kilocode. So:

- `install --clients` prints every token with its display name and an example;
- an unknown token FAILS and prints that same list. Silently treating a typo as
  "that client was not detected" would configure nothing and report success,
  which is the failure mode this project keeps having to fix;
- validation runs before anything is written, so a typo cannot strand a
  half-configured install.

The test pins the whole table rather than a sample: every token must resolve, so
a client added to detection but forgotten in the selector's list is caught here
instead of by a user who cannot select it.

Per the maintainer's decision `install` keeps its name — splitting it into a
separate `setup` command (#1566) stays a deliberate future decision rather than
something folded into a fix batch.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 13:42:06 +02:00
Martin Vogel bd11088fe3 Merge pull request #1585 from DeusData/fix/installer-scope
fix(cli): stop relocating a binary a package manager owns (#1566)
2026-08-13 13:21:31 +02:00
Martin Vogel 75fed228d0 Merge pull request #1378 from SEPURI-SAI-KRISHNA/fix/cypher-bound-terminal-optional-drop
fix(cypher): make expand_from_bound_terminal OPTIONAL fallback lossless
2026-08-13 12:34:22 +02:00
Martin Vogel 5e5dcd8ebc Merge pull request #1423 from PR9000/fix/freebsd-cli-self-path
fix(cli): add native FreeBSD self-executable path resolution
2026-08-13 12:33:38 +02:00
Martin Vogel 786c5864c7 Merge pull request #1421 from PR9000/fix/freebsd-activation-transaction
fix: guard activation_finish_absent_publish behind its call-site conditions
2026-08-13 12:32:53 +02:00
Martin Vogel 783482f8a5 Merge pull request #1584 from DeusData/fix/registry-publish-ordering
fix(release): publish the MCP registry entry after the release is public
2026-08-13 12:31:04 +02:00
Martin Vogel 0e89ef87ba fix(cli): stop relocating a binary a package manager owns (#1566)
`install`/`update` bundle two separable jobs: place the binary (and put its
directory on PATH), and configure the agents. For someone who installed through
mise, Homebrew or nix, the first job is not redundant — it is harmful. It drops
a SECOND copy into ~/.local/bin that shadows the managed one depending on PATH
order, and appends to a shell rc file no package manager asked us to touch:

    Would install binary -> /Users/user/.local/bin/codebase-memory-mcp
    Added /Users/user/.local/bin to PATH in /Users/user/.zshrc

They wanted the agent configs refreshed and nothing else. Reported by @SC-CTS.

`--skip-config` (binary, no configs) has existed for a long time; this adds its
missing mirror. Per the user's decision:

- **Infer by default.** A recognised manager path means the binary is not ours;
  we configure the agents and leave the binary and PATH alone.
- **`--skip-binary` / `--force-binary`** override the inference either way.
- **`update` REFUSES** on a foreign binary and names the command that will work
  (`mise upgrade`, `brew upgrade`), rather than reporting success while changing
  nothing — the dishonest-success pattern this project keeps having to fix.
- **PATH is only touched when WE placed the binary**, which is a bug in its own
  right regardless of the rest.

Detection is POSITIVE evidence — the path must look like a known manager's — and
the first implementation got this wrong in the tempting way: "anything outside
our install dir". The test suite failed immediately and was right to. A test
binary runs from build/, and an ordinary `install --dir=/opt/cbm` also lives
outside the default; both would be misread as foreign, and `update` would then
refuse to update an installation we own. The asymmetry settles it: a false
positive costs a working update, while a false negative merely leaves today's
behaviour for an unrecognised manager, which --skip-binary covers explicitly.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 11:56:17 +02:00
Martin Vogel 0215e9b4b3 Merge pull request #1583 from DeusData/fix/batch3-windows-owner
fix(windows): accept the owner Windows actually stamps on what we create (#1580, #1582)
2026-08-13 11:34:29 +02:00
Martin Vogel e4cb304a11 fix(release): publish the MCP registry entry after the release is public
v0.10.3 published, then failed:

    MCPB package '...codebase-memory-mcp-darwin-amd64.mcpb' is not publicly
    accessible (status: 404)

publish-mcp-registry and publish-final both needed only publish-registries, so
they ran in PARALLEL — and publish-final is the job that un-drafts the release.
The registry validates every package URL it is handed by fetching it, and a
draft release's assets are not publicly readable. The registry lost the race by
five seconds; the identical URL served 200 once the release went public, and the
job passed on a plain re-run.

The registry now needs publish-final. This keeps the documented intent exactly:
publish-final still does NOT depend on the registry, so a registry-preview
outage can never block shipping — the registry simply runs after the assets it
validates exist to the outside world.

The gate-chain contract now pins BOTH directions, because either one alone is a
bug: the registry must depend on publish-final, and publish-final must never
depend on the registry. Verified by reverting the ordering — the contract goes
red with the exact v0.10.3 failure, and green again with it restored.

This is the second latent defect the .mcpb path produced on its first real
release (after the security-strings audit treating a manifest as a binary).
Both were invisible until a stable release ran; an end-to-end bundle gate is
worth adding before the next one.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 11:00:51 +02:00
Martin Vogel 1649fa8344 Merge pull request #1579 from DeusData/chore/homebrew-0.10.3
chore(homebrew): point the formula at v0.10.3
2026-08-13 10:58:17 +02:00
Martin Vogel 4c793c4ded fix(windows): accept the owner Windows actually stamps on what we create
An elevated install created its own staging file and then refused it:

    error: failed to stage install candidate: activation transaction I/O
    failed: owner-not-current-user

We compared the object's owner against TokenUser. Windows stamps newly created
objects with TokenOwner, and for a member of the Administrators group that
defaults to BUILTIN\Administrators ("System objects: Default owner for objects
created by members of the Administrators group" — default on Server, common on
hardened clients). So on any elevated account the check rejected a file written
seconds earlier by the same process. Reported by @spam0115 (#1580); @gotspatel
hit the same wall from the other side (#1582) — a fresh install whose MCP server
exits during initialize, from `C:\Users\Administrator`.

The fix reads TokenOwner and accepts that SID too. This is stricter-or-equal,
never looser: TokenOwner is the ONE SID this process stamps on its own
creations, so the check still refuses anything a different account owns. It does
NOT accept "whatever an administrator owns" — that would be the loose version,
and the neighbouring activation_windows_owner_is_trusted already covers the
pre-existing-directory case where a broader rule is appropriate. On a
non-elevated account TokenOwner == TokenUser and nothing changes at all.

Windows-only code, so it is unverifiable on the macOS host: this needs the
Windows leg to compile and exercise, which is the next step before merge.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 10:04:55 +02:00
Martin Vogel 4a6891b27a chore(homebrew): point the formula at v0.10.3
The formula still pinned 0.8.1 — five releases and two months behind — so
`brew install` handed out a June binary while GitHub, npm and PyPI all served
the current one. Nothing in the release pipeline touches this file, which is
how it drifted that far without anyone noticing.

Version and all four SHA-256 digests come from the published v0.10.3
checksums.txt.

Worth doing separately: have the release workflow rewrite this formula from
checksums.txt after publish. A channel nothing updates is a channel that rots,
and the failure mode is silent — brew keeps working, it just installs something
old.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 03:16:48 +02:00
Martin Vogel 0c64de93a2 Merge pull request #1577 from DeusData/fix/spawn-retry-backoff
fix(subprocess): back off exponentially when the kernel says "try again"
v0.10.3
2026-08-13 00:48:34 +02:00
Martin Vogel 38b9f9e286 fix(subprocess): back off exponentially when the kernel says "try again"
The EAGAIN retry shipped in v0.10.3 was too small to matter. It waited a flat
3 x 10ms, which covers a momentary dip and not the real thing: a CI runner
building and testing in parallel stays process-starved for hundreds of
milliseconds at a stretch. `subprocess_run_spawn_failure` kept failing WITH the
retry in place — macos-15-intel on a release matrix, then macos-14 under
ThreadSanitizer, twice on the same SHA, which is what moved this out of the
flake bucket. A fixed short delay just samples the same congested instant
repeatedly; doubling walks out of it.

Now 10/20/40/80/160/320ms — about 0.6s of total patience. That is invisible
next to spawning a process that does real work, and a machine still refusing
after it is genuinely out of capacity, where failing fast beats hanging.

The previous attempt shipped a constant with no way to prove it worked, so this
adds the seam that was missing: CBM_ENABLE_TEST_SEAMS builds can force N
simulated refusals, and two tests pin the behaviour deterministically — fewer
refusals than the budget must still spawn, more must fail rather than retry
forever. Both are revert-checked. The seam compiles out entirely in production
(cppcheck correctly called the always-false branch dead code, so it is gone
rather than suppressed).

Two things the tests caught that review had not:
- The macOS primary path is posix_spawn, not fork; injecting only into the fork
  fallback exercised nothing on macOS. Both paths now carry it.
- cbm_fork_with_retry ended with an unconditional fork() OUTSIDE the loop, so
  the final attempt could never be reached by a test. Every attempt now goes
  through the same branch.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-13 00:21:13 +02:00
Martin Vogel e26b526c53 Merge pull request #1575 from DeusData/fix/opencode-jsonc
fix(cli): install into an existing opencode.jsonc instead of a second file (#1560)
2026-08-12 23:17:08 +02:00
Martin Vogel 1e6825f912 fix(cli): install into an existing opencode.jsonc instead of a second file (#1560)
OpenCode reads either opencode.json or opencode.jsonc. We always wrote the
.json name, so a user whose real config is opencode.jsonc got a SECOND config
file that OpenCode ignores: the MCP server never appeared, while the installer
reported success. Silent, and indistinguishable from "the tool is broken".

The install target is now whichever file already exists, .jsonc first since it
is the one we used to miss. With neither present, .json is created exactly as
before, so fresh installs are unchanged. Other clients in this file (pochi,
kilo) already resolve .jsonc paths — OpenCode was the gap, not the exception.

Reported by @iandol in discussion #1560, who noticed their config was .jsonc
while the installer named .json. Verified by revert: the test fails without
the fix and passes with it.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 23:12:27 +02:00
Martin Vogel 35659d5810 Merge pull request #1573 from DeusData/fix/security-strings-json
fix(security-strings): audit .mcpb manifests as structured text, not as binaries
2026-08-12 22:32:16 +02:00
Martin Vogel 673e523879 fix(security-strings): audit .mcpb manifests as structured text, not as binaries
The v0.10.3 release failed its `verify` gate:

    --- Auditing: scan-...--manifest.json ---
    BLOCKED: Unauthorized URL in binary: https://github.com/DeusData
    BLOCKED: Unauthorized URL in binary: https://deusdata.github.io/...
    === BINARY STRING AUDIT FAILED ===

The blocked URLs are OURS — the .mcpb bundle manifest's own homepage and
documentation fields.

security-strings.sh already knows that URL and dangerous-command auditing are
"designed for compiled artifacts" and skips them for text, keeping the
credential and base64 audits that are universally meaningful. Its detection
just had no case for JSON: `file` reports "JSON data", which matched none of
the shell-script/ASCII-text patterns, so a manifest was audited as if it were a
compiled binary. An MCP manifest is exactly the shape that misfires — it
carries project URLs by design, and a `command` field by specification.

This is the first release to ship .mcpb bundles, which is why it surfaced now:
it is the first time a manifest reached the scanned object set. The bundles
were noted as end-to-end unproven until a stable release; this is that proof
doing its job.

Two changes:
- Classify structured-text artifacts by extension FIRST (.json/.yaml/.toml/
  .md/...), then by `file` type with JSON/XML added. The extension check also
  covers hosts without file(1), where the existing `command -v` guard silently
  left every artifact classified as binary.
- Allow-list our own org root and documentation site, so a compiled binary
  embedding the docs link (the graph UI does) passes on its own merits rather
  than relying on the text exemption.

Tests: the manifest case, plus a control proving the exemption is narrow — the
credential audit still runs on structured text. The existing negative control
(an unauthorized URL in a real binary is still BLOCKED) is unchanged and still
passes, so the binary audit is not weakened.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 20:57:43 +02:00
Martin Vogel 70a9539301 Merge pull request #1552 from DeusData/fix/batch2-regressions-and-determinism
fix: two self-inflicted regressions, evidence parity in json, and the quarantine verdict tests (v0.10.3)
2026-08-12 16:13:46 +02:00
Martin Vogel 63f26f4ce1 fix(cli): forward CBM_CACHE_DIR to the Codex-spawned server (#1562)
Codex sanitizes the environment of stdio MCP subprocesses and passes through
only the names listed in `env_vars`. Our generated block had `command` and
`args` and nothing else, so a user with a custom CBM_CACHE_DIR got a
Codex-spawned server reading the DEFAULT cache while their account daemon used
the configured one. The two disagree during initialization, the connection
closes, and Codex exposes no cbm tools at all — a total failure that looks like
a broken MCP server rather than a missing environment variable.

The name is listed unconditionally rather than only when the variable happens
to be set during install: `env_vars` names variables to forward IF PRESENT, so
listing it costs nothing when unset, and it keeps working for someone who sets
CBM_CACHE_DIR after installing — a case install-time detection would silently
miss, producing exactly this bug again with no way to tell.

Reported by @anton-matosov, who verified the corrected block end-to-end before
filing.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 13:23:27 +02:00
Martin Vogel 2bb94372a4 fix(cli): quote the skill description so strict YAML readers accept it (#1554)
The installed SKILL.md carried an unquoted `description` whose value contains
"Triggers on: ". In YAML a colon-space inside an unquoted scalar is a
nested-mapping indicator, so strict readers — js-yaml's `load`, the frontmatter
parser in `npx skills` — reject the whole document. The skill does not load
wrongly; it does not load at all, and nothing in the install output says why.

Quoting the scalar fixes it without touching a word of the text.

The test checks the RULE rather than this string: for every skill, any
frontmatter value containing ": " must be quoted. Pinning the current wording
would let the next skill someone adds reintroduce exactly this bug. Verified by
reverting the fix — the test goes red, and green again with it restored.

Reported by @xezzon.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 13:11:56 +02:00
42e320a6cb docs(pi): name the loader mechanism behind the default-export requirement
Two contributors independently sent the same fix for #1550 while ours was
already in CI (#1553 by @musichen, who also reported the bug, and #1564 by
@henri-edh). Rather than close both as duplicates, this takes the part of
theirs that ours was missing.

@henri-edh's comment names the actual mechanism: Pi loads an extension via
jiti.import(path, { default: true }) and rejects anything that is not a
function, so a named-only export hands the loader the module namespace object
and the install fails with "Extension does not export a valid factory
function". Ours said "Pi calls the default export" — true, but it does not tell
the next reader why a named export cannot simply be added alongside, which is
exactly the change someone would try. Their wording is now ours.

The assertion is tightened the same way, from `export default function` to
`export default function (pi)`, so a default export with the wrong shape cannot
satisfy it.

Co-Authored-By: musichen <musichen@users.noreply.github.com>
Co-Authored-By: henri-edh <henri-edh@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 12:52:46 +02:00