Files
codebase-memory-mcp/tests/test_config_yaml_edit.c
T
Martin Vogel ae578bc1cc fix(install): Hermes hook edit no longer refuses unrelated YAML sections (#2209)
`install` on a Hermes Agent config wrote the mcp_servers entry and the skill,
then failed op=pre_llm_hook_install on the same config.yaml. The stock Hermes
config (cli-config.yaml.example) reproduces it on its own.

Root cause: the nested-sequence editor behind the hooks.pre_llm_call item
validated EVERY line of the document and refused any flow collection or block
scalar anywhere. The stock config carries flow sequences in sections the hook
edit never reads or rewrites (`platform_toolsets:` -> `cli: [hermes-cli]`,
reporters' `plugins:` -> `enabled: [...]`). The mapping-entry editor that
writes mcp_servers into the same file already treats other top-level sections
as opaque user content, which is why that op succeeded.

Fix: yaml_sequence_validate_document now validates only the top-level section
the edit descends into (the first sequence_path key). The root mapping is
still validated at parse time, so section boundaries stay sound. An absent
section is appended as a new top-level key and touches no existing line.
Inside the edited `hooks` subtree the fail-closed rules are unchanged. This
also unblocks uninstall (op=pre_llm_hook_uninstall), which shares the analyzer.

Tests: new config_yaml_edit case with foreign flow sequences plus `|`/`>`
block scalars (install, byte-idempotent reinstall, remove). The two #1924
"still refuses" pins now place the unsupported construct inside `hooks:`,
where the refusal still applies byte-identically.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-09-25 22:56:39 +02:00

2257 lines
93 KiB
C

/*
* test_config_yaml_edit.c — Conservative YAML config editor tests.
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h"
#include "test_framework.h"
#include "test_helpers.h"
#define CBM_YAML_ENABLE_TEST_API 1
#include <cli/config_yaml_edit.h>
#define CBM_CONFIG_EDIT_PATH_ENABLE_TEST_API 1
#include <cli/config_edit_path.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <sys/stat.h>
#include <unistd.h>
#endif
/* Expected test seams for races after final verification and failures after
* lock-object creation. Production code must not expose them outside the
* test API build. */
void cbm_yaml_set_prepublish_hook_for_testing(cbm_yaml_precommit_test_hook_t hook, void *context);
#ifndef _WIN32
typedef void (*cbm_yaml_lock_postcreate_test_hook_t)(const char *lock_path, void *context);
void cbm_yaml_set_lock_postcreate_hook_for_testing(cbm_yaml_lock_postcreate_test_hook_t hook,
void *context);
#endif
typedef struct {
char dir[512];
char path[768];
} yaml_fixture_t;
static int yaml_fixture_init(yaml_fixture_t *fixture, const char *initial) {
char *temp = th_mktempdir("cbm_yaml_edit");
if (!temp) {
return -1;
}
int dir_len = snprintf(fixture->dir, sizeof(fixture->dir), "%s", temp);
int path_len = snprintf(fixture->path, sizeof(fixture->path), "%s/config.yaml", temp);
if (dir_len < 0 || (size_t)dir_len >= sizeof(fixture->dir) || path_len < 0 ||
(size_t)path_len >= sizeof(fixture->path)) {
th_cleanup(temp);
return -1;
}
return initial ? th_write_file(fixture->path, initial) : 0;
}
static char *yaml_read_alloc(const char *path) {
FILE *fp = fopen(path, "rb");
if (!fp || fseek(fp, 0L, SEEK_END) != 0) {
if (fp) {
fclose(fp);
}
return NULL;
}
long file_len = ftell(fp);
if (file_len < 0L || fseek(fp, 0L, SEEK_SET) != 0) {
fclose(fp);
return NULL;
}
size_t len = (size_t)file_len;
char *data = (char *)malloc(len + 1U);
if (!data) {
fclose(fp);
return NULL;
}
size_t read_len = fread(data, 1U, len, fp);
int close_rc = fclose(fp);
if (read_len != len || close_rc != 0) {
free(data);
return NULL;
}
data[len] = '\0';
return data;
}
static size_t yaml_count_occurrences(const char *text, const char *needle) {
size_t count = 0U;
size_t needle_len = strlen(needle);
const char *cursor = text;
while ((cursor = strstr(cursor, needle)) != NULL) {
count++;
cursor += needle_len;
}
return count;
}
static size_t yaml_temp_file_count(const yaml_fixture_t *fixture) {
cbm_dir_t *directory = cbm_opendir(fixture->dir);
if (!directory) {
return SIZE_MAX;
}
size_t count = 0U;
cbm_dirent_t *entry = NULL;
while ((entry = cbm_readdir(directory)) != NULL) {
if (strncmp(entry->name, "config.yaml.cbm-yaml-", strlen("config.yaml.cbm-yaml-")) == 0) {
count++;
}
}
cbm_closedir(directory);
return count;
}
static bool yaml_lock_released_state_is_safe(const char *path) {
struct stat state;
#ifdef _WIN32
return stat(path, &state) != 0;
#else
return lstat(path, &state) == 0 && S_ISREG(state.st_mode) && state.st_nlink == 1 &&
state.st_uid == geteuid() && (state.st_mode & 0777U) == 0600U &&
(state.st_mode & (S_ISUID | S_ISGID | S_ISVTX)) == 0;
#endif
}
static bool yaml_upsert_failed_unchanged(const char *path, const char *original) {
const char *block = " command: codebase-memory-mcp\n";
if (th_write_file(path, original) != 0 ||
cbm_yaml_upsert_mapping_entry(path, "mcp_servers", "codebase-memory", block) == 0) {
return false;
}
char *after = yaml_read_alloc(path);
bool unchanged = after && strcmp(after, original) == 0;
free(after);
return unchanged;
}
typedef struct {
const char *content;
const char *backup_path;
bool replace_identity;
int result;
} yaml_precommit_change_t;
typedef struct {
int competing_result;
bool lock_observed;
} yaml_lock_contention_t;
static void yaml_change_before_commit(const char *path, void *context) {
yaml_precommit_change_t *change = (yaml_precommit_change_t *)context;
if (change->replace_identity &&
(!change->backup_path || cbm_rename_replace(path, change->backup_path) != 0)) {
change->result = -1;
return;
}
change->result = th_write_file(path, change->content);
}
#ifndef _WIN32
static void yaml_make_lock_mode_unsafe(const char *lock_path, void *context) {
int *result = (int *)context;
*result = chmod(lock_path, 0755);
}
#endif
static void yaml_attempt_competing_edit(const char *path, void *context) {
yaml_lock_contention_t *contention = (yaml_lock_contention_t *)context;
char lock_path[1024];
int written = snprintf(lock_path, sizeof(lock_path), "%s.cbm-yaml.lock", path);
struct stat state;
bool expected_type = false;
if (written > 0 && (size_t)written < sizeof(lock_path) && stat(lock_path, &state) == 0) {
#ifdef _WIN32
expected_type = S_ISDIR(state.st_mode);
#else
expected_type = S_ISREG(state.st_mode);
#endif
}
contention->lock_observed = expected_type;
contention->competing_result = cbm_yaml_upsert_string_list_item(path, "read", "COMPETING.md");
contention->lock_observed = contention->lock_observed || contention->competing_result == -1;
}
TEST(config_yaml_edit_serializes_two_editor_instances) {
const char *original = "model: fast\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
yaml_lock_contention_t contention = {
.competing_result = 0,
.lock_observed = false,
};
cbm_yaml_set_precommit_hook_for_testing(yaml_attempt_competing_edit, &contention);
int result = cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md");
cbm_yaml_set_precommit_hook_for_testing(NULL, NULL);
ASSERT_EQ(result, 0);
ASSERT(contention.lock_observed);
ASSERT_EQ(contention.competing_result, -1);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, " - \"AGENTS.md\"\n"));
ASSERT_NULL(strstr(after, "COMPETING.md"));
free(after);
char lock_path[1024];
ASSERT(snprintf(lock_path, sizeof(lock_path), "%s.cbm-yaml.lock", fixture.path) > 0);
ASSERT(yaml_lock_released_state_is_safe(lock_path));
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_missing_target_appearance_fails_without_replace) {
const char *concurrent = "model: concurrently-created\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
yaml_precommit_change_t change = {
.content = concurrent,
.backup_path = NULL,
.replace_identity = false,
.result = -1,
};
cbm_yaml_set_precommit_hook_for_testing(yaml_change_before_commit, &change);
int result = cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md");
cbm_yaml_set_precommit_hook_for_testing(NULL, NULL);
ASSERT_EQ(change.result, 0);
ASSERT_EQ(result, -1);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, concurrent);
free(after);
char lock_path[1024];
ASSERT(snprintf(lock_path, sizeof(lock_path), "%s.cbm-yaml.lock", fixture.path) > 0);
ASSERT(yaml_lock_released_state_is_safe(lock_path));
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_rejects_stale_content_and_cleans_temp) {
const char *original = "model: fast\n";
const char *concurrent = "model: concurrent\n";
const char *block = " command: codebase-memory-mcp\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
yaml_precommit_change_t change = {
.content = concurrent,
.backup_path = NULL,
.replace_identity = false,
.result = -1,
};
cbm_yaml_set_precommit_hook_for_testing(yaml_change_before_commit, &change);
int result =
cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory", block);
cbm_yaml_set_precommit_hook_for_testing(NULL, NULL);
ASSERT_EQ(change.result, 0);
ASSERT_EQ(result, -1);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, concurrent);
free(after);
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_rejects_stale_identity_with_same_content) {
const char *original = "model: fast\n";
const char *block = " command: codebase-memory-mcp\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
char backup[sizeof(fixture.path) + 32U];
ASSERT(snprintf(backup, sizeof(backup), "%s/original.yaml", fixture.dir) > 0);
yaml_precommit_change_t change = {
.content = original,
.backup_path = backup,
.replace_identity = true,
.result = -1,
};
cbm_yaml_set_precommit_hook_for_testing(yaml_change_before_commit, &change);
int result =
cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory", block);
cbm_yaml_set_precommit_hook_for_testing(NULL, NULL);
ASSERT_EQ(change.result, 0);
ASSERT_EQ(result, -1);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, original);
free(after);
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(backup), 0);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_existing_target_swap_after_check_preserves_winner) {
const char *original = "model: fast\n";
const char *winner = "model: winner\n";
const char *block = " command: codebase-memory-mcp\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
char backup[sizeof(fixture.path) + 32U];
ASSERT(snprintf(backup, sizeof(backup), "%s/original.yaml", fixture.dir) > 0);
yaml_precommit_change_t race = {
.content = winner,
.backup_path = backup,
.replace_identity = true,
.result = -1,
};
cbm_yaml_set_prepublish_hook_for_testing(yaml_change_before_commit, &race);
int result =
cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory", block);
cbm_yaml_set_prepublish_hook_for_testing(NULL, NULL);
ASSERT_EQ(race.result, 0);
ASSERT_EQ(result, -1);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, winner);
free(after);
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(backup), 0);
char lock_path[1024];
ASSERT(snprintf(lock_path, sizeof(lock_path), "%s.cbm-yaml.lock", fixture.path) > 0);
ASSERT(yaml_lock_released_state_is_safe(lock_path));
th_cleanup(fixture.dir);
PASS();
}
#ifndef _WIN32
TEST(config_yaml_edit_lock_postcreate_verification_failure_preserves_unsafe_sidecar) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, "model: fast\n"), 0);
int mutation_result = -1;
cbm_yaml_set_lock_postcreate_hook_for_testing(yaml_make_lock_mode_unsafe, &mutation_result);
int result = cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md");
cbm_yaml_set_lock_postcreate_hook_for_testing(NULL, NULL);
ASSERT_EQ(mutation_result, 0);
ASSERT_EQ(result, -1);
char lock_path[1024];
ASSERT(snprintf(lock_path, sizeof(lock_path), "%s.cbm-yaml.lock", fixture.path) > 0);
struct stat state;
ASSERT_EQ(lstat(lock_path, &state), 0);
ASSERT(S_ISREG(state.st_mode));
ASSERT_EQ(state.st_uid, geteuid());
ASSERT_EQ(state.st_mode & 0777U, 0755U);
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_reuses_persistent_safe_lock_sidecar) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, "model: fast\n"), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), 0);
char lock_path[1024];
ASSERT(snprintf(lock_path, sizeof(lock_path), "%s.cbm-yaml.lock", fixture.path) > 0);
ASSERT(yaml_lock_released_state_is_safe(lock_path));
struct stat first;
ASSERT_EQ(lstat(lock_path, &first), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "CONVENTIONS.md"), 0);
ASSERT(yaml_lock_released_state_is_safe(lock_path));
struct stat second;
ASSERT_EQ(lstat(lock_path, &second), 0);
ASSERT_EQ(first.st_dev, second.st_dev);
ASSERT_EQ(first.st_ino, second.st_ino);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "AGENTS.md"));
ASSERT_NOT_NULL(strstr(after, "CONVENTIONS.md"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_rejects_symlink_lock_sidecar) {
const char *original = "model: fast\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
char lock_path[1024];
char target_path[1024];
ASSERT(snprintf(lock_path, sizeof(lock_path), "%s.cbm-yaml.lock", fixture.path) > 0);
ASSERT(snprintf(target_path, sizeof(target_path), "%s/foreign-lock", fixture.dir) > 0);
ASSERT_EQ(th_write_file(target_path, "foreign\n"), 0);
ASSERT_EQ(symlink(target_path, lock_path), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), -1);
struct stat link_state;
ASSERT_EQ(lstat(lock_path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, original);
free(after);
char *target = yaml_read_alloc(target_path);
ASSERT_NOT_NULL(target);
ASSERT_STR_EQ(target, "foreign\n");
free(target);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_rejects_hard_linked_lock_sidecar) {
const char *original = "model: fast\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
char lock_path[1024];
char source_path[1024];
ASSERT(snprintf(lock_path, sizeof(lock_path), "%s.cbm-yaml.lock", fixture.path) > 0);
ASSERT(snprintf(source_path, sizeof(source_path), "%s/foreign-lock", fixture.dir) > 0);
ASSERT_EQ(th_write_file(source_path, "foreign\n"), 0);
ASSERT_EQ(chmod(source_path, 0600), 0);
ASSERT_EQ(link(source_path, lock_path), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), -1);
struct stat source_state;
struct stat lock_state;
ASSERT_EQ(lstat(source_path, &source_state), 0);
ASSERT_EQ(lstat(lock_path, &lock_state), 0);
ASSERT_EQ(source_state.st_ino, lock_state.st_ino);
ASSERT_EQ(source_state.st_nlink, 2);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, original);
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_rejects_unsafe_mode_lock_sidecar) {
const char *original = "model: fast\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
char lock_path[1024];
ASSERT(snprintf(lock_path, sizeof(lock_path), "%s.cbm-yaml.lock", fixture.path) > 0);
ASSERT_EQ(th_write_file(lock_path, "foreign\n"), 0);
ASSERT_EQ(chmod(lock_path, 0644), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), -1);
struct stat state;
ASSERT_EQ(lstat(lock_path, &state), 0);
ASSERT(S_ISREG(state.st_mode));
ASSERT_EQ(state.st_mode & 0777U, 0644U);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, original);
free(after);
th_cleanup(fixture.dir);
PASS();
}
#endif
TEST(config_yaml_edit_rejects_non_regular_path) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
ASSERT(cbm_mkdir_p(fixture.path, 0755));
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), -1);
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
th_cleanup(fixture.dir);
PASS();
}
#ifndef _WIN32
TEST(config_yaml_edit_rejects_symlinks_without_touching_target) {
const char *original = "model: safe\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
char target[sizeof(fixture.path) + 32U];
ASSERT(snprintf(target, sizeof(target), "%s/target.yaml", fixture.dir) > 0);
ASSERT_EQ(th_write_file(target, original), 0);
ASSERT_EQ(symlink(target, fixture.path), 0);
/* Foreign-owned link (observer moved by the test seam): still refused. */
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.dir), 0);
cbm_config_edit_path_set_invoking_uid_for_test((unsigned)geteuid() + 1U, 1);
int foreign_rc = cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md");
cbm_config_edit_path_set_invoking_uid_for_test(0U, 0);
cbm_config_edit_path_follow_clear();
ASSERT_EQ(foreign_rc, -1);
struct stat link_state;
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
char *after = yaml_read_alloc(target);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, original);
free(after);
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_unlink(target), 0);
th_cleanup(fixture.dir);
PASS();
}
/* Decision C (#1954): a user-owned symlink is edited through — the link
* survives and the target carries the bytes the same edit yields on a plain file. */
TEST(config_yaml_edit_follows_user_owned_symlink_in_place) {
const char *original = "model: safe\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
char target[sizeof(fixture.path) + 32U];
char control[sizeof(fixture.path) + 32U];
ASSERT(snprintf(target, sizeof(target), "%s/target.yaml", fixture.dir) > 0);
ASSERT(snprintf(control, sizeof(control), "%s/control.yaml", fixture.dir) > 0);
ASSERT_EQ(th_write_file(target, original), 0);
ASSERT_EQ(th_write_file(control, original), 0);
ASSERT_EQ(symlink("target.yaml", fixture.path), 0);
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.dir), 0);
int through_rc = cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md");
int plain_rc = cbm_yaml_upsert_string_list_item(control, "read", "AGENTS.md");
if (through_rc != 0 || plain_rc != 0) {
cbm_config_edit_path_follow_clear();
}
ASSERT_EQ(through_rc, 0);
ASSERT_EQ(plain_rc, 0);
struct stat link_state;
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
char *through = yaml_read_alloc(target);
char *plain = yaml_read_alloc(control);
ASSERT_NOT_NULL(through);
ASSERT_NOT_NULL(plain);
ASSERT_STR_EQ(through, plain);
ASSERT_NOT_NULL(strstr(through, "AGENTS.md"));
free(through);
free(plain);
int through_remove_rc = cbm_yaml_remove_string_list_item(fixture.path, "read", "AGENTS.md");
int plain_remove_rc = cbm_yaml_remove_string_list_item(control, "read", "AGENTS.md");
cbm_config_edit_path_follow_clear();
ASSERT_EQ(through_remove_rc, 0);
ASSERT_EQ(plain_remove_rc, 0);
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
through = yaml_read_alloc(target);
plain = yaml_read_alloc(control);
ASSERT_NOT_NULL(through);
ASSERT_NOT_NULL(plain);
ASSERT_STR_EQ(through, plain);
ASSERT_NULL(strstr(through, "AGENTS.md"));
free(through);
free(plain);
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_unlink(target), 0);
ASSERT_EQ(cbm_unlink(control), 0);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_rejects_dangling_symlink) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
char missing[sizeof(fixture.path) + 32U];
ASSERT(snprintf(missing, sizeof(missing), "%s/missing.yaml", fixture.dir) > 0);
ASSERT_EQ(symlink(missing, fixture.path), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), -1);
struct stat link_state;
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_preserves_owner_group_and_mode) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, "model: fast\n"), 0);
ASSERT_EQ(chmod(fixture.path, 0640), 0);
struct stat before;
ASSERT_EQ(stat(fixture.path, &before), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), 0);
struct stat after;
ASSERT_EQ(stat(fixture.path, &after), 0);
ASSERT_EQ(after.st_uid, before.st_uid);
ASSERT_EQ(after.st_gid, before.st_gid);
ASSERT_EQ(after.st_mode & 07777, before.st_mode & 07777);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_rejects_hard_links_without_splitting_identity) {
const char *original = "model: safe\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
char alias[sizeof(fixture.path) + 32U];
ASSERT(snprintf(alias, sizeof(alias), "%s/alias.yaml", fixture.dir) > 0);
ASSERT_EQ(link(fixture.path, alias), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), -1);
char *after = yaml_read_alloc(alias);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, original);
free(after);
ASSERT_EQ(yaml_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(alias), 0);
th_cleanup(fixture.dir);
PASS();
}
#endif
TEST(config_yaml_edit_encodes_dynamic_scalars_safely) {
char *encoded = NULL;
ASSERT_EQ(cbm_yaml_encode_double_quoted_scalar(
"C:\\Users\\Zo\xC3\xAB\\\xE4\xBB\xA3\xE7\xA0\x81 #1: \"tool\"", &encoded),
0);
ASSERT_NOT_NULL(encoded);
ASSERT_STR_EQ(encoded,
"\"C:\\\\Users\\\\Zo\xC3\xAB\\\\\xE4\xBB\xA3\xE7\xA0\x81 #1: \\\"tool\\\"\"");
free(encoded);
encoded = (char *)0x1;
ASSERT_EQ(cbm_yaml_encode_double_quoted_scalar("line one\nline two", &encoded), -1);
ASSERT_NULL(encoded);
ASSERT_EQ(cbm_yaml_encode_double_quoted_scalar("bad\x01"
"control",
&encoded),
-1);
ASSERT_NULL(encoded);
PASS();
}
TEST(config_yaml_edit_rejects_newline_list_items) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, "model: fast\n"), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "one\ntwo"), -1);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "one\rtwo"), -1);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, "model: fast\n");
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_absent_target_requires_safe_root_mapping) {
const char *cases[] = {
"- sequence-root\n",
"plain scalar root\n",
"{}\n",
"[]\n",
"<<: *defaults\n",
"? mcp_servers\n: {}\n",
"\"bad\\xFF\": value\n",
"%YAML 1.2\n---\nmodel: fast\n",
"model: fast\n---\nother: value\n",
"model: fast\n...\n",
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, cases[i]), 0);
ASSERT(yaml_upsert_failed_unchanged(fixture.path, cases[i]));
th_cleanup(fixture.dir);
}
yaml_fixture_t safe;
ASSERT_EQ(yaml_fixture_init(&safe, "model:\n fallbacks:\n - local\n"), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(safe.path, "mcp_servers", "codebase-memory",
" command: codebase-memory-mcp\n"),
0);
char *after = yaml_read_alloc(safe.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "model:\n fallbacks:\n - local\n"));
ASSERT_NOT_NULL(strstr(after, "mcp_servers:\n codebase-memory:\n"));
free(after);
th_cleanup(safe.dir);
PASS();
}
TEST(config_yaml_edit_rejects_semantic_target_key_aliases) {
const char *cases[] = {
"\"mcp\\x5fservers\":\n other:\n command: other\n",
"mcp_servers:\n \"codebase\\x2dmemory\":\n command: other\n",
"\"r\\x65ad\":\n - docs.md\n",
};
const char *block = " command: codebase-memory-mcp\n";
yaml_fixture_t section;
ASSERT_EQ(yaml_fixture_init(&section, cases[0]), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(section.path, "mcp_servers", "codebase-memory", block),
-1);
char *after = yaml_read_alloc(section.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, cases[0]);
free(after);
th_cleanup(section.dir);
yaml_fixture_t entry;
ASSERT_EQ(yaml_fixture_init(&entry, cases[1]), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(entry.path, "mcp_servers", "codebase-memory", block),
-1);
after = yaml_read_alloc(entry.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, cases[1]);
free(after);
th_cleanup(entry.dir);
yaml_fixture_t list;
ASSERT_EQ(yaml_fixture_init(&list, cases[2]), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(list.path, "read", "AGENTS.md"), -1);
after = yaml_read_alloc(list.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, cases[2]);
free(after);
th_cleanup(list.dir);
PASS();
}
TEST(config_yaml_edit_preserves_crlf_and_handles_no_final_newline) {
const char *block = " command: codebase-memory-mcp\n";
yaml_fixture_t crlf;
ASSERT_EQ(yaml_fixture_init(&crlf, "model: fast\r\n"), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(crlf.path, "mcp_servers", "codebase-memory", block), 0);
char *after = yaml_read_alloc(crlf.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, "model: fast\r\n"
"mcp_servers:\r\n"
" codebase-memory:\r\n"
" command: codebase-memory-mcp\r\n");
free(after);
th_cleanup(crlf.dir);
yaml_fixture_t no_final_newline;
ASSERT_EQ(yaml_fixture_init(&no_final_newline, "model: fast"), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(no_final_newline.path, "mcp_servers", "codebase-memory",
block),
0);
after = yaml_read_alloc(no_final_newline.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, "model: fast\n"
"mcp_servers:\n"
" codebase-memory:\n"
" command: codebase-memory-mcp\n");
free(after);
th_cleanup(no_final_newline.dir);
PASS();
}
TEST(config_yaml_edit_hermes_mapping_lifecycle) {
const char *initial = "# Hermes settings\n"
"model: fast\n"
"mcp_servers:\n"
" # preserve sibling\n"
" other:\n"
" command: other-mcp\n"
"theme: dark\n";
const char *first_block = " command: codebase-memory-mcp\n"
" args: [\"--stdio\"]\n";
const char *replacement_block = " command: /opt/codebase-memory-mcp\n"
" args: [\"--stdio\"]\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(
cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory", first_block),
0);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_NOT_NULL(strstr(installed, "# Hermes settings\n"));
ASSERT_NOT_NULL(strstr(installed, " # preserve sibling\n"));
ASSERT_NOT_NULL(strstr(installed, " other:\n command: other-mcp\n"));
ASSERT_NOT_NULL(strstr(installed, " codebase-memory:\n"
" command: codebase-memory-mcp\n"
" args: [\"--stdio\"]\n"));
ASSERT_NOT_NULL(strstr(installed, "theme: dark\n"));
ASSERT_EQ(yaml_count_occurrences(installed, " codebase-memory:\n"), 1);
ASSERT_EQ(
cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory", first_block),
0);
char *idempotent = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(idempotent);
ASSERT_STR_EQ(idempotent, installed);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory",
replacement_block),
0);
char *replaced = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(replaced);
ASSERT_NOT_NULL(strstr(replaced, " command: /opt/codebase-memory-mcp\n"));
ASSERT_NULL(strstr(replaced, " command: codebase-memory-mcp\n"));
ASSERT_NOT_NULL(strstr(replaced, " other:\n command: other-mcp\n"));
ASSERT_EQ(cbm_yaml_remove_mapping_entry(fixture.path, "mcp_servers", "codebase-memory"), 0);
char *removed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_STR_EQ(removed, initial);
free(removed);
free(replaced);
free(idempotent);
free(installed);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_hermes_creates_missing_section) {
const char *block = " command: codebase-memory-mcp\n"
" args: [\"--stdio\"]\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory", block),
0);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_STR_EQ(installed, "mcp_servers:\n"
" codebase-memory:\n"
" command: codebase-memory-mcp\n"
" args: [\"--stdio\"]\n");
ASSERT_EQ(cbm_yaml_remove_mapping_entry(fixture.path, "mcp_servers", "codebase-memory"), 0);
char *empty = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(empty);
ASSERT_STR_EQ(empty, "mcp_servers: {}\n");
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory", block),
0);
char *reinstalled = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(reinstalled);
ASSERT_STR_EQ(reinstalled, installed);
free(reinstalled);
free(empty);
free(installed);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_goose_extensions_preserve_siblings) {
const char *initial = "extensions:\n"
" shell:\n"
" type: builtin\n"
" telemetry:\n"
" enabled: false\n"
"ui: compact\n";
const char *block = " type: stdio\n"
" cmd: codebase-memory-mcp\n"
" args: []\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(fixture.path, "extensions", "codebase-memory", block),
0);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_NOT_NULL(strstr(installed, " shell:\n type: builtin\n"));
ASSERT_NOT_NULL(strstr(installed, " telemetry:\n enabled: false\n"));
ASSERT_NOT_NULL(strstr(installed, " codebase-memory:\n"
" type: stdio\n"
" cmd: codebase-memory-mcp\n"
" args: []\n"));
ASSERT_NOT_NULL(strstr(installed, "ui: compact\n"));
ASSERT_EQ(cbm_yaml_remove_mapping_entry(fixture.path, "extensions", "codebase-memory"), 0);
char *removed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_STR_EQ(removed, initial);
free(removed);
free(installed);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_goose_accepts_empty_flow_mapping_in_sibling_issue1673) {
const char *initial = "extensions:\n"
" demo:\n"
" enabled: true\n"
" type: stdio\n"
" cmd: /bin/true\n"
" args:\n"
" - --serve\n"
" envs: {}\n"
" env_keys: []\n"
" timeout: 300\n"
"GOOSE_THINKING_EFFORT: max\n";
const char *block = " type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n";
const char *expected = "extensions:\n"
" demo:\n"
" enabled: true\n"
" type: stdio\n"
" cmd: /bin/true\n"
" args:\n"
" - --serve\n"
" envs: {}\n"
" env_keys: []\n"
" timeout: 300\n"
" codebase-memory-mcp:\n"
" type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n"
"GOOSE_THINKING_EFFORT: max\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_owned_mapping_entry(fixture.path, "extensions", "codebase-memory-mcp",
block),
CBM_YAML_IDENTITY_EDIT_OK);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_STR_EQ(installed, expected);
free(installed);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_goose_still_rejects_nonempty_flow_mapping_issue1673) {
const char *initial = "extensions:\n"
" demo:\n"
" enabled: true\n"
" type: stdio\n"
" cmd: /bin/true\n"
" args: []\n"
" envs: {FOO: bar}\n"
" env_keys: []\n"
" timeout: 300\n";
const char *block = " type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_owned_mapping_entry(fixture.path, "extensions", "codebase-memory-mcp",
block),
CBM_YAML_IDENTITY_EDIT_ERROR);
char *unchanged = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(unchanged);
ASSERT_STR_EQ(unchanged, initial);
free(unchanged);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_goose_still_rejects_merge_key_with_empty_mapping_issue1673) {
const char *initial = "extensions:\n"
" demo:\n"
" type: stdio\n"
" cmd: /bin/true\n"
" <<: {}\n";
const char *block = " type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_owned_mapping_entry(fixture.path, "extensions", "codebase-memory-mcp",
block),
CBM_YAML_IDENTITY_EDIT_ERROR);
char *unchanged = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(unchanged);
ASSERT_STR_EQ(unchanged, initial);
free(unchanged);
th_cleanup(fixture.dir);
PASS();
}
/* ── #1631: legal YAML constructs in real Hermes configs the editor refused ──
*
* Each construct is distilled from the reporters' actual config.yaml files
* (iandol + galaxy gists) and was verified RED end-to-end via `install`
* against those files. The document validators must accept them; the owned
* edit still only appends/replaces our entry and preserves every original
* byte of the user's content. */
static const char *const yaml_hermes_block = " command: \"/opt/codebase-memory-mcp\"\n";
static int yaml_hermes_upsert(const yaml_fixture_t *fixture) {
return cbm_yaml_upsert_owned_mapping_entry(fixture->path, "mcp_servers", "codebase-memory-mcp",
yaml_hermes_block);
}
/* Both real Hermes ops: mcp_install (owned mapping entry) AND
* pre_llm_hook_install (mapping sequence item) — E2E showed three of the four
* constructs break only in the second. */
static int yaml_hermes_hook_upsert(const yaml_fixture_t *fixture) {
static const char *const path[] = {"hooks", "pre_llm_call"};
return cbm_yaml_upsert_mapping_sequence_item(
fixture->path, path, 2U, "id", "\"cbm-context\"",
"- id: \"cbm-context\"\n type: \"command\"\n command: \"/opt/codebase-memory-mcp\"\n");
}
TEST(config_yaml_edit_hermes_accepts_empty_flow_sequence_value_issue1631) {
const char *initial = "name: test\n"
"plugins: []\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "plugins: []\n"));
ASSERT_NOT_NULL(strstr(after, "command: \"/opt/codebase-memory-mcp\"\n"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_hermes_accepts_empty_flow_mapping_value_issue1631) {
const char *initial = "name: test\n"
"tool_choice: {}\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "tool_choice: {}\n"));
ASSERT_NOT_NULL(strstr(after, "command: \"/opt/codebase-memory-mcp\"\n"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_hermes_accepts_column_zero_block_sequence_issue1631) {
/* YAML allows a block sequence at the same indent as its mapping key. */
const char *initial = "agents:\n"
"- alpha\n"
"- beta\n"
"model: gpt\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "agents:\n- alpha\n- beta\n"));
ASSERT_NOT_NULL(strstr(after, "command: \"/opt/codebase-memory-mcp\"\n"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_hermes_accepts_escaped_newline_in_double_quote_issue1631) {
/* A double-quoted scalar may continue across lines with a trailing `\`. */
const char *initial = "name: test\n"
"persona: \"line one \\\n"
" line two\"\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "persona: \"line one \\\n"));
ASSERT_NOT_NULL(strstr(after, "command: \"/opt/codebase-memory-mcp\"\n"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_hermes_hook_accepts_column_zero_sequence_issue1631) {
/* The pre_llm_call hook op walks the same document: a column-0 sequence
* elsewhere in the file must not abort the hook install. */
static const char *const path[] = {"hooks", "pre_llm_call"};
const char *initial = "agents:\n"
"- alpha\n"
"model: gpt\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, path, 2U, "id", "\"cbm-context\"",
"- id: \"cbm-context\"\n type: \"command\"\n"
" command: \"/opt/codebase-memory-mcp\"\n"),
CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "agents:\n- alpha\n"));
ASSERT_NOT_NULL(strstr(after, "cbm-context"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_hermes_accepts_interior_apostrophe_and_plain_wrap_issue1631) {
/* Distilled from the iandol config's `personalities:` block: mid-word
* apostrophes in a plain scalar (quotes are indicators only at a node
* start), and a plain scalar folded onto a deeper-indented next line. */
const char *initial = "agent:\n"
" personalities:\n"
" hype: YOOO LET'S GOOOO!!! I am SO PUMPED! Every question\n"
" is AMAZING and we're gonna CRUSH IT together!\n"
" verbose: false\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "LET'S GOOOO"));
ASSERT_NOT_NULL(strstr(after, "cbm-context"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_accepts_utf8_bom_issue1656) {
/* PowerShell 5.1's `Set-Content -Encoding UTF8` writes a BOM, so real
* Windows-authored Hermes configs start with EF BB BF — and every edit op
* failed content-independently (#1656's 26-byte repro is the reporter's
* 23-byte file plus this BOM). The BOM is a prologue: skip it for
* structure, preserve it byte-for-byte on write. */
const char *initial = "\xEF\xBB\xBFmodel:\n default: test\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_EQ(memcmp(after, "\xEF\xBB\xBFmodel:", 9), 0);
ASSERT_NOT_NULL(strstr(after, " default: test\n"));
ASSERT_NOT_NULL(strstr(after, "command: \"/opt/codebase-memory-mcp\"\n"));
ASSERT_NOT_NULL(strstr(after, "cbm-context"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_bom_before_our_own_section_stays_single_issue1656) {
/* When the BOM immediately precedes OUR section key, the key lookup must
* still see `mcp_servers` — otherwise the upsert misses the existing
* section and appends a duplicate. */
const char *initial = "\xEF\xBB\xBFmcp_servers:\n"
" codebase-memory-mcp:\n"
" command: \"/opt/codebase-memory-mcp\"\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
size_t sections = 0U;
for (const char *c = after; (c = strstr(c, "mcp_servers:")) != NULL; c++) {
sections++;
}
ASSERT_EQ((int)sections, 1);
ASSERT_EQ(memcmp(after, "\xEF\xBB\xBF", 3), 0);
free(after);
th_cleanup(fixture.dir);
PASS();
}
/* ── #1924: block/flow indicators are indicators only where a value begins ──
*
* The sequence-document scan (the pre_llm_hook_install path) refused `>`,
* `|`, `[` and `]` at ANY position of a plain scalar, so a Hermes persona
* such as `probe: kaomoji face >w< here` made the hook op fail while the
* mcp_servers op on the same file succeeded (bisected to two persona strings
* by @ukind). YAML reads them as indicators only where a value begins — the
* rule the `*`/`&` fix and the quote fix (#1631) already apply. Both real
* Hermes ops run here; our blocks append, so the original document must
* survive as an untouched prefix. */
TEST(config_yaml_edit_hermes_accepts_interior_gt_in_plain_scalar_issue1924) {
const char *initial = "agent:\n"
" personalities:\n"
" probe: kaomoji face >w< here\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_EQ(strncmp(after, initial, strlen(initial)), 0);
ASSERT_NOT_NULL(strstr(after, "cbm-context"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_hermes_accepts_interior_pipe_in_plain_scalar_issue1924) {
const char *initial = "agent:\n"
" personalities:\n"
" probe: pipe char | mid value\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_EQ(strncmp(after, initial, strlen(initial)), 0);
ASSERT_NOT_NULL(strstr(after, "cbm-context"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_hermes_accepts_interior_brackets_in_plain_scalar_issue1924) {
/* In block context `[`/`]` inside a plain scalar are ordinary text; only
* a value-start `[` opens a flow sequence. */
const char *initial = "agent:\n"
" personalities:\n"
" probe: see docs[1] and args[] here\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_EQ(strncmp(after, initial, strlen(initial)), 0);
ASSERT_NOT_NULL(strstr(after, "cbm-context"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_hermes_hook_still_refuses_block_scalar_value_issue1924) {
/* A `>` or `|` that BEGINS a value is a real block scalar whose
* indentation contract this editor does not model: inside the `hooks`
* subtree the edit descends into, the fail-closed refusal stays and the
* file stays byte-identical. The same construct in an untouched section
* is opaque user content (#2209). */
static const char *const initials[] = {
"hooks:\n probe: >\n folded face\n",
"hooks:\n probe: |\n literal face\n",
"hooks:\n probe: >-\n chomped face\n",
};
for (size_t i = 0U; i < sizeof(initials) / sizeof(initials[0]); i++) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initials[i]), 0);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_ERROR);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, initials[i]);
free(after);
th_cleanup(fixture.dir);
}
PASS();
}
TEST(config_yaml_edit_hermes_hook_still_refuses_nonempty_flow_sequence_value_issue1924) {
/* Pins the #1631 line: exact `[]` is accepted, a non-empty flow sequence
* value inside the edited `hooks` subtree is still unsupported. Outside
* it, the value is opaque user content (#2209). */
const char *initial = "hooks:\n"
" probe: [a, b]\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_hook_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_ERROR);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, initial);
free(after);
th_cleanup(fixture.dir);
PASS();
}
/* ── Owned-entry repair (#1631 galaxy + goose upgrades) ──────────────────────
*
* Byte-identity alone freezes users on any OLD canonical our past writers
* produced: v0.10.x wrote `command:` unquoted (the galaxy reporter's file),
* and the goose block gained a required `name:` field — without repair, every
* such entry is declared FOREIGN forever and install fails. An existing entry
* under OUR key is repairable when it parses as a known prior shape and its
* command basename is our binary. Truly foreign shapes stay refused. */
TEST(config_yaml_edit_repairs_prior_unquoted_command_entry_issue1631) {
const char *initial = "mcp_servers:\n"
" obsidian:\n"
" url: \"<VALUE>\"\n"
" codebase-memory-mcp:\n"
" command: C:/Users/Administrator/AppData/Local/Programs/"
"codebase-memory-mcp/codebase-memory-mcp.exe\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, " obsidian:\n url: \"<VALUE>\"\n"));
ASSERT_NOT_NULL(strstr(after, "command: \"/opt/codebase-memory-mcp\"\n"));
ASSERT_NULL(strstr(after, "Administrator"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_repairs_prior_goose_block_without_name) {
const char *initial = "extensions:\n"
" codebase-memory-mcp:\n"
" type: stdio\n"
" cmd: \"/old/place/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n";
const char *block = " name: codebase-memory-mcp\n"
" type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_owned_mapping_entry(fixture.path, "extensions", "codebase-memory-mcp",
block),
CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "name: codebase-memory-mcp\n"));
ASSERT_NOT_NULL(strstr(after, "cmd: \"/opt/codebase-memory-mcp\"\n"));
ASSERT_NULL(strstr(after, "/old/place/"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_still_refuses_truly_foreign_entry_under_our_key) {
/* Same key, but the body is not any shape we ever wrote — refuse and
* leave the file byte-identical. */
const char *initial = "mcp_servers:\n"
" codebase-memory-mcp:\n"
" command: /usr/bin/somebody-elses-tool\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(yaml_hermes_upsert(&fixture), CBM_YAML_IDENTITY_EDIT_FOREIGN);
char *unchanged = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(unchanged);
ASSERT_STR_EQ(unchanged, initial);
free(unchanged);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_owned_agent_mapping_installs_idempotently_and_removes_exact_state) {
struct owned_mapping_case {
const char *section;
const char *canonical;
const char *installed;
const char *empty_section;
} cases[] = {
{
"mcp_servers",
" command: \"/opt/codebase-memory-mcp\"\n",
"mcp_servers:\n"
" codebase-memory-mcp:\n"
" command: \"/opt/codebase-memory-mcp\"\n",
"mcp_servers: {}\n",
},
{
"extensions",
" type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n",
"extensions:\n"
" codebase-memory-mcp:\n"
" type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n",
"extensions: {}\n",
},
};
for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
ASSERT_EQ(cbm_yaml_upsert_owned_mapping_entry(fixture.path, cases[i].section,
"codebase-memory-mcp", cases[i].canonical),
CBM_YAML_IDENTITY_EDIT_OK);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_STR_EQ(installed, cases[i].installed);
ASSERT_EQ(cbm_yaml_upsert_owned_mapping_entry(fixture.path, cases[i].section,
"codebase-memory-mcp", cases[i].canonical),
CBM_YAML_IDENTITY_EDIT_OK);
char *idempotent = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(idempotent);
ASSERT_STR_EQ(idempotent, installed);
ASSERT_EQ(cbm_yaml_remove_owned_mapping_entry(fixture.path, cases[i].section,
"codebase-memory-mcp", cases[i].canonical),
CBM_YAML_IDENTITY_EDIT_OK);
char *removed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_STR_EQ(removed, cases[i].empty_section);
free(removed);
free(idempotent);
free(installed);
th_cleanup(fixture.dir);
}
PASS();
}
TEST(config_yaml_edit_owned_agent_mapping_preserves_foreign_same_name_state) {
struct foreign_mapping_case {
const char *section;
const char *canonical;
const char *foreign;
} cases[] = {
{
"mcp_servers",
" command: \"/opt/codebase-memory-mcp\"\n",
"mcp_servers:\n"
" codebase-memory-mcp:\n"
" command: \"/opt/user-owned-mcp\"\n",
},
{
"mcp_servers",
" command: \"/opt/codebase-memory-mcp\"\n",
"mcp_servers:\n"
" codebase-memory-mcp:\n"
" command: \"/opt/codebase-memory-mcp\"\n"
" startup_timeout_sec: 45\n",
},
{
"extensions",
" type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n",
"extensions:\n"
" codebase-memory-mcp:\n"
" type: stdio\n"
" cmd: \"/opt/user-owned-mcp\"\n"
" args: [\"--custom\"]\n"
" enabled: true\n",
},
{
"extensions",
" type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n",
"extensions:\n"
" codebase-memory-mcp:\n"
" type: stdio\n"
" cmd: \"/opt/codebase-memory-mcp\"\n"
" args: []\n"
" enabled: true\n"
" startup_timeout_sec: 45\n",
},
};
for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, cases[i].foreign), 0);
ASSERT_EQ(cbm_yaml_upsert_owned_mapping_entry(fixture.path, cases[i].section,
"codebase-memory-mcp", cases[i].canonical),
CBM_YAML_IDENTITY_EDIT_FOREIGN);
char *after_upsert = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after_upsert);
ASSERT_STR_EQ(after_upsert, cases[i].foreign);
free(after_upsert);
ASSERT_EQ(cbm_yaml_remove_owned_mapping_entry(fixture.path, cases[i].section,
"codebase-memory-mcp", cases[i].canonical),
CBM_YAML_IDENTITY_EDIT_FOREIGN);
char *after_remove = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after_remove);
ASSERT_STR_EQ(after_remove, cases[i].foreign);
free(after_remove);
th_cleanup(fixture.dir);
}
PASS();
}
TEST(config_yaml_edit_mapping_remove_first_middle_last) {
const char *initial = "extensions:\n"
" first:\n"
" type: one\n"
" middle:\n"
" type: two\n"
" last:\n"
" type: three\n"
"mode: keep\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_remove_mapping_entry(fixture.path, "extensions", "first"), 0);
char *after_first = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after_first);
ASSERT_STR_EQ(after_first, "extensions:\n"
" middle:\n"
" type: two\n"
" last:\n"
" type: three\n"
"mode: keep\n");
ASSERT_EQ(cbm_yaml_remove_mapping_entry(fixture.path, "extensions", "middle"), 0);
char *after_middle = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after_middle);
ASSERT_STR_EQ(after_middle, "extensions:\n"
" last:\n"
" type: three\n"
"mode: keep\n");
ASSERT_EQ(cbm_yaml_remove_mapping_entry(fixture.path, "extensions", "last"), 0);
char *after_last = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after_last);
ASSERT_STR_EQ(after_last, "extensions: {}\nmode: keep\n");
free(after_last);
free(after_middle);
free(after_first);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_mapping_remove_last_preserves_section_comments) {
const char *initial = "extensions: # preserve section comment\n"
" # preserve user note\n"
" codebase-memory:\n"
" type: stdio\n"
"mode: keep\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_remove_mapping_entry(fixture.path, "extensions", "codebase-memory"), 0);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, "extensions: {} # preserve section comment\n"
" # preserve user note\n"
"mode: keep\n");
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_mapping_entry_is_explicit_managed_boundary) {
const char *initial = "mcp_servers:\n"
" codebase-memory:\n"
" command: old-binary\n"
" user_added_field: replaced-with-managed-entry\n"
" user-server:\n"
" command: preserve-me\n";
const char *managed = " command: new-binary\n"
" args: []\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(
cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory", managed), 0);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, " codebase-memory:\n"
" command: new-binary\n"
" args: []\n"));
ASSERT_NULL(strstr(after, "user_added_field"));
ASSERT_NOT_NULL(strstr(after, " user-server:\n command: preserve-me\n"));
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_aider_absent_read_key) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), 0);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_STR_EQ(installed, "read:\n - \"AGENTS.md\"\n");
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), 0);
char *idempotent = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(idempotent);
ASSERT_STR_EQ(idempotent, installed);
ASSERT_EQ(cbm_yaml_remove_string_list_item(fixture.path, "read", "AGENTS.md"), 0);
char *removed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_STR_EQ(removed, "");
free(removed);
free(idempotent);
free(installed);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_aider_scalar_read_add_remove) {
const char *initial = "model: sonnet\n"
"read: docs.md # keep read comment\n"
"color: auto\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), 0);
char *added = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(added);
ASSERT_STR_EQ(added, "model: sonnet\n"
"read: [docs.md, \"AGENTS.md\"] # keep read comment\n"
"color: auto\n");
ASSERT_EQ(cbm_yaml_remove_string_list_item(fixture.path, "read", "docs.md"), 0);
char *one_left = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(one_left);
ASSERT_STR_EQ(one_left, "model: sonnet\n"
"read: [\"AGENTS.md\"] # keep read comment\n"
"color: auto\n");
ASSERT_EQ(cbm_yaml_remove_string_list_item(fixture.path, "read", "AGENTS.md"), 0);
char *removed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_STR_EQ(removed, "model: sonnet\ncolor: auto\n");
free(removed);
free(one_left);
free(added);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_aider_flow_read_add_remove) {
const char *initial = "read: [README.md, 'notes file.md'] # user list\n"
"model: local\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "docs:guide #1.md"), 0);
char *added = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(added);
ASSERT_STR_EQ(added, "read: [README.md, 'notes file.md', \"docs:guide #1.md\"] # user list\n"
"model: local\n");
ASSERT_EQ(cbm_yaml_remove_string_list_item(fixture.path, "read", "notes file.md"), 0);
char *removed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_STR_EQ(removed, "read: [README.md, \"docs:guide #1.md\"] # user list\n"
"model: local\n");
free(removed);
free(added);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_aider_block_read_add_remove) {
const char *initial = "read:\n"
" - README.md\n"
" # preserve list comment\n"
" - \"docs/file.md\"\n"
"model: local\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), 0);
char *added = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(added);
ASSERT_STR_EQ(added, "read:\n"
" - README.md\n"
" # preserve list comment\n"
" - \"docs/file.md\"\n"
" - \"AGENTS.md\"\n"
"model: local\n");
ASSERT_EQ(cbm_yaml_remove_string_list_item(fixture.path, "read", "docs/file.md"), 0);
char *removed_middle = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed_middle);
ASSERT_STR_EQ(removed_middle, "read:\n"
" - README.md\n"
" # preserve list comment\n"
" - \"AGENTS.md\"\n"
"model: local\n");
ASSERT_EQ(cbm_yaml_remove_string_list_item(fixture.path, "read", "AGENTS.md"), 0);
char *removed_last = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed_last);
ASSERT_STR_EQ(removed_last, "read:\n"
" - README.md\n"
" # preserve list comment\n"
"model: local\n");
free(removed_last);
free(removed_middle);
free(added);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_quotes_windows_path_safely) {
const char *item = "C:\\Users\\Ada\\My \"Notes\": #1.md";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", item), 0);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_STR_EQ(installed, "read:\n"
" - \"C:\\\\Users\\\\Ada\\\\My \\\"Notes\\\": #1.md\"\n");
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", item), 0);
char *idempotent = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(idempotent);
ASSERT_STR_EQ(idempotent, installed);
ASSERT_EQ(cbm_yaml_remove_string_list_item(fixture.path, "read", item), 0);
char *removed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_STR_EQ(removed, "");
free(removed);
free(idempotent);
free(installed);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_mapping_ambiguity_fails_unchanged) {
const char *cases[] = {
"mcp_servers:\n one:\n command: one\nmcp_servers:\n",
("mcp_servers:\n codebase-memory:\n command: one\n"
" codebase-memory:\n command: two\n"),
"mcp_servers:\n codebase-memory:\n command: bad-indent\n",
"mcp_servers:\n\tcodebase-memory:\n\t\tcommand: tabbed\n",
"mcp_servers: &shared\n other:\n command: other\n",
"mcp_servers:\n <<: *defaults\n other:\n command: other\n",
"mcp_servers: {codebase-memory: {command: codebase-memory-mcp}}\n",
"mcp_servers:\n other:\n description: >\n folded text\n",
};
const char *block = " command: codebase-memory-mcp\n";
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, cases[i]), 0);
ASSERT_EQ(
cbm_yaml_upsert_mapping_entry(fixture.path, "mcp_servers", "codebase-memory", block),
-1);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, cases[i]);
free(after);
th_cleanup(fixture.dir);
}
yaml_fixture_t invalid_block;
const char *safe = "mcp_servers:\n other:\n command: other\n";
ASSERT_EQ(yaml_fixture_init(&invalid_block, safe), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(invalid_block.path, "mcp_servers", "codebase-memory",
" command: &shared\n"),
-1);
char *after_block = yaml_read_alloc(invalid_block.path);
ASSERT_NOT_NULL(after_block);
ASSERT_STR_EQ(after_block, safe);
free(after_block);
th_cleanup(invalid_block.dir);
yaml_fixture_t comment_truncation;
ASSERT_EQ(yaml_fixture_init(&comment_truncation, safe), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(comment_truncation.path, "mcp_servers",
"codebase-memory",
" command: /tmp/tool # truncated\n"),
-1);
char *after_comment = yaml_read_alloc(comment_truncation.path);
ASSERT_NOT_NULL(after_comment);
ASSERT_STR_EQ(after_comment, safe);
free(after_comment);
th_cleanup(comment_truncation.dir);
yaml_fixture_t quoted_hash;
ASSERT_EQ(yaml_fixture_init(&quoted_hash, safe), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(quoted_hash.path, "mcp_servers", "codebase-memory",
" command: \"/tmp/tool # literal\"\n"),
0);
char *after_quoted = yaml_read_alloc(quoted_hash.path);
ASSERT_NOT_NULL(after_quoted);
ASSERT_NOT_NULL(strstr(after_quoted, " command: \"/tmp/tool # literal\"\n"));
free(after_quoted);
th_cleanup(quoted_hash.dir);
PASS();
}
TEST(config_yaml_edit_list_ambiguity_fails_unchanged) {
const char control_yaml[] = "read:\n - good.md\n # bad\x01"
"control\n";
const char *cases[] = {
"read: one.md\nread: two.md\n",
"read: *defaults\nmodel: local\n",
"read: |\n one.md\n",
"read: {path: one.md}\n",
"read:\n - &shared one.md\n",
"read:\n - three-space-indent.md\n",
control_yaml,
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, cases[i]), 0);
ASSERT_EQ(cbm_yaml_upsert_string_list_item(fixture.path, "read", "AGENTS.md"), -1);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, cases[i]);
free(after);
th_cleanup(fixture.dir);
}
PASS();
}
static const char *const yaml_hook_sequence_path[] = {"hooks", "pre_llm_call"};
static const char yaml_hook_identity[] = "\"codebase-memory-mcp\"";
static const char yaml_hook_canonical_item[] = "- id: \"codebase-memory-mcp\"\n"
" type: \"command\"\n"
" command: \"/opt/Codebase Memory/bin/cbm\"\n";
TEST(config_yaml_edit_nested_sequence_preserves_siblings_comments_and_is_idempotent) {
const char *initial = "# user header\n"
"hooks:\n"
" session_start:\n"
" - id: \"session-owner\"\n"
" command: \"session-command\"\n"
" pre_llm_call:\n"
" # preserve same-event comment\n"
" - id: \"other-hook\"\n"
" command: \"other-command\"\n"
" post_llm_call:\n"
" - id: \"post-owner\"\n"
" command: \"post-command\"\n"
"model: local\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_NOT_NULL(strstr(installed, "# user header\n"));
ASSERT_NOT_NULL(strstr(installed, " # preserve same-event comment\n"));
ASSERT_NOT_NULL(strstr(installed, " - id: \"other-hook\"\n"));
ASSERT_NOT_NULL(strstr(installed, " session_start:\n"));
ASSERT_NOT_NULL(strstr(installed, " post_llm_call:\n"));
ASSERT_NOT_NULL(strstr(installed, "model: local\n"));
ASSERT_NOT_NULL(strstr(installed, " - id: \"codebase-memory-mcp\"\n"
" type: \"command\"\n"
" command: \"/opt/Codebase Memory/bin/cbm\"\n"));
ASSERT_EQ(yaml_count_occurrences(installed, "id: \"codebase-memory-mcp\""), 1U);
ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
char *second = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(second);
ASSERT_STR_EQ(second, installed);
free(second);
free(installed);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_nested_sequence_creates_missing_file_section_and_list) {
const char *initial_documents[] = {
NULL,
"model: local\n",
"hooks:\n session_start:\n - id: \"keep\"\n command: \"keep\"\n",
};
for (size_t i = 0U; i < sizeof(initial_documents) / sizeof(initial_documents[0]); i++) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial_documents[i]), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U,
"id", yaml_hook_identity,
yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_NOT_NULL(strstr(installed, "hooks:\n"));
ASSERT_NOT_NULL(strstr(installed, " pre_llm_call:\n"));
ASSERT_NOT_NULL(strstr(installed, " - id: \"codebase-memory-mcp\"\n"));
ASSERT_EQ(yaml_count_occurrences(installed, "id: \"codebase-memory-mcp\""), 1U);
if (initial_documents[i]) {
if (strstr(initial_documents[i], "model:")) {
ASSERT_NOT_NULL(strstr(installed, "model: local\n"));
}
if (strstr(initial_documents[i], "session_start:")) {
ASSERT_NOT_NULL(strstr(installed, " session_start:\n"));
ASSERT_NOT_NULL(strstr(installed, " - id: \"keep\"\n"));
}
}
free(installed);
th_cleanup(fixture.dir);
}
PASS();
}
TEST(config_yaml_edit_nested_sequence_preserves_crlf) {
const char *initial = "hooks:\r\n"
" pre_llm_call:\r\n"
" - id: \"other\"\r\n"
" command: \"other\"\r\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
#ifndef _WIN32
ASSERT_EQ(chmod(fixture.path, 0640), 0);
struct stat before;
ASSERT_EQ(stat(fixture.path, &before), 0);
#endif
ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
for (const char *newline = strchr(after, '\n'); newline; newline = strchr(newline + 1U, '\n')) {
ASSERT(newline > after && newline[-1] == '\r');
}
ASSERT_NOT_NULL(strstr(after, " - id: \"codebase-memory-mcp\"\r\n"));
#ifndef _WIN32
struct stat after_state;
ASSERT_EQ(stat(fixture.path, &after_state), 0);
ASSERT_EQ(after_state.st_uid, before.st_uid);
ASSERT_EQ(after_state.st_gid, before.st_gid);
ASSERT_EQ(after_state.st_mode & 07777, before.st_mode & 07777);
#endif
free(after);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_nested_sequence_foreign_identity_is_preserved) {
const char *cases[] = {
"hooks:\n"
" pre_llm_call:\n"
" - id: \"codebase-memory-mcp\"\n"
" type: \"command\"\n"
" command: \"foreign\"\n",
"hooks:\n"
" pre_llm_call:\n"
" - id: \"codebase-memory-mcp\"\n"
" type: \"command\"\n"
" command: \"/opt/Codebase Memory/bin/cbm\"\n"
" timeout: 30\n",
};
for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, cases[i]), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U,
"id", yaml_hook_identity,
yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_FOREIGN);
char *after_upsert = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after_upsert);
ASSERT_STR_EQ(after_upsert, cases[i]);
free(after_upsert);
ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U,
"id", yaml_hook_identity,
yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_FOREIGN);
char *after_remove = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after_remove);
ASSERT_STR_EQ(after_remove, cases[i]);
free(after_remove);
th_cleanup(fixture.dir);
}
PASS();
}
TEST(config_yaml_edit_nested_sequence_removes_only_exact_canonical_item) {
const char *initial = "hooks:\n"
" pre_llm_call:\n"
" - id: \"other-hook\"\n"
" command: \"other\"\n"
" - id: \"codebase-memory-mcp\"\n"
" type: \"command\"\n"
" command: \"/opt/Codebase Memory/bin/cbm\"\n"
" post_llm_call:\n"
" - id: \"post\"\n"
" command: \"post\"\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
char *removed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_NULL(strstr(removed, "id: \"codebase-memory-mcp\""));
ASSERT_NOT_NULL(strstr(removed, " - id: \"other-hook\"\n"));
ASSERT_NOT_NULL(strstr(removed, " post_llm_call:\n"));
ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
char *second = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(second);
ASSERT_STR_EQ(second, removed);
free(second);
free(removed);
th_cleanup(fixture.dir);
PASS();
}
/* Uninstall must be idempotent against a config that was never written. Hermes
* is detected by its binary on PATH, so a HOME without `.hermes/` reached the
* YAML removers with an absent target — and they failed acquiring the lock
* beside a file whose directory does not exist, which `uninstall` then
* counted as an agent cleanup error and refused to remove the executable.
* Every remover answers OK for a genuinely absent path and creates nothing.
* Runs on every platform: the fix has a separate Windows branch. */
TEST(config_yaml_edit_remove_on_absent_config_is_a_no_op) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
char absent[sizeof(fixture.path) + 32U];
ASSERT(snprintf(absent, sizeof(absent), "%s/no-such-dir/config.yaml", fixture.dir) > 0);
ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(absent, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(cbm_yaml_remove_owned_mapping_entry(absent, "mcp_servers", "codebase-memory-mcp",
" command: \"/opt/cbm\"\n"),
CBM_YAML_IDENTITY_EDIT_OK);
ASSERT_EQ(cbm_yaml_remove_mapping_entry(absent, "hooks", "pre_llm_call"), 0);
ASSERT_EQ(cbm_yaml_remove_string_list_item(absent, "read", "AGENTS.md"), 0);
/* Nothing was created. cbm_path_info_utf8 rather than lstat: same meaning
* — it reports the link instead of following it — and it compiles on
* Windows, which is exactly where the fix has its own branch. */
cbm_path_info_t absent_info;
ASSERT(cbm_path_info_utf8(absent, &absent_info) != 0);
th_cleanup(fixture.dir);
PASS();
}
/* #2209: the stock Hermes config carries flow sequences and block scalars in
* sections the hook edit never touches (`platform_toolsets:` -> `cli:
* [hermes-cli]`, `plugins:` -> `enabled: [...]`, `|` prompts). They are opaque
* user content, exactly as for the mapping-entry editor that writes
* `mcp_servers` into the same file; only the `hooks` subtree is validated. */
TEST(config_yaml_edit_nested_sequence_ignores_foreign_flow_and_block_scalars_issue2209) {
const char *initial = "plugins:\n"
" enabled: [disk-cleanup]\n"
"platform_toolsets:\n"
" cli: [hermes-cli]\n"
" telegram: [hermes-telegram]\n"
"toolsets: [all]\n"
"agent:\n"
" system_prompt: |\n"
" You are helpful.\n"
" - keep: [this, text]\n"
" note: >\n"
" folded\n"
"model:\n"
" default: \"anthropic/claude-opus-4.6\"\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, initial), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
char *installed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(installed);
ASSERT_EQ(strncmp(installed, initial, strlen(initial)), 0);
ASSERT_NOT_NULL(strstr(installed, "hooks:\n"
" pre_llm_call:\n"
" - id: \"codebase-memory-mcp\"\n"));
ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
char *second = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(second);
ASSERT_STR_EQ(second, installed);
free(second);
free(installed);
ASSERT_EQ(cbm_yaml_remove_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_OK);
char *removed = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_NULL(strstr(removed, "codebase-memory-mcp"));
ASSERT_EQ(strncmp(removed, initial, strlen(initial)), 0);
free(removed);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_nested_sequence_ambiguity_fails_byte_identically) {
const char *cases[] = {
"hooks:\n pre_llm_call:\n - id: \"bad-indent\"\n",
"hooks:\n pre_llm_call:\n - id: \"one\"\nhooks:\n keep: true\n",
"hooks:\n pre_llm_call:\n - id: \"one\"\n pre_llm_call:\n - id: \"two\"\n",
"shared: &shared\n - id: \"other\"\nhooks:\n pre_llm_call: *shared\n",
"hooks: {pre_llm_call: []}\n",
"hooks:\n pre_llm_call: [{id: \"other\", command: \"other\"}]\n",
"hooks:\n pre_llm_call:\n - id: \"unterminated\n",
"hooks:\n pre_llm_call:\n - id: \"codebase-memory-mcp\"\n"
" command: \"one\"\n"
" - id: \"codebase-memory-mcp\"\n"
" command: \"two\"\n",
};
for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) {
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, cases[i]), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U,
"id", yaml_hook_identity,
yaml_hook_canonical_item),
CBM_YAML_IDENTITY_EDIT_ERROR);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, cases[i]);
free(after);
th_cleanup(fixture.dir);
}
PASS();
}
#ifndef _WIN32
/* #1631: an interior `*` in a written value is ordinary text, not an alias.
* A real 16 KB Hermes config was permanently un-editable because prose
* asterisks in a personality string were read as aliases (root-caused by
* @rg6304 with an isolated repro).
*
* The asterisk is in the ENTRY BLOCK deliberately: that is the range the
* editor validates. An earlier version of this test put it in an untouched
* foreign section, which is never scanned - so it passed with the fix
* reverted and proved nothing. */
TEST(config_yaml_edit_accepts_interior_asterisk_in_plain_scalar_issue1631) {
const char *original = "existing:\n keep: 1\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
ASSERT_EQ(cbm_yaml_upsert_mapping_entry(fixture.path, "hooks", "cbm",
" value: use *emphasis* and 2 * 3\n"),
0);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_NOT_NULL(strstr(after, "value: use *emphasis* and 2 * 3"));
free(after);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_still_refuses_leading_alias_issue1631) {
const char *original = "existing:\n keep: 1\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, original), 0);
ASSERT(cbm_yaml_upsert_mapping_entry(fixture.path, "hooks", "cbm", " value: *alias\n") != 0);
char *after = yaml_read_alloc(fixture.path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, original);
free(after);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
th_cleanup(fixture.dir);
PASS();
}
TEST(config_yaml_edit_nested_sequence_rejects_symlink_byte_identically) {
const char *original = "hooks:\n pre_llm_call:\n - id: \"other\"\n";
yaml_fixture_t fixture;
ASSERT_EQ(yaml_fixture_init(&fixture, NULL), 0);
char target[sizeof(fixture.path) + 32U];
ASSERT(snprintf(target, sizeof(target), "%s/target-hooks.yaml", fixture.dir) > 0);
ASSERT_EQ(th_write_file(target, original), 0);
ASSERT_EQ(symlink(target, fixture.path), 0);
/* Foreign-owned link (observer moved by the test seam): still refused. */
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.dir), 0);
cbm_config_edit_path_set_invoking_uid_for_test((unsigned)geteuid() + 1U, 1);
int foreign_rc =
cbm_yaml_upsert_mapping_sequence_item(fixture.path, yaml_hook_sequence_path, 2U, "id",
yaml_hook_identity, yaml_hook_canonical_item);
cbm_config_edit_path_set_invoking_uid_for_test(0U, 0);
cbm_config_edit_path_follow_clear();
ASSERT_EQ(foreign_rc, CBM_YAML_IDENTITY_EDIT_ERROR);
char *after = yaml_read_alloc(target);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, original);
free(after);
struct stat link_state;
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_unlink(target), 0);
th_cleanup(fixture.dir);
PASS();
}
#endif
SUITE(config_yaml_edit) {
RUN_TEST(config_yaml_edit_serializes_two_editor_instances);
RUN_TEST(config_yaml_edit_missing_target_appearance_fails_without_replace);
RUN_TEST(config_yaml_edit_rejects_stale_content_and_cleans_temp);
RUN_TEST(config_yaml_edit_rejects_stale_identity_with_same_content);
RUN_TEST(config_yaml_edit_existing_target_swap_after_check_preserves_winner);
#ifndef _WIN32
RUN_TEST(config_yaml_edit_lock_postcreate_verification_failure_preserves_unsafe_sidecar);
RUN_TEST(config_yaml_edit_reuses_persistent_safe_lock_sidecar);
RUN_TEST(config_yaml_edit_rejects_symlink_lock_sidecar);
RUN_TEST(config_yaml_edit_rejects_hard_linked_lock_sidecar);
RUN_TEST(config_yaml_edit_rejects_unsafe_mode_lock_sidecar);
#endif
RUN_TEST(config_yaml_edit_rejects_non_regular_path);
#ifndef _WIN32
RUN_TEST(config_yaml_edit_rejects_symlinks_without_touching_target);
RUN_TEST(config_yaml_edit_follows_user_owned_symlink_in_place);
RUN_TEST(config_yaml_edit_rejects_dangling_symlink);
RUN_TEST(config_yaml_edit_preserves_owner_group_and_mode);
RUN_TEST(config_yaml_edit_rejects_hard_links_without_splitting_identity);
#endif
RUN_TEST(config_yaml_edit_encodes_dynamic_scalars_safely);
RUN_TEST(config_yaml_edit_rejects_newline_list_items);
RUN_TEST(config_yaml_edit_absent_target_requires_safe_root_mapping);
RUN_TEST(config_yaml_edit_rejects_semantic_target_key_aliases);
RUN_TEST(config_yaml_edit_preserves_crlf_and_handles_no_final_newline);
RUN_TEST(config_yaml_edit_hermes_mapping_lifecycle);
RUN_TEST(config_yaml_edit_hermes_creates_missing_section);
RUN_TEST(config_yaml_edit_goose_extensions_preserve_siblings);
RUN_TEST(config_yaml_edit_goose_accepts_empty_flow_mapping_in_sibling_issue1673);
RUN_TEST(config_yaml_edit_hermes_accepts_empty_flow_sequence_value_issue1631);
RUN_TEST(config_yaml_edit_hermes_accepts_empty_flow_mapping_value_issue1631);
RUN_TEST(config_yaml_edit_hermes_accepts_column_zero_block_sequence_issue1631);
RUN_TEST(config_yaml_edit_hermes_accepts_escaped_newline_in_double_quote_issue1631);
RUN_TEST(config_yaml_edit_hermes_hook_accepts_column_zero_sequence_issue1631);
RUN_TEST(config_yaml_edit_hermes_accepts_interior_apostrophe_and_plain_wrap_issue1631);
RUN_TEST(config_yaml_edit_accepts_utf8_bom_issue1656);
RUN_TEST(config_yaml_edit_bom_before_our_own_section_stays_single_issue1656);
RUN_TEST(config_yaml_edit_hermes_accepts_interior_gt_in_plain_scalar_issue1924);
RUN_TEST(config_yaml_edit_hermes_accepts_interior_pipe_in_plain_scalar_issue1924);
RUN_TEST(config_yaml_edit_hermes_accepts_interior_brackets_in_plain_scalar_issue1924);
RUN_TEST(config_yaml_edit_hermes_hook_still_refuses_block_scalar_value_issue1924);
RUN_TEST(config_yaml_edit_hermes_hook_still_refuses_nonempty_flow_sequence_value_issue1924);
RUN_TEST(config_yaml_edit_repairs_prior_unquoted_command_entry_issue1631);
RUN_TEST(config_yaml_edit_repairs_prior_goose_block_without_name);
RUN_TEST(config_yaml_edit_still_refuses_truly_foreign_entry_under_our_key);
RUN_TEST(config_yaml_edit_goose_still_rejects_nonempty_flow_mapping_issue1673);
RUN_TEST(config_yaml_edit_goose_still_rejects_merge_key_with_empty_mapping_issue1673);
RUN_TEST(config_yaml_edit_owned_agent_mapping_installs_idempotently_and_removes_exact_state);
RUN_TEST(config_yaml_edit_owned_agent_mapping_preserves_foreign_same_name_state);
RUN_TEST(config_yaml_edit_mapping_remove_first_middle_last);
RUN_TEST(config_yaml_edit_mapping_remove_last_preserves_section_comments);
RUN_TEST(config_yaml_edit_mapping_entry_is_explicit_managed_boundary);
RUN_TEST(config_yaml_edit_aider_absent_read_key);
RUN_TEST(config_yaml_edit_aider_scalar_read_add_remove);
RUN_TEST(config_yaml_edit_aider_flow_read_add_remove);
RUN_TEST(config_yaml_edit_aider_block_read_add_remove);
RUN_TEST(config_yaml_edit_quotes_windows_path_safely);
RUN_TEST(config_yaml_edit_mapping_ambiguity_fails_unchanged);
RUN_TEST(config_yaml_edit_list_ambiguity_fails_unchanged);
RUN_TEST(config_yaml_edit_nested_sequence_preserves_siblings_comments_and_is_idempotent);
RUN_TEST(config_yaml_edit_nested_sequence_creates_missing_file_section_and_list);
RUN_TEST(config_yaml_edit_nested_sequence_preserves_crlf);
RUN_TEST(config_yaml_edit_nested_sequence_foreign_identity_is_preserved);
RUN_TEST(config_yaml_edit_nested_sequence_removes_only_exact_canonical_item);
RUN_TEST(config_yaml_edit_nested_sequence_ignores_foreign_flow_and_block_scalars_issue2209);
RUN_TEST(config_yaml_edit_nested_sequence_ambiguity_fails_byte_identically);
RUN_TEST(config_yaml_edit_remove_on_absent_config_is_a_no_op);
#ifndef _WIN32
RUN_TEST(config_yaml_edit_accepts_interior_asterisk_in_plain_scalar_issue1631);
RUN_TEST(config_yaml_edit_still_refuses_leading_alias_issue1631);
RUN_TEST(config_yaml_edit_nested_sequence_rejects_symlink_byte_identically);
#endif
}