Commit Graph
2269 Commits
Author SHA1 Message Date
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 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 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 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
Martin Vogel b91abca173 fix(daemon): admit a group-writable ancestor, and stop inventing an errno (#1537)
The reporter came back with the output that named this:

    exact executable identity could not be verified (cache-private) -
    /Users/.../.cache/codebase-memory-mcp: ancestry component validation
    failed (errno 2)

Two separate defects, and the second is why nobody could see the first.

**The refusal.** The daemon's private-directory ancestry walk rejected any
group-write bit on any ancestor — `(mode & 0022) == 0`. That is the identical
rule #1535 removed on the activation side, in the sibling gate the consolidated
strictness decision covers but that never got changed. A group-writable ~ or
~/.cache is ordinary: WSL2 ships 0775, so do several distro skeletons and any
site with a shared primary group. So the daemon refused to run, permanently,
with nothing the reader could close or restart.

World-writable is still refused (any local user could swap a path component),
and the private directory itself is still chmod'd to 0700 and verified after
the walk — which is what makes admitting the ancestor safe: the thing that
actually holds data stays owner-private either way. Group-writable ancestors
are logged, so the weaker check is never silent. Both directions are tested;
a fix that admitted everything would "fix" this by deleting the protection.

**The invented errno.** When that check failed, the branch set no detail, so
the caller fell back to printing `errno` — which nothing on the path sets. The
reporter was handed ENOENT for a permission refusal and went looking for a
missing file that was there all along; it cost a round trip to find out the
number meant nothing. The branch now names the component and the rule.

Reported by @listepo, who ran the diagnostics that turned this from a guess
into a one-line root cause.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 12:47:59 +02:00
Martin VogelandKiborgik a654acbc88 fix(test-infra): grant the build-dir ACL by SID, not by name (#1532 follow-up)
The local Windows leg caught this within hours of merging #1532: on a machine
that is not domain-joined, USERDOMAIN is the literal string WORKGROUP, and
`WORKGROUP\test` is not a resolvable principal —

    WARN: build-dir DACL stamp (pre-wave) failed (user=WORKGROUP\test ...):
    WORKGROUP\test: No mapping between account names and security IDs was done.

so the grant fails outright and the tree keeps its inherited
`Authenticated Users:(M)`. The guard then correctly reports that cross-account
mutation is still possible, and the whole leg stops. Since most Windows
machines are not domain-joined, that is the common case, not the exotic one.

#1532 was fixing a real bug in the other direction (a bare name resolves
against the machine first, so a host named like its user grants to an empty
principal). Both failures are name RESOLUTION failures, so this stops resolving
names: the account is identified by SID, which is unambiguous everywhere. The
SYSTEM and Administrators grants in these same commands have always used that
form — the current user was the odd one out. Name lookup survives only as a
fallback where PowerShell is unavailable.

Applied to all three scripts carrying the stamp (run-tests-parallel, soak-test,
memlab), since they share the failure and would otherwise drift apart.

Co-Authored-By: Kiborgik <Kiborgik@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 11:57:35 +02:00
Martin Vogel 8d487b7da0 test(daemon): scale the frontend announce backstop with the build
`test-msan` failed the two backpressure cases while every other lane was green,
and the same suite passes under MSan locally (12/12). The wait that fired is a
LIVENESS BACKSTOP, not a race budget: the daemon child either announces on its
ready pipe or it does not, and the test asserts the announcement itself, never a
timing window.

A backstop only does its job if it never fires on a legitimately slow start.
30s was already the second value here — the first was below the daemon's own
12s cohort-claim budget and SIGKILLed healthy startups on oversubscribed
runners. Under MemorySanitizer, with instrumented libc++ and origin tracking,
startup runs several times slower than that same budget it has to clear, so 30s
stopped being a backstop and started being a verdict.

Sanitized builds now get 180s, matching the existing sanitizer-aware pattern in
test_cs_lsp_bench.c. This costs nothing when the daemon is healthy: a passing
run returns the moment the byte arrives, whatever the ceiling. Nothing about the
asserted state changed, and the non-sanitized bound is untouched.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 11:52:38 +02:00
Martin Vogel ff853ac14b fix(cli): stop blaming sessions for a failure that has none (#1537)
A reporter ran `uninstall`, then could not install again: every attempt said
"active CBM sessions and operations could not be stopped safely". A reboot
proved no such session existed, and the remedy line v0.10.2 added made it worse
— it told them to run `codebase-memory-mcp daemon status`, a binary they had
just uninstalled.

Both halves were wrong for their case:

The guard funneled two unrelated outcomes into one message. A cohort that is
BUSY means real sessions hold it and closing something is the fix. A reservation
that FAILED — lock I/O, leftover coordination state, permissions — means nothing
is running at all, and telling that reader to close sessions sends them after
processes that do not exist. Same misattribution class as #1416 and #1535: a
non-session failure wearing a session costume. They are now separate messages,
and the failure one says plainly that nothing needs to be closed.

The remedy also has to survive the situation it prints in. Install, and any
retry after uninstall, runs with no cbm on PATH; advice that starts with
`codebase-memory-mcp ...` is unusable exactly when it is needed. The busy
message now leads with the actionable step (close the editor/agent holding it)
and mentions the command only as an aid where a binary still exists; the
failure message asks for a cache listing, which needs no cbm at all.

A recorded transaction refusal still outranks both generic messages, so the
#1416 attribution is unchanged.

This does NOT yet explain why their reservation fails with nothing running —
that needs their environment, which is being requested on the issue. What it
does is stop sending them after the wrong thing while we find out.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 11:52:38 +02:00
Martin Vogel 98aff5b7dc fix(subprocess): retry a spawn the kernel refused for load, not for cause
`posix_spawnp` and `fork` both fail with EAGAIN when the process table or a
per-user limit is momentarily full. Both paths treated that as a hard failure,
so a burst of process creation turned into CBM_PROC_SPAWN_FAILED — a git probe,
an LSP server, or an index worker refusing to run for a reason the user can
neither see nor act on, on a machine that is merely busy.

This is the production half of a flake we have been re-running for two days:
`subprocess_run_clean` failed spawning `/bin/sh -c "exit 0"` on macOS runners in
three separate CI runs, always under a loaded release matrix, always green on a
quiet rerun. Chasing it as a test race would have been chasing the symptom — the
test is right, and a user on a loaded laptop hits the same edge.

EAGAIN/ENOMEM now retry three times over ~30ms; everything else keeps failing
immediately, and an exhausted system still fails after the backoff rather than
spinning. exec-class errors (ENOENT/EACCES/ENOEXEC/...) are untouched, so the
documented "missing binary exits 127, spawn_failed means the mechanism failed"
contract still holds. The fork fallback gets the same treatment: it fails for
the same reason under the same pressure, and the fallback must not be less
robust than the primary path.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 11:52:38 +02:00
Martin Vogel 4b886904d7 fix(mcp): give format:"json" the evidence columns it advertises (#1542)
`trace_path(include_evidence:true)` added `strategy` and `confidence` per hop in
the default tree output, and returned `cols: ["name","hop"]` under
format:"json" — no error, no warning, just two fields missing from the response
shape the schema and `--help` both promise.

include_evidence was only ever implemented in bfs_to_tree_table; bfs_to_tree_json
never took the flag. So the callers most likely to want the evidence — the ones
asking for structured output to consume programmatically — were exactly the ones
who silently got none of it. Reported by @asemraza.

The json path now emits both columns with the same semantics as the tree path,
including the placeholder case: the root hop has no inbound edge and non-CALLS
edges record no strategy, so those rows carry nulls rather than dropping fields
and leaving callers with ragged rows. Tree says "-", json says null — the same
promise in the form each consumer can test.

The existing evidence test covered only the tree path, which is why this went
unnoticed; it now asserts both formats.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 11:52:38 +02:00
Martin VogelandLynxBay f3d78b1cf7 test(store): bind the quarantine verdict that #1545 introduced (#1206, #1037)
#1545 fixed the defect that let concurrent instances quarantine each other's
healthy databases, but shipped without tests — and this is a data-loss path, so
it gets coverage before it ships.

Three cases, one per verdict, chosen so a regression in either direction fails:

  healthy      -> OK          (the baseline the other two are measured against)
  bad root_path-> CORRUPT     (a verdict that never says CORRUPT would protect
                               broken databases instead of users)
  no handle    -> TRANSIENT   (a file we could not open tells us NOTHING about
                               its contents; answering CORRUPT there is how a
                               database nobody had read got renamed and rebuilt)

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