Commit Graph
206 Commits
Author SHA1 Message Date
Alexander Musichen 1f1832ab37 Merge branch 'main' into fix/pi-extension-tool-schemas 2026-08-18 13:37:23 +02:00
Martin Vogel 773bb037dc fix(test): the stdin gate's #1359 guards survive a registry with no zero-argument tool
#1181 gave list_projects pagination parameters, retiring the last
empty-properties schema — and the two #1359 regression tests leaned on
list_projects as their live zero-argument example, so main went red the
moment the merge train composed (the PR was green on its July base, which
predated these tests).

The gate's schema→decision core is split behind a CBM_CLI_ENABLE_TEST_API
seam, so the zero-argument branch stays pinned directly (empty properties,
absent properties, populated properties) regardless of what the registry
ships; list_projects now asserts its NEW truth (piped args accepted, TTY
still refused); and the schema↔gate parity sweep keeps running over every
tool without the impossible >=1 zero-argument floor. Production behavior
is unchanged — this is the tests catching up with an intended schema
change, plus a seam so they never again depend on a shipped example.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-18 07:15:09 +02:00
Alexander Musichen 4656a5f3e1 Merge branch 'main' into fix/pi-extension-tool-schemas 2026-08-18 04:20:37 +02:00
Alex Musichen 109299f262 fix(pi): match ToolDefinition.execute arity on 0.84.2
Pi 0.84.2 calls execute(toolCallId, params, signal, onUpdate, ctx).
The generated (args, ctx) shape bound the call id as the MCP arguments.

Forward params and signal, pin the @earendil-works/pi-coding-agent
contract in the generated header, and lock the 5-arg form in tests.

