Markdown docs reference other repo files constantly (a coding-standards
doc links the modules it governs, a README points at entry points) but
none of that surfaced as edges, so fan-in queries were blind to
documentation hubs. In an A/B retrieval benchmark I ran in August on a
docs-heavy repo, the graph's top fan-in answer was off by roughly 17x:
the most-referenced file was a standards doc with 173 references and
zero inbound edges.
New pre-dump pass, pass_doclinks.c, modeled on pass_configlink.c: three
strategies emit REFERENCES_FILE edges between existing File nodes only
(unresolvable targets are dropped, the pass never invents nodes):
inline links, backtick paths, bare path mentions. Targets resolve
against the referencing file's directory and the repo root; repeated
references collapse into one edge carrying strategy, confidence and
count. Registered in the pre-dump sequence after configlink and in the
incremental post-passes; REFERENCES_FILE added to the skill's edge-type
list and the structural/language contract tests.
Tests mirror test_configlink.c (real files in a tmpdir, File nodes in a
gbuf, run the pass, assert edges): inline link, backtick, bare mention,
http/anchor ignored, anchor-suffixed file link, dedupe with count,
relative-vs-root resolution, unresolvable-target guard, NULL repo_path
skip.
A companion change adds the same linking for shell files (source lines
and script invocations); split out to keep each change reviewable.
Validation: scripts/build.sh clean; focused serial runner (doclinks,
configlink, pipeline, edge_structural, lang_contract) 340 passed under
ASan/UBSan; cppcheck clean on the new files with the repo's flags.
Signed-off-by: Clément Deust <clement.deust@gmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CLI_USAGE described outer --json but did not mention tool-level
--format json. Add a help-only pointer that tools which accept format
support --format tree|json, and distinguish payload JSON from the full
MCP envelope. No new flags, env vars, or output-default changes.
Fixes#2102
Signed-off-by: Lowpower <3133734+Lowpower@users.noreply.github.com>
Nine findings from the 2026-09-16 graph-quality probe over the eleven bench corpora, each with a
test that fails without its fix and a before/after measurement on the real corpus.
Deterministic resolution (O9). Two indexes of one kernel tree differed in 5,821 IMPORTS and 632
CALLS edges. Three order/load dependencies carried it: the `#include` target was the first by-name
hit whose path ends with the include path — a bucket in worker merge order (include/linux/device.h
vs tools/virtio/linux/device.h); the registry broke suffix_match score ties by bucket position; the
LSP budget-share rule measured wall time. Include targets now prefer the File node, the least nested
path, then the lexicographic path; registry ties prefer the least nested QN, then the smaller QN; the
budget share is measured in thread CPU time like the budget itself. Two kernel indexes are now
identical (8,529,723 nodes, 1,974,940 CALLS, zero churn), as are two elasticsearch indexes.
Findability. search_graph's BM25 path honours the `label` filter and ranks an exact-name definition
above test methods that repeat the token (15/15 probe queries).
Coverage. The parse-coverage gap tree subtracts definitions the walker recovered inside an error
region (multi-line only); kernel/sched/core.c reports 476 flagged lines instead of 7,674.
Precision. Config-language callers (Makefile, CMake, YAML) no longer bind unique_name into code
(kernel Makefile -> C CALLS 195 -> 0). JS-family token names ({}, 1, (x: T) => U) are not
definitions; computed, quoted and escaped member keys stay (TypeScript 204 junk -> 0, 1,782 real
names kept). Names are cut at their first line (elasticsearch YAML 8,306 -> 0); a comment inside an
argument list is not an argument, so no Route node is named by a Java block comment; the wildcard
route `/*` is still a route. HTML/Vue/Svelte/Astro embedded member calls stay unbound.
Recall. A Python call through a self- or cls-rooted attribute keeps a specific unique_name member
target (Django +751, elasticsearch +3,288 CALLS, no new callee names).
Robustness. Edge properties never carry a split UTF-8 sequence (the 120-byte expression cut and the
JSON escaper both trim a partial code point). CLI array flags accept a JSON-array literal. Cypher
seeds a single-hop pattern from its selective end (kernel `MATCH (a)-[:CALLS]->(b) WHERE b.name =
'_printk'`: execution limit -> 7.9 s).
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A reporter brought a full ProcMon trace to #1856 and still could not say which
check refused their install. That is not their failing: the binary discards the
answer before printing it.
cli_activation_production_context_init() validates the cache, rendezvous and
log directories -- it is the emitter MOST likely to hold a useful detail -- and
it printed CLI_ACTIVATION_REFUSED_MESSAGE bare, through the raw sink rather
than cli_activation_diagnostic(). So the reader got "Check the errors above"
with nothing above. That is the exact dead end #1416 and #1537 already fixed;
the property was repaired on the paths that had tests and left broken on this
sibling, which had none. Route it through the attributing helper: the refusal
now names the transaction refusal note or cbm_daemon_ipc_validation_detail().
The remedy was also wrong for the reported shape. `icacls <dir> /remove:g <sid>`
cannot remove an INHERITED ACE, and the stock C:\ grant for Authenticated Users
reaches every new child directory exactly that way -- so a reporter following
our advice watches the command succeed and the refusal persist. The refusal now
says whether the ACE was inherited, and the advice names
`icacls <dir> /inheritance:r /grant:r "%USERNAME%":(OI)(CI)F` for that case.
No safety check is relaxed. An install directory writable by other accounts is
still refused; it is now refused in terms the reader can act on.
The new contract test guards the CLASS rather than this one call site: any
emitter of a refusal constant that bypasses the attributing helper fails it,
which is what would have caught this. It fails on the previous tree naming
src/cli/cli.c:750, plus the missing inherited-ACE report and the missing
/inheritance:r remedy.
Local: cli 314 passed; activation_transaction daemon_ipc daemon_bootstrap
daemon_version 110 passed, 1 skipped (user namespaces are Linux-only, runs on
the Linux leg); make -f Makefile.cbm lint-ci clean.
Reported by zaferavci1.
Refs #1856
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The LSA typedefs and GetProcAddress casts in the #1705 local-Administrator
resolver were formatted with a different clang-format; CI clang-format-20
(the lint leg) flagged the line-wrapping. Reformatted only those lines with
the CI binary; no logic change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Windows install/daemon runtime directories created by an elevated process are
owned by the built-in Administrator ACCOUNT (RID 500 under the local machine
account-domain SID), even when that account is renamed or disabled. cbm's
directory-owner/ACL trust refused it, breaking install/activation on such
machines.
#1705: resolve THIS machine's built-in Administrator SID via LSA
(LsaOpenPolicy(POLICY_VIEW_LOCAL_INFORMATION) +
LsaQueryInformationPolicy(PolicyAccountDomainInformation) ->
CreateWellKnownSid(WinAccountAdministratorSid, accountDomainSid)), cached once
per process, and accept it as a trusted owner/grantee in the daemon
(win_sid_trusted) and the CLI activation transaction. Resolution is
deliberately NOT IsWellKnownSid(WinAccountAdministratorSid) and NOT a
trailing-RID-500 match: both accept a FOREIGN S-1-5-21-*-500 (a domain
administrator, or another machine's built-in Administrator), which would be a
cross-machine trust escalation. Any LSA or synthesis failure caches "none" and
grants no tolerance (fail closed). owner-not-current-user (the exact-owner
target rule) is unchanged.
#2023/#1686: an owner-trust refusal now names the offending owner SID (the path
was already carried), so a terse "status -3, os 0" becomes actionable.
Scope notes: on a domain controller -- which has no separate local SAM, so its
account domain IS the AD domain -- this trusts the domain Administrator, by
design (on a DC that account is the machine-equivalent top admin). An
un-sysprepped cloned Windows image shares the machine account-domain SID, so a
sibling clone's built-in Administrator would be non-foreign; this is inherent
to machine-SID identity.
Verified on a native Windows (arm64) VM: compiles under the sanitized build;
the daemon_ipc SID-trust test passes (local RID-500 trusted, a fabricated
foreign S-1-5-21-*-500 rejected, BUILTIN\Administrators trusted) at 35 passed /
0 failed with the fix, and 34 passed / 1 failed (ASSERT(local_ok)) with the
trust clause reverted -- the test binds precisely to the fix. #1856 (stock
ancestor ACEs): the Windows ancestor-ACL guards already pass; no defect
reproduced, no code change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
cbm_remove_indexes unlinked each *.db and *.db.tmp but never the SQLite
sidecars (<db>-wal/-shm/-journal), so `cbm update`, `cbm uninstall`, and
`install --reset-indexes` (the four callers of the single deletion
chokepoint) left orphan -wal/-shm files behind. Call the existing
wide-path-safe cbm_remove_db_sidecars() helper on both the live and staged
db paths after the .db unlink; it is idempotent and ENOENT-tolerant, so an
orphan -wal that outlived its .db is still cleaned. Sidecars are not
indexes, so the removed-count is unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
#1954's config-symlink follow support stored its opted-in roots in a
_Thread_local char[4][4096] -- 16 KiB in the binary's static TLS block.
On glibc/x86-64 that block is carved out of every thread's stack, including
the parent-death watchdog thread, created with a deliberately tiny 64 KiB
stack (PARENT_WATCHDOG_STACK_SIZE, main.c). The extra 16 KiB pushed that
thread's pthread_create past the static-TLS + TCB + guard budget, so it
failed with EINVAL: client_start_parent_watchdog() returned false and the
stdio server exited before answering initialize. The parent-death watchdog
regression test (#406/#407, scripts/test.sh Step 5) went red on every Linux
x86-64 leg while macOS/arm64 stayed green -- its TLS is not stack-carved.
Move the buffer off the static TLS block: keep a _Thread_local POINTER
(8 bytes) and calloc the 16 KiB lazily on first add, freeing it in
follow_clear(). Per-thread isolation and the exact API/semantics are
unchanged; the watchdog thread's static-TLS budget is restored. main.c and
the watchdog path are untouched.
Evidence (local, Linux x86-64 gcc under QEMU + Linux arm64):
- PT_TLS MemSiz 59,889 B -> 43,505 B (-16,384 B, exactly the array).
- parent-watchdog test 10/10 red -> 10/10 green; red again on revert.
- origin/main 10/10 green on the same env (regression is PR-specific).
- config editor + cli suites: 522/522 ASan/UBSan (macOS),
523/523 ASan/LeakSanitizer (Linux arm64), no leak.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Indexing accepts whatever a repository contains. A tree carrying a vendored
monorepo, a generated dump, or a runaway build directory is discovered in
full, and the first sign of trouble is a host under memory pressure with
nothing that attributes it to indexing.
Add two opt-in limits evaluated during discovery against accepted source
files only: index_max_files and index_max_source_mb. Both default to off, so
nothing changes until an operator sets one. Crossing a limit fails the whole
attempt with a structured resource_limit_exceeded result naming the resource,
the observed value and the limit; no partial graph is published, and an
existing serving index keeps answering.
Limits are read from the CLI-managed _config.db and are not MCP request
arguments. A supervised parent replaces any caller-supplied policy before
spawning its worker, and the worker rejects a missing or incomplete contract,
so the CLI, the daemon and the supervised worker all enforce the same
decision.
Both keys reach an operator through the existing config get/set/list/reset
with no new subcommand. `set` suppresses its own generic message for them
because the policy writer names the precise reason -- so that writer speaks
on every failure it can return, including a validated value whose write then
fails on a database that cannot be written. Exiting non-zero in silence is
not an acceptable answer from a CLI.
The two shell regressions that hand-roll the supervisor's worker argv carry
that contract as well. Without it the worker exits before either guard can
observe anything, and the guard would go quietly vacuous.
Signed-off-by: 刘冲 <mail@liuchong.dev>
CI's clang-format-20 flagged continuation-line alignment in the new
OpenHands MCP install/uninstall helpers (cbm_build_openhands_mcp_entry,
cbm_upsert_openhands_settings_mcp, cbm_remove_openhands_settings_mcp,
openhands_update_profile_refs). Reformat only those regions with
clang-format-20 to satisfy the lint gate; whitespace-only, no behaviour
change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
On Windows, install registers the install directory in the persistent
HKCU\Environment\Path, but uninstall never removed it — so every
install/uninstall cycle left its entry behind and the user PATH grew
without bound (a real test host reached ~8000 chars, emptying the
effective PATH and breaking tooling).
The reported non-ASCII "mojibake" is historical, not a current-code bug.
Both install paths already write the value through the UTF-16 registry
API: install.ps1 (shipped in each release archive) runs `cbm install`,
which calls RegSetValueExW(L"Path", ...) — git history shows no ANSI
(RegSetValueExA / SetEnvironmentVariableA) predecessor — and
setup-windows.ps1 writes via .NET [Environment]::SetEnvironmentVariable
(Unicode) with an ASCII-only install dir. The only path that handles a
non-ASCII directory is the wide-correct C path, so current code cannot
produce a garbled entry; the garbage the reporter saw was left by an
older binary. No code change is needed for the encoding itself.
The real defect is the missing uninstall-side removal. This adds
cli_remove_windows_user_path(), the counterpart to
cli_ensure_windows_user_path(): it opens the same key, walks the
';'-separated segments, and drops exactly the ones equal to the install
dir using the same case- and trailing-separator-insensitive comparison
the install `present` scan uses, so it removes precisely what install
would have de-duplicated. Every other segment is preserved
byte-for-byte, the value kind (REG_SZ / REG_EXPAND_SZ) is preserved, and
a registry error is a warning rather than a hard failure so a user is
never blocked from finishing an uninstall over a cosmetic PATH edit.
Removing the entry on uninstall also stops accumulation on reinstall: a
garbled historical entry defeats the install de-dup and gets re-added,
so taking our own entry back out keeps the value bounded.
Testing:
- tests/test_cli.c: a hermetic Windows unit test drives the append and
remove logic against a GUID-scoped scratch key via the existing
CBM_TEST_WINDOWS_USER_PATH_RUN_ID seam, never the developer's live
HKCU\Environment\Path. A non-ASCII install dir doubles as a mojibake
guard (a broken UTF-8->UTF-16 round-trip would fail the match and leak
a duplicate). It asserts append, idempotent reinstall, exact removal
from the tail and the middle, idempotent removal, and byte-for-byte
survival of unrelated entries.
- tests/test_smoke_fixture_contract.sh: a macOS-runnable source contract
that fails when the removal is absent and passes with it, proving the
wiring binds (verified RED-on-revert).
The Windows-only registry code is #ifdef _WIN32; its compile and the
behavioral unit test are verified by CI's Windows leg (the local Windows
VM is down). Pre-existing mojibaked entries from old binaries are a
separate one-time cleanup, out of scope here, and setup-windows.ps1's
own lack of an uninstall mode is noted as a possible follow-up.
Fixes#2117
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
install wrote only part of the OpenHands config. Register the server globally
under ~/.openhands/settings.json -> mcp_config {transport:stdio, command, enabled}
(owned-entry, unrelated content preserved), and add "codebase-memory-mcp" to
each ~/.openhands/agent-profiles/<p>.json mcp_server_refs array (null -> [it],
existing -> appended once). uninstall removes exactly what it added.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
config_edit_path.c's edit_path_note is defined before the '#ifndef _WIN32'
follow-resolver block but only called inside it, so the Windows build compiled
it dead and failed under -Werror,-Wunused-function (pr-smoke windows-latest).
Windows never follows a symlinked config, so guard the recorder with the same
condition as its callers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
The rendezvous directory the CLI's activation guard talks to is per OS
account (service.h), not per HOME/CBM_CACHE_DIR: every daemon owned by
one user meets at the same endpoint, and only the cache fingerprint
carried in the cohort identity separates one HOME/CBM_CACHE_DIR
namespace from another. `cli_activation_production_reserve` used
`cbm_version_cohort_reserve_for_mutation`, which treats any lifetime-
lock holder at that shared endpoint as the daemon this activation must
drain, with no cache-fingerprint awareness at all. An `install` run
against a second HOME (a sandbox, a second profile, `--skip-binary`
into an alternate CBM_CACHE_DIR) therefore reached the LIVE host
daemon at the shared endpoint and drained it — disconnecting every one
of the host's real MCP clients — even though nothing the sandboxed
install touched belonged to the host's cache namespace.
The fix adds `cli_activation_resolve_scope`, a non-blocking probe via
the already cache-fingerprint-aware `cbm_version_cohort_acquire`, run
before the blind drain path. It distinguishes three cases from the
resolved target of THIS activation:
- nothing to quiesce at all: a `--skip-binary` install that also
resets no index publishes nothing, so no session anywhere needs to
stop (`quiesce_required` on `cli_activation_guard_scoped`, plumbed
from `cbm_cmd_install`'s own binary/index-reset decision);
- the active cohort's cache fingerprint doesn't match this
activation's target cache: a foreign namespace, left untouched;
- same cache fingerprint (or no active cohort): this activation
really does replace that daemon, so the existing drain/quiesce
path runs unchanged, including the barrier wait when another
activation already holds maintenance.
Verified against a real forked daemon (a live runtime service with one
committed client) standing in for the host: an install into a foreign
HOME/CBM_CACHE_DIR (`--skip-binary` and a full binary install) leaves
that daemon serving with its client still attached, while an install
that targets the host's own namespace still drains it and the
activation audit still names the client it disconnected.
New/updated tests in tests/test_cli.c (cli suite):
- cli_install_skip_binary_into_foreign_home_never_drains_host_cohort
- cli_install_binary_into_foreign_home_never_drains_host_cohort
- cli_install_into_host_namespace_still_drains_host_cohort (regression)
- cli_install_skip_binary_unchanged_in_host_namespace_quiesces_nothing
- cli_activation_quiesce_does_not_wait_on_bootstrap_startup updated to
carry a real cache fingerprint for its participant, now that scope
is fingerprint-gated
src/mcp/index_supervisor.{c,h}: CBM_CLI_ENABLE_TEST_API-gated setter
so the fixture's forked daemon can carry the same build fingerprint
the guard captures from the supervisor.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Review of the previous head found the global rule unsound for one caller:
cbm_mkdir_p also creates <repo_path>/.codebase-memory inside the checkout
being indexed, and git creates symlinks owned by whoever cloned, so a
repository shipping `.codebase-memory -> ~/.ssh` would have been followed
by the artifact writer. "User-owned" says nothing about "user-intended"
once the path root is a checkout.
cbm_mkdir_p keeps the strict walk (root-owned links only). The new
cbm_mkdir_p_ex(path, mode, policy) with CBM_MKDIR_FOLLOW_OWNED also
follows a symlink owned by the invoking user, and only callers whose
paths are rooted in the user's own configuration opt in: the installer's
agent roots, skills, instruction files and hook directories, the
JSON-like config editor's parent creation, the cache root, <cache>/logs,
the UI config directory and the DB parent. The artifact directory inside
the checkout and the ~/.local/bin creations stay strict; the activation
transaction re-walks the latter with its own no-follow policy anyway.
What a follow lands on is now checked too: the opened target must be a
directory owned by root or the invoking user and not world-writable
unless sticky, so a link swapped between the fstatat and the openat
cannot steer the walk into a directory an attacker controls or one where
anyone can pre-plant entries. This applies to root-owned links as well;
macOS /private/tmp is root-owned and sticky and still passes. Refusal
restores the first openat's errno.
Tests: the plain walk refuses an own link that the opted-in walk
follows; targets that are world-writable without the sticky bit are
refused; as root, a foreign-owned target and a user-owned link are both
refused, each followed by restoring ownership and walking again; the
identical `.codebase-memory -> <user-owned dir>` link is refused by the
plain walk and followed once opted in; cbm_artifact_export refuses a
checked-in artifact-dir link and leaves its target empty; the installer
test gains the euid-0 leg with home, link and target owned by a non-root
uid. Under euid 0 the tests demote their links to a non-root owner, since
root-owned links are trusted by both walks.
Refs #1722
Signed-off-by: TheStranjer <791672+TheStranjer@users.noreply.github.com>
Every config editor (JSON-like, TOML, YAML, text) opens with O_NOFOLLOW
and refuses symlinks, so a link planted at ~/.cursor/mcp.json cannot
point a privileged writer at another file. Users keep exactly those
files as symlinks into a dotfiles checkout; the blanket refusal made
install and uninstall fail on them and, before the previous commit,
turned uninstall into data loss (#1954).
Decision C: a symlink the INVOKING user owns, resolving to a regular
file the same user owns, is followed — as an explicit opt-in of the
agent-configuration writers only. cbm_install_agent_configs and the
uninstall activation register the user's configuration roots (HOME,
XDG_CONFIG_HOME) for the duration of their work; a link whose own
directory is outside every registered root, or any link seen by another
editor caller, stays refused, so repository paths never follow.
One shared helper (src/cli/config_edit_path.c) runs the race-safe
sequence: lstat(link) → owner == invoking uid (a root process follows
root-owned links only) → realpath → the target's parent directory
opened as a dirfd (O_DIRECTORY|O_NOFOLLOW; owned by the invoking user
or root, not writable by others unless sticky) → openat(dirfd, base,
O_NOFOLLOW) → fstat: regular file, same owner, link count 1, and the
same (dev, ino) as stat(realpath). Each editor reads through that
validated descriptor, stages its temp file with openat(dirfd, …,
O_CREAT|O_EXCL|O_NOFOLLOW), runs its pre-publish snapshot comparison
against the resolved path, and commits with renameat on the pinned
dirfd, so the link itself is never replaced. Every other link is refused
with an observable reason (surfaced in the uninstall diagnostics):
another owner, root over a non-root link, dangling, a directory or
special target, a hard-linked or swapped target, a foreign or
world-writable parent. Whether the target must also stay under a root
is a pending user decision behind CBM_CONFIG_EDIT_TARGET_UNDER_ROOT
(default: anywhere the user owns). Windows never follows reparse points.
Tests: each editor suite gains a same-owner link edited in place under
the opt-in (link preserved and still pointing at the same target, temp
file staged beside the target, bytes identical to the same edit on a
plain file, no stray temp files) and keeps its refusal cases; the
JSON-like suite adds no-opt-in, outside-root, dangling, directory,
identity-skew and world-writable-parent refusals. The foreign-owner and
root-run refusals move the observer through a test seam (a test cannot
create a foreign-owned link without root; a real root run demotes the
link first). test_cli.c drives `uninstall -y` end to end through a
symlinked ~/.cursor/mcp.json.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
A single agent configuration the editors refuse to touch (the reporter's
~/.cursor/mcp.json was a symlink into a dotfiles checkout) aborted the
uninstall activation BEFORE index and binary removal. `uninstall -y`
exited 1 and left a 300 MB executable plus the whole cache behind — the
data-loss half of #1954 — while the transcript still read "removed".
Agent-config failures are now collected instead of gating: every cleanup
runs, the indexes and the executable go, and the command ends with one
list of the files that could not be cleaned (agent, operation, path,
observed target) and a non-zero exit. The existing per-error stderr line
is unchanged.
Two tests encoded the old hostage contract ("binary must survive failed
cleanup") and move to the new one. The new test drives `uninstall -y`
against a sandbox HOME with a dangling ~/.cursor/mcp.json link and
asserts binary + index gone, exit non-zero, and the file named on
stderr; on main it fails at ASSERT(binary_gone). (#1954)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Hermes `install` failed with `op=pre_llm_hook_install` whenever config.yaml
held a PLAIN scalar with a `>` or `|` anywhere after the value start —
`probe: kaomoji face >w< here` — while the identical string double-quoted,
or a mid-value `*`, installed fine. An agent-config failure aborts
activation, so the whole install exited 1 and user-PATH registration was
skipped although eleven other agents had configured cleanly. Bisected by
@ukind, key by key and persona by persona, down to two persona strings
(#1924, continuing #1631/#1656).
Root cause: yaml_sequence_line_has_unsupported — the document validator on
the mapping-sequence path that the hook upsert/remove uses — ran a second
scan after the shared yaml_range_has_unsupported_ex and returned
"unsupported" for `[`, `]`, `|` and `>` at ANY position of a plain scalar.
The mapping-body path (mcp_servers) never had that check, which is exactly
why the mcp op succeeded and only the hook op failed on the same file.
Fix: the positional rule the `*`/`&` fix (v0.10.4) and the quote fix
(#1631, v0.10.6) already use — an indicator counts only where a NODE begins
(range start, after a mapping colon, after a block-sequence dash).
Interior ones are text. `probe: >`, `probe: |`, `probe: >-` and
`probe: [a, b]` still begin a value and stay unsupported, byte-identically.
`[`/`]` get the same treatment on purpose: in block context they are
ordinary characters inside a plain scalar (`a: b[0]` parses as the string
`b[0]`), a value-start `[` still opens a flow sequence and stays refused
(the #1631 line: exact `[]` accepted, non-empty flow sequences unsupported),
and the mapping-body path already accepted them mid-value — leaving the
sequence scan stricter would keep the mcp-op-passes / hook-op-fails
asymmetry this issue is about. Known conservative residue, shared with the
`*` rule: a plain-scalar CONTINUATION line that itself starts with one of
these characters is still refused (nothing precedes it on its line).
Tests (tests/test_config_yaml_edit.c): three accept cases run both real
Hermes ops (mcp_servers + pre_llm_call) against a persona corpus with a
mid-value `>`, `|` and `[]` and require the original document to survive as
an untouched prefix; two pins keep value-start block scalars and a non-empty
flow sequence refused with the file byte-identical. RED on the unfixed
editor: 61 passed, 3 failed (`yaml_hermes_hook_upsert(&fixture) == -1`);
GREEN: 64 passed; RED again with the production change reverted.
config_yaml_edit 64 · yaml 73 · cli 306 — 443 passed, 0 failed (macOS,
ASan/UBSan test build).
Not in this change: the refusal still reaches the user as
`op=pre_llm_hook_install path=... (target: regular file, N bytes)` with no
reason. The editor collapses every one of its ~236 YAML_ERROR sites into
CBM_YAML_IDENTITY_EDIT_ERROR, so naming the refusing rule needs a detailed
variant with a typed failure out-parameter, the shape the Codex TOML hook
path already has (cbm_toml_codex_hook_failure_t +
record_agent_config_error_with_reason). Follow-up.
Fixes#1924.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
`uninstall` failed with rc 1 on any macOS where Hermes is installed:
Hermes is detected by its binary on PATH, not by its home, so a HOME
without `.hermes/` (every test HOME, and a fresh user's) reached the
Hermes uninstall ops with `~/.hermes/config.yaml` absent. The JSON
removers already treat a missing config as "nothing to remove"; the YAML
removers acquired the lock first, and the lock directory is created
beside the target — with the parent directory absent that failed, the
adapter recorded
error: agent_config agent=Hermes op=pre_llm_hook_uninstall ... (target: does not exist or cannot be inspected)
error: agent_config agent=Hermes op=mcp_uninstall ...
and uninstall refused to remove the executable and the indexes. This is
what made cli_uninstall_quiesces_active_cohort_before_removing_binary_and_index
fail on such a machine (7837 passed, 1 failed on a pristine main).
All four YAML removers short-circuit on a genuinely absent path and
create nothing: lstat + ENOENT on POSIX, and CreateFileW with
FILE_FLAG_OPEN_REPARSE_POINT on Windows, mirroring toml_read_file.
Opening the reparse point rather than following it keeps a dangling
symlink out of the short-circuit on both platforms, so it still reaches
the reader and its byte-identical rejection.
The regression test covers the four removers and runs on every platform:
it asserts "nothing was created" through cbm_path_info_utf8 rather than
lstat, so the Windows branch is exercised rather than compiled out.
Signed-off-by: David Gil <dgilperez@gmail.com>
Tool definitions no longer carry a separate title (the name is the MCP
display fallback), so the accessor main added for the pi adapter has no
field to read. The adapter already used the name when no title existed.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
- Fix client adapter passing output?.args instead of input?.args in tool.execute.after hook (closes#1737)
- Add tool_search and tool_search_regex permissions to OpenCode subagent profiles so deferred MCP tools can be discovered and unblocked
- Add regression tests for OpenCode adapter hook args and subagent profile permissions
Signed-off-by: Choti Wongbussakorn <126886556+Chewji9875@users.noreply.github.com>