mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
fix(yaml): accept the #1631 constructs and repair prior owned entries
Four legal-YAML constructs from the reporters' real Hermes configs made
`install` fail permanently (any one of them aborted mcp_install and/or
pre_llm_hook_install):
- exact empty flow collections as values (`plugins: []`, `tool_choice: {}`)
— now validated key-only in both the mapping-body and sequence document
scans, mirroring the #1673 empty-mapping exception; non-empty flow
collections stay rejected.
- block sequences at the same indent as their mapping key (column-0 `- item`)
— item lines directly after a value-less key are structure, not malformed
keys, in the root walker, the key matcher, and the sequence mapping-range
walker.
- double-quoted scalars continued across lines with a trailing `\` — the doc
loader now precomputes per-line continuation flags; continuation lines are
value bytes every structural walker skips, and a document ending inside an
open continuation stays an error.
- mid-word quote characters in plain scalars (`LET'S`) — quotes are scalar
indicators only at a node start (range start, after `:`, after `-`),
exactly like the #1639 anchor/alias rule; real quoted values keep their
protection.
Byte-identity alone also froze users on canonicals older releases wrote:
galaxy's entry had `command:` unquoted, and the goose block gained `name:`
(#1675), so the existing entry was declared FOREIGN forever. An entry under
our key now repairs when it parses as a known prior shape (single command
line, or the pre-name goose block) with a codebase-memory-mcp[.exe] command
basename; anything else stays FOREIGN and the file untouched.
End-to-end: both reporters' full configs (iandol 15.6 KB, galaxy 15.2 KB) now
install with zero agent_config errors, every original line byte-preserved,
and the goose upgrade path rewrites the old block in place. Each construct
carries a distilled regression test proven RED on the unfixed editor.
Fixes #1631.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
e87b42ceba
commit
b54998d242
2 files changed
+547
-25
No files matched your search
+328
-25
@@ -99,6 +99,13 @@ typedef struct {
|
||||
size_t indent;
|
||||
bool blank;
|
||||
bool comment;
|
||||
/* Double-quoted scalars may continue across lines via a trailing `\`
|
||||
* (#1631). dquote_opens: this line ends inside an open double quote whose
|
||||
* last byte is the continuation backslash. dquote_cont: this line is the
|
||||
* textual continuation of such a scalar — VALUE BYTES, not structure;
|
||||
* every structural walker must skip it. */
|
||||
bool dquote_opens;
|
||||
bool dquote_cont;
|
||||
} yaml_line_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -1079,6 +1086,76 @@ static void yaml_doc_free(yaml_doc_t *doc) {
|
||||
memset(doc, 0, sizeof(*doc));
|
||||
}
|
||||
|
||||
/* Precompute the double-quote continuation flags (#1631): a line whose scan
|
||||
* ends inside a double-quoted scalar with a trailing `\` opens a continuation;
|
||||
* the following line is value text and may itself close, or re-open, the
|
||||
* scalar. Any OTHER way of ending a line inside a quote stays an error at the
|
||||
* validators. Returns YAML_ERROR only when the document ENDS inside an open
|
||||
* continuation (the quote can never close). */
|
||||
static int yaml_mark_dquote_continuations(yaml_doc_t *doc) {
|
||||
bool cont = false;
|
||||
for (size_t i = 0; i < doc->line_count; i++) {
|
||||
yaml_line_t *line = &doc->lines[i];
|
||||
line->dquote_cont = cont;
|
||||
line->dquote_opens = false;
|
||||
if (!cont && (line->blank || line->comment)) {
|
||||
continue;
|
||||
}
|
||||
size_t start = cont ? line->start : line->start + line->indent;
|
||||
char quote = cont ? '"' : '\0';
|
||||
bool opens = false;
|
||||
char previous = '\0';
|
||||
size_t j = start;
|
||||
while (j < line->text_end) {
|
||||
char c = doc->data[j];
|
||||
if (quote == '"') {
|
||||
if (c == '\\') {
|
||||
if (j + YAML_UNIT == line->text_end) {
|
||||
opens = true;
|
||||
break;
|
||||
}
|
||||
j += YAML_ENTRY_INDENT;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') {
|
||||
quote = '\0';
|
||||
}
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (quote == '\'') {
|
||||
if (c == '\'') {
|
||||
if (j + YAML_UNIT < line->text_end && doc->data[j + YAML_UNIT] == '\'') {
|
||||
j += YAML_ENTRY_INDENT;
|
||||
} else {
|
||||
quote = '\0';
|
||||
j++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if ((c == '"' || c == '\'') &&
|
||||
(previous == '\0' || previous == ':' || previous == '-')) {
|
||||
quote = c;
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
if (c == '#' && (j == start || doc->data[j - YAML_UNIT] == ' ')) {
|
||||
break;
|
||||
}
|
||||
if (c != ' ' && c != '\t') {
|
||||
previous = c;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
line->dquote_opens = opens;
|
||||
cont = opens;
|
||||
}
|
||||
return cont ? YAML_ERROR : 0;
|
||||
}
|
||||
|
||||
static int yaml_doc_init(yaml_doc_t *doc, const char *data, size_t len) {
|
||||
memset(doc, 0, sizeof(*doc));
|
||||
doc->data = data;
|
||||
@@ -1086,9 +1163,13 @@ static int yaml_doc_init(yaml_doc_t *doc, const char *data, size_t len) {
|
||||
if (yaml_build_lines(doc) != 0) {
|
||||
return YAML_ERROR;
|
||||
}
|
||||
if (yaml_mark_dquote_continuations(doc) != 0) {
|
||||
yaml_doc_free(doc);
|
||||
return YAML_ERROR;
|
||||
}
|
||||
for (size_t i = 0; i < doc->line_count; i++) {
|
||||
const yaml_line_t *line = &doc->lines[i];
|
||||
if (line->indent == 0U && !line->blank && !line->comment) {
|
||||
if (line->indent == 0U && !line->blank && !line->comment && !line->dquote_cont) {
|
||||
size_t len_no_eol = line->text_end - line->start;
|
||||
if ((len_no_eol == YAML_DOC_MARKER_LEN &&
|
||||
memcmp(doc->data + line->start, "---", YAML_DOC_MARKER_LEN) == 0) ||
|
||||
@@ -1122,7 +1203,7 @@ static size_t yaml_trim_spaces_end(const char *data, size_t start, size_t end) {
|
||||
|
||||
static int yaml_line_matches_key(const yaml_doc_t *doc, const yaml_line_t *line, size_t indent,
|
||||
const char *key, size_t key_len, size_t *out_colon) {
|
||||
if (line->blank || line->comment || line->indent != indent) {
|
||||
if (line->blank || line->comment || line->dquote_cont || line->indent != indent) {
|
||||
return 0;
|
||||
}
|
||||
size_t start = line->start + indent;
|
||||
@@ -1130,6 +1211,14 @@ static int yaml_line_matches_key(const yaml_doc_t *doc, const yaml_line_t *line,
|
||||
if (start >= end) {
|
||||
return 0;
|
||||
}
|
||||
/* A block-sequence item at this indent is a known non-key line (#1631):
|
||||
* YAML puts items at the same indent as their mapping key, so a column-0
|
||||
* `- item` is ordinary structure, not a malformed key. The root-mapping
|
||||
* walker already validated that items only follow a value-less key. */
|
||||
if (doc->data[start] == '-' &&
|
||||
(start + YAML_UNIT == end || doc->data[start + YAML_UNIT] == ' ')) {
|
||||
return 0;
|
||||
}
|
||||
size_t colon = 0U;
|
||||
if (yaml_find_mapping_colon(doc->data, start, end, &colon) != 0) {
|
||||
return YAML_ERROR;
|
||||
@@ -1193,7 +1282,7 @@ static int yaml_find_unique_key(const yaml_doc_t *doc, size_t indent, const char
|
||||
static size_t yaml_top_level_section_end(const yaml_doc_t *doc, size_t header_line) {
|
||||
for (size_t i = header_line + YAML_UNIT; i < doc->line_count; i++) {
|
||||
const yaml_line_t *line = &doc->lines[i];
|
||||
if (!line->blank && !line->comment && line->indent == 0U) {
|
||||
if (!line->blank && !line->comment && !line->dquote_cont && line->indent == 0U) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -1232,6 +1321,23 @@ static int yaml_scan_quote(const char *data, size_t end, size_t *pos, char *quot
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Positional variant (#1631): a quote character OPENS a scalar only where a
|
||||
* node begins — at the range start, after a mapping colon, or after a block-
|
||||
* sequence dash. Mid-word apostrophes (`LET'S`) and inch marks are ordinary
|
||||
* text, exactly like the `&`/`*` indicator rule above. Closing behaviour for
|
||||
* an already-open quote is unchanged. */
|
||||
static int yaml_scan_quote_positional(const char *data, size_t end, size_t *pos, char *quote,
|
||||
bool *is_plain, char previous) {
|
||||
if (*quote == '\0') {
|
||||
char c = data[*pos];
|
||||
if ((c == '\'' || c == '"') && !(previous == '\0' || previous == ':' || previous == '-')) {
|
||||
*is_plain = true;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return yaml_scan_quote(data, end, pos, quote, is_plain);
|
||||
}
|
||||
|
||||
static int yaml_find_comment(const char *data, size_t start, size_t end, size_t *out_comment) {
|
||||
char quote = '\0';
|
||||
for (size_t i = start; i < end; i++) {
|
||||
@@ -1267,8 +1373,21 @@ static int yaml_find_comment(const char *data, size_t start, size_t end, size_t
|
||||
* honoured as one when nothing has appeared before it in the value. `key: *a`
|
||||
* is still an alias and still refused; `key: 2 * 3` and `key: a*b` are text.
|
||||
* `{`/`}` keep their existing treatment here; mapping-body validation admits
|
||||
* exact empty flow mappings through a context-specific exception. */
|
||||
* exact empty flow mappings through a context-specific exception.
|
||||
*
|
||||
* allow_open_dquote: the caller established (via the doc-level continuation
|
||||
* flags, #1631) that this range legitimately ends inside a double-quoted
|
||||
* scalar continued on the next line — an open `"` at range end is then not an
|
||||
* error. Every other caller keeps the fail-closed unterminated-quote check. */
|
||||
static int yaml_range_has_unsupported_ex(const char *data, size_t start, size_t end,
|
||||
bool allow_open_dquote);
|
||||
|
||||
static int yaml_range_has_unsupported(const char *data, size_t start, size_t end) {
|
||||
return yaml_range_has_unsupported_ex(data, start, end, false);
|
||||
}
|
||||
|
||||
static int yaml_range_has_unsupported_ex(const char *data, size_t start, size_t end,
|
||||
bool allow_open_dquote) {
|
||||
char quote = '\0';
|
||||
/* Last significant character seen, so an indicator can be judged by what
|
||||
* PRECEDES it. The scanned range covers a whole line including its key, so
|
||||
@@ -1277,9 +1396,14 @@ static int yaml_range_has_unsupported(const char *data, size_t start, size_t end
|
||||
* first. A node begins at the range start, after a mapping colon, or after
|
||||
* a block-sequence dash. */
|
||||
char previous = '\0';
|
||||
/* A continuation range ends with the escaping backslash itself; keep it
|
||||
* out of the scan so yaml_scan_quote never sees a dangling escape. */
|
||||
if (allow_open_dquote && end > start && data[end - YAML_UNIT] == '\\') {
|
||||
end--;
|
||||
}
|
||||
for (size_t i = start; i < end; i++) {
|
||||
bool is_plain = false;
|
||||
if (yaml_scan_quote(data, end, &i, "e, &is_plain) != 0) {
|
||||
if (yaml_scan_quote_positional(data, end, &i, "e, &is_plain, previous) != 0) {
|
||||
return YAML_MATCH;
|
||||
}
|
||||
if (!is_plain) {
|
||||
@@ -1307,6 +1431,9 @@ static int yaml_range_has_unsupported(const char *data, size_t start, size_t end
|
||||
return YAML_MATCH;
|
||||
}
|
||||
}
|
||||
if (quote == '"' && allow_open_dquote) {
|
||||
return 0;
|
||||
}
|
||||
return quote != '\0';
|
||||
}
|
||||
|
||||
@@ -1334,9 +1461,13 @@ static int yaml_find_mapping_colon(const char *data, size_t start, size_t end, s
|
||||
|
||||
static int yaml_validate_root_mapping(const yaml_doc_t *doc) {
|
||||
bool have_top_level_entry = false;
|
||||
/* YAML allows a block sequence at the same indent as its mapping key
|
||||
* (#1631): `agents:` followed by `- alpha` at column 0. Item lines are
|
||||
* only legal directly after a value-less top-level key or another item. */
|
||||
bool in_column_zero_sequence = false;
|
||||
for (size_t i = 0U; i < doc->line_count; i++) {
|
||||
const yaml_line_t *line = &doc->lines[i];
|
||||
if (line->blank || line->comment) {
|
||||
if (line->blank || line->comment || line->dquote_cont) {
|
||||
continue;
|
||||
}
|
||||
if (line->indent != 0U) {
|
||||
@@ -1347,6 +1478,14 @@ static int yaml_validate_root_mapping(const yaml_doc_t *doc) {
|
||||
}
|
||||
|
||||
size_t start = line->start;
|
||||
bool is_item = doc->data[start] == '-' &&
|
||||
(start + YAML_UNIT == line->text_end || doc->data[start + YAML_UNIT] == ' ');
|
||||
if (is_item) {
|
||||
if (!in_column_zero_sequence) {
|
||||
return YAML_ERROR;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (doc->data[start] == '%' || doc->data[start] == '?' || doc->data[start] == ':') {
|
||||
return YAML_ERROR;
|
||||
}
|
||||
@@ -1365,6 +1504,8 @@ static int yaml_validate_root_mapping(const yaml_doc_t *doc) {
|
||||
return YAML_ERROR;
|
||||
}
|
||||
have_top_level_entry = true;
|
||||
size_t tail = yaml_skip_spaces(doc->data, colon + YAML_UNIT, line->text_end);
|
||||
in_column_zero_sequence = tail == line->text_end || doc->data[tail] == '#';
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -1387,17 +1528,35 @@ static int yaml_tail_is_explicit_empty_mapping(const char *data, size_t colon, s
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Exact empty flow collection (`{}` or `[]`) as the whole value, optionally
|
||||
* followed by a comment. Used ONLY by the document VALIDATORS (#1631/#1673):
|
||||
* an empty flow collection is preserved opaque user content. Section-header
|
||||
* semantics keep the mapping-only helper above — `section: []` is a sequence,
|
||||
* never an empty mapping section. */
|
||||
static int yaml_tail_is_explicit_empty_flow(const char *data, size_t colon, size_t end,
|
||||
bool *out_empty) {
|
||||
size_t comment = 0U;
|
||||
if (yaml_find_comment(data, colon + YAML_UNIT, end, &comment) != 0) {
|
||||
return YAML_ERROR;
|
||||
}
|
||||
size_t value_start = yaml_skip_spaces(data, colon + YAML_UNIT, comment);
|
||||
size_t value_end = yaml_trim_spaces_end(data, value_start, comment);
|
||||
*out_empty = value_end - value_start == YAML_QUOTED_MIN_LEN &&
|
||||
((data[value_start] == '{' && data[value_start + YAML_UNIT] == '}') ||
|
||||
(data[value_start] == '[' && data[value_start + YAML_UNIT] == ']'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int yaml_mapping_body_line_has_unsupported(const yaml_doc_t *doc, const yaml_line_t *line) {
|
||||
size_t start = line->start + line->indent;
|
||||
size_t colon = 0U;
|
||||
bool empty_mapping = false;
|
||||
bool empty_flow = false;
|
||||
if (yaml_find_mapping_colon(doc->data, start, line->text_end, &colon) == 0 &&
|
||||
yaml_tail_is_explicit_empty_mapping(doc->data, colon, line->text_end, &empty_mapping) ==
|
||||
0 &&
|
||||
empty_mapping) {
|
||||
yaml_tail_is_explicit_empty_flow(doc->data, colon, line->text_end, &empty_flow) == 0 &&
|
||||
empty_flow) {
|
||||
return yaml_range_has_unsupported(doc->data, start, colon + YAML_UNIT);
|
||||
}
|
||||
return yaml_range_has_unsupported(doc->data, start, line->text_end);
|
||||
return yaml_range_has_unsupported_ex(doc->data, start, line->text_end, line->dquote_opens);
|
||||
}
|
||||
|
||||
static int yaml_value_starts_multiline(const char *data, size_t colon, size_t end) {
|
||||
@@ -1413,7 +1572,7 @@ static int yaml_validate_mapping_body(const yaml_doc_t *doc, size_t first_line,
|
||||
bool have_entry = false;
|
||||
for (size_t i = first_line; i < end_line; i++) {
|
||||
const yaml_line_t *line = &doc->lines[i];
|
||||
if (line->blank || line->comment) {
|
||||
if (line->blank || line->comment || line->dquote_cont) {
|
||||
continue;
|
||||
}
|
||||
if (line->indent < YAML_ENTRY_INDENT || (line->indent & YAML_UNIT) != 0U ||
|
||||
@@ -2390,7 +2549,7 @@ static size_t yaml_sequence_nested_end(const yaml_doc_t *doc, size_t header_line
|
||||
size_t indent = doc->lines[header_line].indent;
|
||||
for (size_t i = header_line + YAML_UNIT; i < parent_end; i++) {
|
||||
const yaml_line_t *line = &doc->lines[i];
|
||||
if (!line->blank && !line->comment && line->indent <= indent) {
|
||||
if (!line->blank && !line->comment && !line->dquote_cont && line->indent <= indent) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -2399,16 +2558,31 @@ static size_t yaml_sequence_nested_end(const yaml_doc_t *doc, size_t header_line
|
||||
|
||||
static int yaml_sequence_line_has_unsupported(const yaml_doc_t *doc, const yaml_line_t *line) {
|
||||
size_t start = line->start + line->indent;
|
||||
if (yaml_range_has_unsupported(doc->data, start, line->text_end)) {
|
||||
/* Exact empty flow collections as values (`envs: {}`, `plugins: []`) are
|
||||
* preserved opaque user content (#1631/#1673): validate only the key. */
|
||||
size_t flow_colon = 0U;
|
||||
bool empty_flow = false;
|
||||
if (yaml_find_mapping_colon(doc->data, start, line->text_end, &flow_colon) == 0 &&
|
||||
yaml_tail_is_explicit_empty_flow(doc->data, flow_colon, line->text_end, &empty_flow) == 0 &&
|
||||
empty_flow) {
|
||||
return yaml_range_has_unsupported(doc->data, start, flow_colon + YAML_UNIT);
|
||||
}
|
||||
size_t scan_end = line->text_end;
|
||||
if (line->dquote_opens && scan_end > start && doc->data[scan_end - YAML_UNIT] == '\\') {
|
||||
scan_end--;
|
||||
}
|
||||
if (yaml_range_has_unsupported_ex(doc->data, start, line->text_end, line->dquote_opens)) {
|
||||
return YAML_MATCH;
|
||||
}
|
||||
char quote = '\0';
|
||||
for (size_t i = start; i < line->text_end; i++) {
|
||||
char previous = '\0';
|
||||
for (size_t i = start; i < scan_end; i++) {
|
||||
bool is_plain = false;
|
||||
if (yaml_scan_quote(doc->data, line->text_end, &i, "e, &is_plain) != 0) {
|
||||
if (yaml_scan_quote_positional(doc->data, scan_end, &i, "e, &is_plain, previous) != 0) {
|
||||
return YAML_MATCH;
|
||||
}
|
||||
if (!is_plain) {
|
||||
previous = 'q';
|
||||
continue;
|
||||
}
|
||||
char value = doc->data[i];
|
||||
@@ -2418,6 +2592,12 @@ static int yaml_sequence_line_has_unsupported(const yaml_doc_t *doc, const yaml_
|
||||
if (value == '[' || value == ']' || value == '|' || value == '>') {
|
||||
return YAML_MATCH;
|
||||
}
|
||||
if (value != ' ' && value != '\t') {
|
||||
previous = value;
|
||||
}
|
||||
}
|
||||
if (quote == '"' && line->dquote_opens) {
|
||||
return 0;
|
||||
}
|
||||
return quote != '\0' ? YAML_MATCH : 0;
|
||||
}
|
||||
@@ -2425,7 +2605,7 @@ static int yaml_sequence_line_has_unsupported(const yaml_doc_t *doc, const yaml_
|
||||
static int yaml_sequence_validate_document(const yaml_doc_t *doc) {
|
||||
for (size_t i = 0U; i < doc->line_count; i++) {
|
||||
const yaml_line_t *line = &doc->lines[i];
|
||||
if (line->blank || line->comment) {
|
||||
if (line->blank || line->comment || line->dquote_cont) {
|
||||
continue;
|
||||
}
|
||||
if ((line->indent & YAML_UNIT) != 0U || yaml_sequence_line_has_unsupported(doc, line)) {
|
||||
@@ -2456,11 +2636,15 @@ static int yaml_sequence_decode_field_key(const yaml_doc_t *doc, size_t start, s
|
||||
static int yaml_sequence_validate_mapping_range(const yaml_doc_t *doc, size_t begin_line,
|
||||
size_t end_line, size_t direct_indent) {
|
||||
bool have_direct = false;
|
||||
/* YAML allows a block sequence at the same indent as its mapping key
|
||||
* (#1631): a dash line directly after a value-less key at this indent is
|
||||
* that key's opaque foreign value, not malformed structure. */
|
||||
bool prev_key_open = false;
|
||||
yaml_sequence_key_vec_t keys = {0};
|
||||
int result = 0;
|
||||
for (size_t i = begin_line; i < end_line; i++) {
|
||||
const yaml_line_t *line = &doc->lines[i];
|
||||
if (line->blank || line->comment) {
|
||||
if (line->blank || line->comment || line->dquote_cont) {
|
||||
continue;
|
||||
}
|
||||
if (line->indent < direct_indent || (line->indent > direct_indent && !have_direct)) {
|
||||
@@ -2471,10 +2655,19 @@ static int yaml_sequence_validate_mapping_range(const yaml_doc_t *doc, size_t be
|
||||
continue;
|
||||
}
|
||||
size_t start = line->start + direct_indent;
|
||||
if (start >= line->text_end || doc->data[start] == '-') {
|
||||
if (start >= line->text_end) {
|
||||
result = YAML_ERROR;
|
||||
break;
|
||||
}
|
||||
if (doc->data[start] == '-') {
|
||||
bool is_item =
|
||||
start + YAML_UNIT == line->text_end || doc->data[start + YAML_UNIT] == ' ';
|
||||
if (!is_item || !prev_key_open) {
|
||||
result = YAML_ERROR;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
size_t colon = 0U;
|
||||
char *key = NULL;
|
||||
if (yaml_sequence_decode_field_key(doc, start, line->text_end, &colon, &key) != 0 ||
|
||||
@@ -2483,6 +2676,7 @@ static int yaml_sequence_validate_mapping_range(const yaml_doc_t *doc, size_t be
|
||||
break;
|
||||
}
|
||||
have_direct = true;
|
||||
prev_key_open = yaml_tail_is_empty(doc->data, colon, line->text_end) != 0;
|
||||
}
|
||||
yaml_sequence_key_vec_free(&keys);
|
||||
return result;
|
||||
@@ -2557,7 +2751,7 @@ static int yaml_sequence_parse_item(const yaml_doc_t *doc, size_t start_line, si
|
||||
int result = 0;
|
||||
for (size_t i = start_line; i < end_line; i++) {
|
||||
const yaml_line_t *line = &doc->lines[i];
|
||||
if (line->blank || line->comment) {
|
||||
if (line->blank || line->comment || line->dquote_cont) {
|
||||
continue;
|
||||
}
|
||||
size_t field_start = 0U;
|
||||
@@ -2611,7 +2805,7 @@ static int yaml_sequence_parse_block(const yaml_doc_t *doc, size_t begin_line, s
|
||||
size_t start_count = 0U;
|
||||
for (size_t i = begin_line; i < end_line; i++) {
|
||||
const yaml_line_t *line = &doc->lines[i];
|
||||
if (line->blank || line->comment) {
|
||||
if (line->blank || line->comment || line->dquote_cont) {
|
||||
continue;
|
||||
}
|
||||
if (line->indent == item_indent) {
|
||||
@@ -3037,6 +3231,112 @@ static int yaml_remove_mapping_entry_locked(const char *file_path, const char *s
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* #1631 / goose upgrades: an existing entry under OUR key whose bytes differ
|
||||
* from today's canonical is still OURS — and repairable — when it parses as a
|
||||
* shape a previous release wrote AND its command value's basename is our
|
||||
* binary. Recognized prior shapes, nothing else:
|
||||
* header ` <key>:` then EITHER exactly one ` command: V` line (the YAML
|
||||
* stdio schema; early releases wrote V unquoted), OR the goose block
|
||||
* `type/cmd/args/enabled` in order with an optional leading `name:` (the
|
||||
* pre-#1675 block had no name). V may be plain or double-quoted; its
|
||||
* basename must be codebase-memory-mcp[.exe]. Anything else stays FOREIGN
|
||||
* (fail-closed). */
|
||||
static bool yaml_owned_value_is_our_binary(const char *v, size_t vlen) {
|
||||
if (vlen >= 2U && v[0] == '"' && v[vlen - 1U] == '"') {
|
||||
v++;
|
||||
vlen -= 2U;
|
||||
}
|
||||
size_t base = 0U;
|
||||
for (size_t i = 0; i < vlen; i++) {
|
||||
if (v[i] == '/' || v[i] == '\\') {
|
||||
base = i + 1U;
|
||||
}
|
||||
}
|
||||
const char *name = v + base;
|
||||
size_t name_len = vlen - base;
|
||||
static const char plain[] = "codebase-memory-mcp";
|
||||
static const char exe[] = "codebase-memory-mcp.exe";
|
||||
return (name_len == sizeof(plain) - 1U && memcmp(name, plain, name_len) == 0) ||
|
||||
(name_len == sizeof(exe) - 1U && memcmp(name, exe, name_len) == 0);
|
||||
}
|
||||
|
||||
static bool yaml_owned_entry_is_prior_shape(const char *data, size_t len, const char *entry_key,
|
||||
size_t entry_key_len) {
|
||||
/* Split into lines, tolerating CRLF. */
|
||||
enum { PRIOR_MAX_LINES = 8 };
|
||||
struct {
|
||||
const char *text;
|
||||
size_t len;
|
||||
} lines[PRIOR_MAX_LINES];
|
||||
size_t count = 0U;
|
||||
size_t pos = 0U;
|
||||
while (pos < len) {
|
||||
const char *nl = memchr(data + pos, '\n', len - pos);
|
||||
size_t line_len = nl ? (size_t)(nl - (data + pos)) : len - pos;
|
||||
size_t trimmed = line_len;
|
||||
while (trimmed > 0U && (data[pos + trimmed - 1U] == '\r')) {
|
||||
trimmed--;
|
||||
}
|
||||
if (trimmed > 0U) {
|
||||
if (count == PRIOR_MAX_LINES) {
|
||||
return false;
|
||||
}
|
||||
lines[count].text = data + pos;
|
||||
lines[count].len = trimmed;
|
||||
count++;
|
||||
}
|
||||
pos += line_len + (nl ? 1U : 0U);
|
||||
}
|
||||
if (count < 2U) {
|
||||
return false;
|
||||
}
|
||||
/* Header: ` <key>:` exactly. */
|
||||
if (lines[0].len != entry_key_len + 3U || memcmp(lines[0].text, " ", 2U) != 0 ||
|
||||
memcmp(lines[0].text + 2U, entry_key, entry_key_len) != 0 ||
|
||||
lines[0].text[2U + entry_key_len] != ':') {
|
||||
return false;
|
||||
}
|
||||
/* Body lines: 4-space indent, known fields only. */
|
||||
static const char cmd_prefix[] = " command: ";
|
||||
static const char goose_name[] = " name: codebase-memory-mcp";
|
||||
static const char goose_type[] = " type: stdio";
|
||||
static const char goose_cmd[] = " cmd: ";
|
||||
static const char goose_args[] = " args: []";
|
||||
static const char goose_enabled[] = " enabled: true";
|
||||
if (count == 2U && lines[1].len > sizeof(cmd_prefix) - 1U &&
|
||||
memcmp(lines[1].text, cmd_prefix, sizeof(cmd_prefix) - 1U) == 0) {
|
||||
return yaml_owned_value_is_our_binary(lines[1].text + sizeof(cmd_prefix) - 1U,
|
||||
lines[1].len - (sizeof(cmd_prefix) - 1U));
|
||||
}
|
||||
size_t i = 1U;
|
||||
if (i < count && lines[i].len == sizeof(goose_name) - 1U &&
|
||||
memcmp(lines[i].text, goose_name, lines[i].len) == 0) {
|
||||
i++;
|
||||
}
|
||||
if (i + 4U != count) {
|
||||
return false;
|
||||
}
|
||||
if (lines[i].len != sizeof(goose_type) - 1U ||
|
||||
memcmp(lines[i].text, goose_type, lines[i].len) != 0) {
|
||||
return false;
|
||||
}
|
||||
i++;
|
||||
if (lines[i].len <= sizeof(goose_cmd) - 1U ||
|
||||
memcmp(lines[i].text, goose_cmd, sizeof(goose_cmd) - 1U) != 0 ||
|
||||
!yaml_owned_value_is_our_binary(lines[i].text + sizeof(goose_cmd) - 1U,
|
||||
lines[i].len - (sizeof(goose_cmd) - 1U))) {
|
||||
return false;
|
||||
}
|
||||
i++;
|
||||
if (lines[i].len != sizeof(goose_args) - 1U ||
|
||||
memcmp(lines[i].text, goose_args, lines[i].len) != 0) {
|
||||
return false;
|
||||
}
|
||||
i++;
|
||||
return lines[i].len == sizeof(goose_enabled) - 1U &&
|
||||
memcmp(lines[i].text, goose_enabled, lines[i].len) == 0;
|
||||
}
|
||||
|
||||
static int yaml_edit_owned_mapping_entry_locked(const char *file_path, const char *section_key,
|
||||
const char *entry_key,
|
||||
const char *canonical_entry_block, bool remove) {
|
||||
@@ -3096,14 +3396,17 @@ static int yaml_edit_owned_mapping_entry_locked(const char *file_path, const cha
|
||||
}
|
||||
entry_start = header->start;
|
||||
size_t existing_entry_len = target.entry_end - entry_start;
|
||||
if (existing_entry_len != canonical.len ||
|
||||
memcmp(doc.data + entry_start, canonical.data, canonical.len) != 0) {
|
||||
bool canonical_match = existing_entry_len == canonical.len &&
|
||||
memcmp(doc.data + entry_start, canonical.data, canonical.len) == 0;
|
||||
if (!canonical_match &&
|
||||
!yaml_owned_entry_is_prior_shape(doc.data + entry_start, existing_entry_len, entry_key,
|
||||
entry_len)) {
|
||||
yaml_buf_free(&canonical);
|
||||
yaml_doc_free(&doc);
|
||||
free(data);
|
||||
return CBM_YAML_IDENTITY_EDIT_FOREIGN;
|
||||
}
|
||||
if (!remove) {
|
||||
if (!remove && canonical_match) {
|
||||
yaml_buf_free(&canonical);
|
||||
yaml_doc_free(&doc);
|
||||
free(data);
|
||||
@@ -3123,7 +3426,7 @@ static int yaml_edit_owned_mapping_entry_locked(const char *file_path, const cha
|
||||
? yaml_build_last_mapping_entry_removal(&out, &doc, &target)
|
||||
: yaml_splice(&out, &doc, entry_start, target.entry_end, NULL, 0U);
|
||||
} else {
|
||||
build_result = yaml_build_mapping_update(&out, &doc, &target, NULL, section_key,
|
||||
build_result = yaml_build_mapping_update(&out, &doc, &target, header, section_key,
|
||||
section_len, &canonical);
|
||||
}
|
||||
int result = build_result == 0 ? yaml_commit_if_changed(file_path, data, len, &snapshot, &out)
|
||||
|
||||
@@ -913,6 +913,216 @@ TEST(config_yaml_edit_goose_still_rejects_merge_key_with_empty_mapping_issue1673
|
||||
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();
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
@@ -1694,6 +1904,15 @@ SUITE(config_yaml_edit) {
|
||||
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_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);
|
||||
|
||||
Reference in new issue
Block a user