Signed-off-by: Alex Musichen <alex.musichen@gmail.com>
2026-08-18 04:11:49 +02:00
Martin Vogel 20ad3e5b89 fix(cli): forward CBM_RUNTIME_DIR in the generated Codex configuration (#1664)
Codex sanitizes stdio MCP subprocess environments to the names listed in
env_vars. Since #1645 CBM_RUNTIME_DIR relocates the daemon rendezvous, so a
Codex subprocess that does not receive it looks for the daemon in the DEFAULT
location and never finds it — the same silent client/daemon split
CBM_CACHE_DIR caused in #1562. Both names decide WHICH daemon a process talks
to and are now forwarded unconditionally (forward-if-present semantics);
behavioural knobs (log level, workers, budgets) deliberately stay
unforwarded — that broader list remains #1664's open enhancement question.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-17 05:40:38 +02:00
Martin Vogel 276664ebff fix(cli): compare our binary path separator-insensitively in MCP ownership (#1582)
gotspatel's live opencode.json stores our entry with backslashes
(`C:\...\codebase-memory-mcp.exe`) while the installer compares its own path
with forward slashes — the same file on disk, refused over the separator
spelling, so op=mcp_install failed on a correctly-installed machine (and on
Windows the dead-path probe rightly reported the binary PRESENT, which turned
the mismatch into a hard refusal).

Ownership comparison now treats `\` and `/` as equal everywhere and folds
case on Windows only, where the filesystem is case-insensitive; POSIX
byte-exactness otherwise holds. The annotated entry that names this binary is
recognised as already satisfied and preserved byte-for-byte.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-17 05:12:08 +02:00
Martin Vogel 4cd84422ac fix(yaml): accept a UTF-8 BOM as a document prologue (#1656)
PowerShell 5.1's `Set-Content -Encoding UTF8` writes a BOM, so real
Windows-authored Hermes configs start with EF BB BF — and both edit ops
failed content-independently (the reporter's 26-byte reproduction is their
23-byte file plus exactly this BOM; reproduced RED on macOS with the same
bytes, so the platform was never the variable).

The document read now validates past a leading BOM and yaml_doc_init treats
it as a prologue: the first line's structure starts after it, the key lookup
still sees our own section when the BOM immediately precedes it (guarded by a
dedicated no-duplicate-section test), and every edit splices interior ranges,
so the BOM survives writes byte-for-byte. Non-document inputs — keys, entry
blocks, identity scalars — keep the strict no-BOM rule.

Also makes the moved-entry cli test fixture platform-correct: the Windows
dead-path probe can only prove a fixed-drive path absent, so the Windows
branch uses one; a POSIX-shaped path is refused there by design.

Fixes #1656.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-17 04:54:14 +02:00
Martin Vogel 33d1ecea1b fix(cli): install-entry compatibility repairs for goose and annotated MCP entries
Two install failures with the same root theme — the entry we write is a
compatibility contract with the agent's parser, and both sides of that
contract needed repair:

goose (#1675): ExtensionConfig::Stdio declares `name` as a required serde
field with no default, and goose's loader silently drops entries that fail to
deserialize — install reported success and the extension was invisible. The
goose block now carries `name: codebase-memory-mcp`; the non-goose YAML
schema stays name-free. A CBM_CLI_ENABLE_TEST_API seam asserts the exact
block bytes per schema.

annotated MCP entries (#1630, the deferred field-merge): dbd20eaa recognised
an entry the client annotated ("enabled": true beside our command/type) but
could only leave it untouched, because replacing the whole entry would drop
the client's keys. config_json_like gains
cbm_json_like_replace_field_raw_if_unchanged — splice ONE member's value,
preserving every other byte (comments, ordering, client keys) — and the
upsert flow uses it on the two AUTHORIZED repair channels only:

- a relocating update (the entry names the previous managed binary), and
- the existing Windows dead-path probe, which previously fell back to a
  wholesale rewrite and lost the annotations.

POSIX keeps its doctrine unchanged: a config-supplied path is never trusted,
so a moved-looking entry without that authority is preserved byte-for-byte
and install fails loudly (cli_editor_mcp_preserves_unrecorded_posix_absolute_
entries_without_probe holds). All repair/refusal paths are covered by tests
proven RED on the unfixed flow.

Fixes #1675.
Fixes #1630.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-17 04:23:50 +02:00
Martin Vogel b54998d242 fix(yaml): accept the #1631 constructs and repair prior owned entries
Four legal-YAML constructs from the reporters' real Hermes configs made
`install` fail permanently (any one of them aborted mcp_install and/or
pre_llm_hook_install):

- exact empty flow collections as values (`plugins: []`, `tool_choice: {}`)
  — now validated key-only in both the mapping-body and sequence document
  scans, mirroring the #1673 empty-mapping exception; non-empty flow
  collections stay rejected.
- block sequences at the same indent as their mapping key (column-0 `- item`)
  — item lines directly after a value-less key are structure, not malformed
  keys, in the root walker, the key matcher, and the sequence mapping-range
  walker.
- double-quoted scalars continued across lines with a trailing `\` — the doc
  loader now precomputes per-line continuation flags; continuation lines are
  value bytes every structural walker skips, and a document ending inside an
  open continuation stays an error.
- mid-word quote characters in plain scalars (`LET'S`) — quotes are scalar
  indicators only at a node start (range start, after `:`, after `-`),
  exactly like the #1639 anchor/alias rule; real quoted values keep their
  protection.

Byte-identity alone also froze users on canonicals older releases wrote:
galaxy's entry had `command:` unquoted, and the goose block gained `name:`
(#1675), so the existing entry was declared FOREIGN forever. An entry under
our key now repairs when it parses as a known prior shape (single command
line, or the pre-name goose block) with a codebase-memory-mcp[.exe] command
basename; anything else stays FOREIGN and the file untouched.

End-to-end: both reporters' full configs (iandol 15.6 KB, galaxy 15.2 KB) now
install with zero agent_config errors, every original line byte-preserved,
and the goose upgrade path rewrites the old block in place. Each construct
carries a distilled regression test proven RED on the unfixed editor.

Fixes #1631.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-17 04:23:50 +02:00
Alex Musichen d918fce287 style: fix clang-format violation in pi adapter
clang-format wants no spaces inside a braced initializer.

Signed-off-by: Alex Musichen <alex.musichen@gmail.com>
2026-08-16 22:58:58 +02:00
Alex Musichen cf5eb61c20 fix(pi): return a valid AgentToolResult and request raw JSON
The generated execute forwarded the raw MCP JSON directly, but pi's
ToolDefinition.execute must return { content: [{ type: 'text', text }],
details } — a result without a content array crashes the TUI's
getTextOutput on result.content.filter(...).

Request raw JSON from the CLI ('--json') so the bridge parses the MCP
result instead of the human-readable text, then wrap it: pass the content
array through, throw on transport errors, and stringify anything else.
Adds coverage asserting the corrected execute shape and the --json flag.

Signed-off-by: Alex Musichen <alex.musichen@gmail.com>
2026-08-16 22:26:34 +02:00
Alex Musichen 263cf22b9c fix(pi): emit parameters and execute in the generated pi adapter
The generated Pi extension registered each MCP tool as { name, run }, but
Pi's ToolDefinition requires label, description, parameters, and execute.
Tools registered that way carried no parameter schema, so strict providers
such as xAI/Grok reject the request with a 422 'missing field parameters',
and the tools were uncallable because Pi invokes execute, never run.

Emit the full tool shape from the registry: label/description via new
accessors, the input_schema embedded directly as a JSON object literal, and
execute instead of run.

Signed-off-by: Alex Musichen <alex.musichen@gmail.com>
2026-08-16 16:41:36 +02:00
pcristin f277034fe5 fix(yaml): accept empty flow mappings in mapping bodies
Signed-off-by: pcristin <xxxokzxxx@protonmail.com>
2026-08-16 12:28:54 +00:00
Martin Vogel 3162f09173 Merge pull request #1638 from astandrik/codex/fix-1633-codex-hook-preflight-diagnostics
fix(cli): explain Codex hook preflight refusals
2026-08-15 15:44:03 +02:00
Martin Vogel d58962c010 fix(cli): only name an installer that is actually there
`update` hands off to install.sh (install.ps1 on Windows) and prints the command
to run. It built that command from cbm_detect_self_path - the BINARY's directory
- and treated "I resolved my own location" as "the installer is beside me".

Those are different questions. install.sh is placed beside the binary by
install.sh itself, but a binary that was moved, packaged by a distro, or built
from source has no installer next to it. We printed the path anyway:

    bash "/home/<user>/.local/bin/install.sh"
    /usr/bin/bash: /home/<user>/.local/bin/install.sh: No such file or directory

Reported on discussion #1560 (#1632) by a user who was already three releases
deep in install trouble and had just been told, by us, to run a file that does
not exist.

`update` exists to tell someone how to proceed. Ending the interaction on a
command that cannot run is the one outcome it must not produce - and the
fallback text was already there and already correct, naming install.sh as
shipping in the release archive without asserting a path.

The probe goes through cbm_path_info_utf8 so a non-ASCII install directory
resolves on Windows, and rejects a DIRECTORY of that name, because `bash <dir>`
is not a command either. A symlink still counts: it is reported rather than
followed, and the shell runs it perfectly well.

The Windows branch gets the same treatment; it had the identical assumption
about install.ps1.

Reproduce-first and revert-checked: with the probe forced to return true - the
old behaviour - the new test fails with "a directory with no installer must not
be named as one", and passes once it is restored. cli: 276 passed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-15 10:43:15 +02:00
astandrik 00e0cf38d6 fix(cli): harden hook diagnostic contract
Signed-off-by: astandrik <astandrik@yandex-team.ru>
2026-08-15 10:37:24 +03:00
astandrik 7d4dc779d0 fix(cli): explain Codex hook preflight refusals
Signed-off-by: astandrik <astandrik@yandex-team.ru>
2026-08-15 10:37:24 +03:00
Martin Vogel a598a5a5d8 Merge pull request #1641 from DeusData/fix/uninstall-help-destroys
fix(cli): stop `uninstall --help` from performing a real uninstall
2026-08-15 08:04:29 +02:00
Martin Vogel e513beb487 Merge pull request #1643 from DeusData/fix/no-stdin-slurp-without-flags
fix(cli): never read stdin for a tool that declares no arguments (#1359)
2026-08-15 02:52:29 +02:00
Martin Vogel a530c8aabe fix(cli): stop uninstall --help from performing a real uninstall
`codebase-memory-mcp uninstall --help` removed the binary and every agent
configuration (#1038). It did the destructive thing to someone asking what the
command does.

The top-level dispatcher matches the subcommand at argv[1] and forwards the rest,
so its own --help check at src/main.c:1047 never sees argv[2]. Nothing downstream
looked either, and cbm_cmd_uninstall went straight to parse_auto_answer.

The guard is checked FIRST, before parse_auto_answer, so a `-y` elsewhere on the
line cannot auto-confirm the destruction we are trying to prevent. It prints real
usage, states plainly that the command is destructive, and points at --dry-run.

--help is the flag a person types precisely BECAUSE they are unsure what a
command does. It must never be the thing that destroys their install.

cli suite: 271 passed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-15 02:43:27 +02:00
Martin Vogel f8b2c6fa47 Merge pull request #1640 from DeusData/fix/json-mcp-extra-keys
fix(cli): accept an MCP entry the client annotated, instead of refusing it
2026-08-15 02:42:24 +02:00
Martin Vogel 1ab5512f0e Merge pull request #1639 from DeusData/fix/yaml-interior-anchor-chars
fix(yaml): treat interior `*` and `&` as text, not as alias/anchor indicators
2026-08-15 02:41:27 +02:00
Martin Vogel 9ca108fce9 fix(cli): never read stdin for a tool that declares no arguments (#1359)
`cli <tool>` resolved its JSON arguments from stdin whenever stdin was not a
terminal and no --args-file, raw-JSON positional or --flag form was supplied,
and cli_slurp_stream reads to EOF. An ordinary automation caller never sends
that EOF: Node's child_process.spawn defaults to stdio ['pipe','pipe','pipe']
and the parent must call child.stdin.end() explicitly, which almost nobody does
for a command it is not writing to. fd 0 then stays open with no writer and the
read never returns. The reporter's shell script was still parked in fread(0)
fourteen minutes later.

`list_projects` advertises "properties":{} — stdin could never have carried
anything it accepts, so the read was pure deadlock with nothing to gain. Gate
the stdin path on the tool's input_schema actually declaring properties, in a
seam (cbm_cli_args_from_stdin_allowed) that main.c's resolution chain calls.
An unknown tool also stops blocking: dispatch rejects it by name and no stdin
content can change that verdict. Tools that do declare properties keep the
documented `echo '<json>' | cli <tool>` channel untouched, and interactive runs
are unchanged because isatty(0) already short-circuited them.

Measured on the pre-fix binary, stdin held open by a writer that never writes,
15-second watchdog: `cli list_projects` was killed at 15s with 0 bytes of
output, while `cli list_projects < /dev/null` returned in 3s with 90 bytes.
After the fix the same open-pipe invocation returns in 3s with 90 bytes.

Regression tests (tests/test_cli.c): the reported hang, the piped-argument
channel that must survive, the interactive path, unknown/NULL tool names, and a
sweep asserting the gate tracks the advertised schema across the whole tool
table so a future zero-argument tool cannot reintroduce this silently.
Revert-check: with the schema gate reverted to the pre-fix `return
!stdin_is_tty`, both tests fail on the list_projects assertion and the suite
reports 2 failed; with the gate applied, 272 passed / 0 failed.

Known limit, not addressed here: a tool that DOES declare properties, invoked
with its required flag omitted (`cli index_status` without --project), still
blocks on an open pipe. Distinguishing "arguments are coming" from "nobody will
ever write" needs a bounded wait, which is a public-surface decision (deadline
value, and the cross-platform pipe-readiness probe Windows would need) rather
than a mechanical fix.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-14 19:55:24 +02:00
Martin Vogel dbd20eaa48 fix(cli): accept an MCP entry the client annotated, instead of refusing it
OpenCode writes "enabled": true beside the "command" and "type" we write. Our
ownership check required the entry's key set to match EXACTLY
(config_json_like.c: member_count != found_count), so a three-key entry with two
recognised keys was classified as FOREIGN - and we refused to touch an entry we
had written ourselves. install then failed with:

  error: agent_config agent=OpenCode op=mcp_install path=.../opencode.json

Confirmed on two independent configs: Linux (#1630) and Windows (#1582). In
gotspatel's file EVERY MCP server carries the key - mssql, forgetful,
chrome-devtools and ours - so this is OpenCode's normal shape, not an unusual
hand-edit. Anyone who has ever toggled a server on or off in the UI was hit.

Two of my own hypotheses were wrong before the reporters' files settled it: it is
not JSONC comment parsing, and it is not the .jsonc targeting that #1575 fixed.
#1575 fixed WHICH file we open; this happens after, on what we find inside.

The distinction now reported is MATCH_WITH_EXTRAS, and the caller treats it as
ALREADY SATISFIED - success, without touching the file. That is deliberate and it
is the safe half of the fix: cbm_json_like_upsert_entry REPLACES an entry
wholesale, so writing our canonical shape over an annotated entry would silently
delete the client's keys. A refusal the user can see beats a deletion they
cannot. Doing nothing is also correct on the merits: the entry already names this
binary with the right type, which is the entire content of the install.

Merging our fields into an annotated entry while preserving the rest is the
fuller fix and stays tracked in #1630. This makes the common case work without
risking anyone's configuration tonight.

Ownership is NOT loosened otherwise: an entry whose command points at a different
binary is still foreign and still refused, byte-identically, and that direction
is pinned by its own test.

Tests use the reporters' actual entry shape. cli suite: 272 passed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-14 19:19:02 +02:00
Martin Vogel 436707f8ff fix(yaml): treat interior * and & as text, not as alias/anchor indicators
yaml_range_has_unsupported rejected `&` and `*` anywhere in a plain scalar, with
no positional test at all. So a value containing prose asterisks - `use
*emphasis* here`, a kaomoji, a glob inside a description - was refused as if it
were an alias.

That is one of four constructs that made a real 16 KB hand-maintained Hermes
config permanently un-editable by us (#1631). It is not YAML we cannot model; it
is YAML we declined to read.

An indicator only counts where a NODE begins. The test is now positional and
judged by what PRECEDES the character, because the scanned range covers a whole
line including its key: in `command: &shared` the `&` is an anchor even though
`command:` came first. A node begins at the range start, after a mapping colon,
or after a block-sequence dash. Everything else is text.

`{` and `}` keep their existing treatment. Empty flow mappings (`key: {}`) are
the next item in #1631 and carry their own semantics; bundling them here would
mix a positional correction with a value-semantics change.

Root-caused by @rg6304, who reproduced it in isolation, read this source, and
corrected the maintainer's hypothesis - the constructs I had guessed (inline
comments, anchors, `---` separators) appear nowhere in the failing file.

TESTING NOTE, recorded because it nearly went wrong: the first version of the
regression test put the asterisk in an untouched foreign section and passed with
the fix REVERTED - the editor only validates the range it writes, so the test
proved nothing. Rewritten onto the entry block, it now fails without the fix
(`== -1, expected 0`) and passes with it. The companion test pins the other
direction: a LEADING `*` is still an alias and still refused, byte-identically.

config_yaml_edit + yaml suites: 117 passed, 0 failed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-14 18:27:06 +02:00
Martin Vogel 22ebed4d3b fix(cli): say what the agent-config target IS when a write is refused
`error: agent_config agent=OpenCode op=mcp_install path=...` is the whole
diagnostic today. The config editors collapse nine distinct fail-closed
conditions into a single -1 - unsupported structure, an inline comment on a
field line, a non-single-link file, unsafe metadata, lock contention, I/O - and
the message separates none of them.

Four failures of exactly this shape are sitting in discussion #1560 right now,
unresolved across five releases: OpenCode mcp_install on Linux and Windows,
Hermes mcp_install and pre_llm_hook_install on both, and a Codex hook_preflight
that is new in v0.10.4. One reporter has hit install failures on every release
from 0.10.0 to 0.10.4 and is still filing them. Nobody can tell whether these
are one bug or four.

The refusal now reports what the target actually is: kind and size, observed at
the moment of failure. That is enough to split the space - a 19 KB regular file
that we refuse to write is a structural refusal, not a permissions or
missing-file problem, and that alone rules out most of the candidates.

Deliberately NOT errno. This function is called from 119 sites and errno may be
stale from an unrelated call. Printing it would repeat #1537 exactly, where a
permission decision surfaced a fabricated ENOENT and sent the reporter hunting
for a file that was never missing. Only observed facts, or nothing.

This is the first step, not the whole fix: the editors should carry their own
refusal reason out, the way cbm_daemon_ipc_validation_detail() already does for
the daemon. That is 89 failure returns in the YAML editor alone, so it lands
separately rather than as an unreviewable diff.

cli suite: 270 passed, 0 failed.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-14 15:21:13 +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 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 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 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 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 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 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 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 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 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 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 a154d18a2e fix(cli): stop breaking commands people already type (#1544, #1550)
Two regressions we shipped ourselves, both with the same shape: a change that
was correct in the abstract broke a concrete thing users had working.

**`update --ui` became a hard error (#1544).** v0.10.2 removed the ui/standard
chooser and deleted the flags that drove it, so a command in scripts, aliases
and muscle memory started failing with "unknown update option" where v0.10.1
had printed the install-script handoff. Retiring a CHOICE is fine; retiring the
WORDS is a break users get nothing for. Both flags are accepted again, do
nothing, and print one note explaining that every build now includes the UI.
A genuinely unknown flag is still rejected — the point is compatibility, not
silence. Reported by @ehendrix23.

**The generated Pi extension could not load (#1550).** Since #1392 the
extension has been generated from the tool registry, emitting
`export function register(pi)`. Pi loads an extension by calling its DEFAULT
export as a factory, so the file failed to load — and a Pi extension that fails
to load takes every `pi` command with it, `pi doctor` included, not just cbm's
tools. It now default-exports the factory. Reported by @musichen.

The existing generator test passed throughout: it asserted WHICH tools the file
lists, never that the file Pi loads is loadable. The new test pins the export
shape, which is the property that actually matters to the consumer.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-12 11:52:38 +02:00
Martin Vogel 8eabe191d2 fix(install,daemon): unbreak npx clients, group-writable homes, and legacy updaters
Five field reports in the 24 hours after v0.10.0 all pointed at the same thing:
gates that were right in principle refused real, ordinary setups, and then
failed to say why. Per the consolidated strictness decision, each gate keeps the
protection that matters and drops the part that was refusing legitimate users —
and every refusal now names what it refused and how to proceed.

**Daemon image gate: npx and every ephemeral install path (#1539, #1383).**
The admission check treated "the peer's image hashes differently" and "the
peer's image cannot be examined at all" as one failure. The second is what
`npx codebase-memory-mcp` always produces (ephemeral cache path,
unfingerprintable), so every npx-invoked client was rejected — and, because the
client never reported it, agents saw a transport that closed mid-handshake with
zero bytes on stdout. Reported by @wassolles with the admission path already
read and the fix space mapped.

An unverifiable image is now admitted: the rendezvous HELLO immediately above it
has already proven semantic version, build fingerprint, and protocol/store/
feature ABI, and the image check was trading that real proof for an unavailable
one. It logs daemon.client_image_unverifiable_admitted so the weaker check is
never invisible. A fingerprint MISMATCH — the tamper case the gate exists for —
still rejects hard. Separate test seams keep the two modes testable apart.

**Client bootstrap failures are no longer silent (#1539).**
An MCP client that cannot reach the daemon now emits a JSON-RPC error on stdout
naming the reason, plus the same text on stderr. Previously the reason sat in
bootstrap_result.message and the process exited having written nothing at all.

**POSIX activation: group-writable ancestors (#1535, discussion #1526).**
activation_directory_secure required no group or other write bit on the install
directory AND every ancestor. WSL2 ships ~ and ~/.local at 0775, as do several
distro skeletons and any site using a shared primary group, so install.sh failed
for a large fraction of Linux users — reporting a policy refusal as "activation
transaction I/O failed", which sent reporters after disk errors and filesystem
types. Root-caused by @AmirF194 in a clean ubuntu container; @shochdoerfer and
@iandol confirmed independently.

World-writable ancestors are still refused (any local user could swap a path
component mid-transaction). Group-writable ancestors are now warned about and
admitted. The LEAF directory stays strictly owner-private — that is where the
binary is published, and group write there would let another account replace the
executable between validation and exec. Refusals now name the directory, its
mode, and which rule refused.

**The obsolete ui/standard chooser (#1538, from discussion #1526).**
v0.10.0 consolidated to one archive per platform with the UI always embedded,
but `update` still offered a variant choice: "ui" could only 404, and "standard"
quietly WAS the UI build. Reported by @iandol upgrading 0.9.0 -> 0.10.0. The
chooser, its --standard/--ui flags, and the ui- URL plumbing are removed, along
with the CBM_VARIANT=ui remnant in the npm installer.

Already-released 0.9.x binaries cannot be fixed retroactively, so the release
workflow now publishes byte-identical ui-*-named alias assets — their updaters
work again with no user action. The aliases are uploaded AFTER the VirusTotal
gate: they are the same bytes as archives it already cleared, and uploading them
earlier would duplicate every object in the scan set and the provenance manifest.

**macOS install noise and attribution (#1537).**
install.sh silenced the "No such xattr: com.apple.quarantine" line, which is
what happens when a curl-downloaded archive carries no quarantine attribute —
harmless, and it became the title of a bug report about an unrelated failure.
The session-stop refusal now points at `daemon status` to list the client
processes actually holding the daemon, instead of asserting sessions exist and
leaving the reader to guess. Reported by @listepo.

**Riders.** hatchling is pinned in pkg/pypi (an unpinned backend resolved fresh
inside `python -m build` is what emitted Metadata-Version 2.5 and broke the
v0.10.1 publish); SECURITY.md's supported-versions table moves to 0.10.x.

Tests: separate seams for unverifiable vs mismatched peer images with a test per
outcome; activation refusal must name directory + mode + rule; a group-writable
ancestor must stage successfully. The update tests drop the flag that no longer
exists. Verified against each reporter's environment shape.

**Open security alerts (all three, OSSF Scorecard).**
- HIGH, binary artifact: an 8.8 MB compiled Go ELF wrapper had been committed at
  pkg/go/codebase-memory-mcp by accident. Removed, and both it and its .exe
  sibling are gitignored so `go build` in that directory cannot repeat it.
- HIGH, GHSA-2v37-7h3g-55p8: nanoid < 3.3.17 loops forever when a custom
  generator is called with size 0. It reaches us transitively (postcss -> vite),
  so it is pinned through the existing graph-ui overrides block rather than
  promoted to a direct dependency; the lockfile resolves 3.3.18.
- MEDIUM, unpinned pip command: the publish step installed build/twine by
  version only, leaving the whole transitive graph resolved at run time.
  pkg/pypi/requirements-publish.txt now hash-pins the complete toolchain (316
  hashes), generated on a linux/amd64 python:3.12 image so the wheels match what
  ubuntu-latest resolves, and the step runs pip with --require-hashes. Verified
  by installing from it in that same image.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-11 13:37:32 +02:00
Martin Vogel f59d24fd10 fix(mcp): make every tool reply client-usable — structuredContent, pipelining, config get (#1522)
Three regressions shipped in 0.10.0 share one failure shape: an empty result
with a success status, indistinguishable from "nothing found" for the LLM
clients that are cbm's primary consumers.

1) structuredContent {} on the whole tree-format surface (#1522 bug 1).
   #1488 replaced the duplicated payload with an EMPTY structuredContent
   object while every tool still declared a blanket permissive outputSchema.
   Spec-honoring clients (Claude Code among them) treat structuredContent as
   THE result when a schema is declared, so search_graph, trace_path,
   query_graph, get_architecture, search_code, and detect_changes all rendered
   as literally "{}" on their DEFAULT format, on every platform. The corrected
   contract: no tool declares an outputSchema (tool output is
   format-parameter-polymorphic — no static schema is truthful), JSON-object
   payloads keep their parsed structuredContent, errors keep
   structuredContent.error, and text-shaped payloads carry NO structuredContent
   key at all — which also preserves #1375's no-duplication win.

2) Frontend queue overflow killed the session (found by the #1522 sweep).
   Any 7+ requests pipelined in one stdin burst — an agent issuing parallel
   tool calls does exactly this — overflowed the 8-frame frontend queue, which
   failed the whole session: rc=1 with ZERO bytes of output, every buffered
   response lost. A full queue is now backpressure: the stdin reader blocks
   until the worker drains (bounded by the same stop/fail flags every teardown
   path already sets); only a single frame larger than the entire 12 MiB byte
   budget — which could never be admitted — remains a hard failure.

3) config get printed "" with exit 0 for every unset and every unknown key
   (#1522 bug 2). list printed stored-or-DEFAULT while get printed
   stored-or-EMPTY, and no subcommand validated key names, so a typo was
   indistinguishable from a correctly-read setting. One config-key table now
   drives help, list, get, set, and reset: get prints the stored value or the
   key's real default (the same fallback the runtime readers use), and unknown
   keys error with exit 1 on get, set, and reset alike.

Tests — each RED on the pre-fix tree and RED again on revert:
  * test_mcp.c: text results carry no structuredContent key; tools/list
    declares no outputSchema; the tool-table guard now binds all three
    branches (absent / parsed-object / error) for every registered tool.
  * test_daemon_frontend.c: the over-capacity contract flips from
    "session fails" to "backpressure without loss" — the held first request
    plus all 32 over-capacity frames are answered and the run closes cleanly.
  * test_cli.c: the config command contract — defaults, round-trip, reset,
    and unknown-key rejection on all three subcommands.
  * smoke-test.sh: Phase 3z rewritten to the corrected structuredContent
    contract, new Phase 3z1 (default-format replies usable in schema-honoring
    clients, no outputSchema advertised), 3z2 (24 pipelined calls all
    answered), 3z3 (config defaults + unknown-key rejection) — all asserted
    against the SHIPPED artifact, where #1488's smoke phase previously pinned
    the empty-object behavior as correct.

Verified end-to-end on the locally built production binary: all 15 tools
declare no schema; text tools return ABSENT structuredContent, object tools
populated, error envelopes intact; 7/24/64-deep pipelined bursts all answered
with rc=0; config get prints real defaults and exits 1 on unknown keys.
hook_augment (structuredContent.projects) and index_resilience
(structuredContent.status) consume object payloads and are unaffected.

Fixes #1522.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-11 01:12:59 +02:00
Martin Vogel 57f30e28e3 style(cli): clang-format the uninstall --dir target resolution
Ran the format gate before adding --dir rather than after, so the ternary
landed unformatted and lint/lint went red. Reformatted with the pinned Homebrew
LLVM clang-format; the diff is confined to that block and cli.c still compiles.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-09 19:56:56 +02:00
Martin Vogel a3ae04571a fix(cli): restore uninstall --dir so a custom install stays removable
The Windows long-path guard failed with:

  error: unknown uninstall option: --dir=C:\...\install_Ωμέγα\...\bin

Restoring cli.c from before the externalization dropped --dir from uninstall
while install kept it. That is worse than a test failure: anyone who installed
outside ~/.local/bin had no supported uninstall path at all — uninstall would
look in the default location, find nothing, and leave the real install in place.

Parsing mirrors cbm_cmd_install (--dir=PATH and --dir PATH, both rejecting an
empty value), and the resolved directory now feeds the target path with an
explicit truncation check instead of an unchecked snprintf into a fixed buffer.

Confirms the earlier cascade read: with the extended-path probes fixed,
daemon_stability, hook_augment, cli_non_ascii_arg and ui_drive_listing all went
green on their own. non_ascii_path was the only real failure, and it was hiding
two distinct product regressions behind one red guard.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-09 19:40:04 +02:00
Martin Vogel 1ba668cded fix(cli): keep the install/uninstall existence probes extended-path safe
test_non_ascii_path.py caught a real product regression I introduced:

  [FAIL] long-path existing target was overwritten despite --no
         (target existence probe is not extended-path safe)

Restoring cli.c from before the externalization also reverted three probes from
cbm_path_info_utf8() back to plain stat(). On Windows stat() resolves through
the ANSI code page, so an extended-length or non-ASCII target reports "absent" —
and a non-force `install --no` then overwrites the exact bytes the user asked to
keep. Silent data loss, not a cosmetic difference.

That fix rode along in the externalization commit but is orthogonal to it: the
wide-path helpers it depends on (win_utf8.h, compat_fs.c) were kept. Only their
call sites in cli.c were lost.

Two of main's five cbm_path_info_utf8 sites lived in asset code that is
correctly gone; the two restored here are the install target probe and the
uninstall binary probe.

Not yet explained: test_daemon_stability.py also went red in the same run
("daemon did not accept the UI configuration"). Its code path is identical to
main's apart from the intended capability predicate, and the guard that failed
before it completes an install into a long path it should have refused, so this
may be downstream state. Pushing this fix to find out rather than guessing.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-09 19:13:47 +02:00