Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/cli/cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -9399,8 +9399,30 @@ static void install_devin_durable_context(const cbm_agent_registry_context_t *re
static void install_generated_client_extension(const char *label, const char *path,
const char *binary_path,
char *(*generate)(const char *), bool dry_run) {
/* #2204: a module that already carries content but none of our markers is
* the user's. The generated header promises "remove the markers to take
* ownership", and appending our prelude to a foreign module produces a file
* the client cannot even parse — the prelude redeclares the user's own
* import and const names, and both sides declare a default export. Probe
* ownership before generating so the preview names the refusal instead of
* promising a write the run will not make. A probe failure (-1) falls
* through to the write, which reports it through the usual error path. */
bool foreign =
cbm_text_managed_block_status(path, CBM_ADAPTER_MARKER_START, CBM_ADAPTER_MARKER_END) == 1;
if (g_install_plan) {
plan_record(label, "extension", path);
if (foreign) {
printf(" extension: %s would be skipped — the file there is not ours (no "
"codebase-memory-mcp markers), so it is left untouched; delete it or add the "
"markers to let the installer manage it\n",
path);
}
return;
}
if (foreign) {
printf(" extension: %s left untouched — the file there is not ours (no "
"codebase-memory-mcp markers)\n",
path);
return;
}
char *content = generate(binary_path);
Expand Down
38 changes: 38 additions & 0 deletions src/cli/config_text_edit.c
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,44 @@ int cbm_text_upsert_managed_block_limited(const char *file_path, const char *beg
max_document_bytes);
}

int cbm_text_managed_block_status(const char *file_path, const char *begin_marker,
const char *end_marker) {
size_t begin_len = 0U;
size_t end_len = 0U;
if (!text_valid_path(file_path) ||
text_validate_markers(begin_marker, end_marker, &begin_len, &end_len) != TEXT_OK) {
return TEXT_ERROR;
}
char *data = NULL;
size_t len = 0U;
text_file_snapshot_t snapshot;
if (text_read_file(file_path, &data, &len, &snapshot) != TEXT_OK) {
free(data);
return TEXT_ERROR;
}
int result = TEXT_OK;
if (snapshot.exists && text_validate_bytes(data, len, 1) != TEXT_OK) {
result = TEXT_ERROR;
} else if (snapshot.exists) {
size_t bom_len = text_bom_length(data, len);
text_managed_region_t region;
if (text_scan_managed_region(data, len, bom_len, begin_marker, begin_len, end_marker,
end_len, &region) != TEXT_OK) {
result = TEXT_ERROR;
} else if (!region.present) {
for (size_t pos = bom_len; pos < len; pos++) {
char byte = data[pos];
if (byte != ' ' && byte != '\t' && byte != '\r' && byte != '\n') {
result = TEXT_UNOWNED;
break;
}
}
}
}
free(data);
return result;
}

int cbm_text_remove_managed_block(const char *file_path, const char *begin_marker,
const char *end_marker) {
size_t begin_len = 0U;
Expand Down
10 changes: 10 additions & 0 deletions src/cli/config_text_edit.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ int cbm_text_upsert_managed_block(const char *file_path, const char *begin_marke
int cbm_text_upsert_managed_block_limited(const char *file_path, const char *begin_marker,
const char *end_marker, const char *owned_content,
size_t max_document_bytes);
/* Read-only ownership probe for a managed block, used before writing a file a
* client auto-loads as a module. Returns 0 when the block may be written (no
* file, a file whose content is only whitespace, or a file already carrying our
* markers), 1 when the file carries content but none of our markers — the
* caller must leave the user's file alone and say so rather than append to it,
* because the generated block redeclares module-level names a hand-ported
* module also declares (#2204) — and -1 for unsafe state or I/O failure. Never
* writes, so a dry run may call it. */
int cbm_text_managed_block_status(const char *file_path, const char *begin_marker,
const char *end_marker);
int cbm_text_remove_managed_block(const char *file_path, const char *begin_marker,
const char *end_marker);

Expand Down
74 changes: 74 additions & 0 deletions tests/test_cli.c
Original file line number Diff line number Diff line change
Expand Up @@ -9220,6 +9220,79 @@ TEST(cli_dry_run_predicts_refused_hook_script_issue1387) {
FAIL("dry-run must predict a refused hook-script rewrite (#1387)");
PASS();
}

/* #2204: the OpenCode adapter is an auto-loaded module, and the generated block
* tells readers to "remove the markers to take ownership". A marker-less file
* with content is the user's: appending the block to a hand-ported module
* produces a file the client cannot even parse (the prelude redeclares the
* user's own import and const names, and both sides declare a default export).
* The installer must leave that file alone, and the dry run must say so instead
* of promising a write it will not make. */
TEST(cli_extension_leaves_user_owned_module_untouched_issue2204) {
char home[256];
char bin[320];
if (!client_detect_fixture(home, sizeof(home), bin, sizeof(bin)))
FAIL("cbm_mkdtemp failed");

/* An `opencode` CLI on the isolated PATH marks OpenCode as installed. */
char exe[512];
snprintf(exe, sizeof(exe), "%s/opencode", bin);
if (write_test_file(exe, "#!/bin/sh\nexit 0\n") != 0 || chmod(exe, 0755) != 0)
FAIL("opencode fixture failed");

char plugins_dir[512];
snprintf(plugins_dir, sizeof(plugins_dir), "%s/.config/opencode/plugins", home);
test_mkdirp(plugins_dir);
char plugin_path[768];
snprintf(plugin_path, sizeof(plugin_path), "%s/cbm-augment.ts", plugins_dir);
const char foreign[] = "import { spawn } from 'node:child_process';\n"
"const BIN = '/opt/other/codebase-memory-mcp';\n"
"export default { id: 'codebase-memory-augment', setup() {} };\n";
if (write_test_file(plugin_path, foreign) != 0)
FAIL("foreign module fixture failed");

client_detect_env_t saved = client_detect_env_isolate(bin);
FILE *capture = tmpfile();
int saved_stdout = capture ? dup(STDOUT_FILENO) : -1;
bool redirected = false;
if (capture && saved_stdout >= 0) {
fflush(stdout);
redirected = dup2(fileno(capture), STDOUT_FILENO) >= 0;
}
if (redirected) {
(void)cbm_install_agent_configs(home, "/opt/other/codebase-memory-mcp", false, true);
fflush(stdout);
(void)dup2(saved_stdout, STDOUT_FILENO);
}
if (saved_stdout >= 0)
close(saved_stdout);
client_detect_env_restore(&saved);

char output[16384] = {0};
if (capture) {
rewind(capture);
size_t count = fread(output, 1, sizeof(output) - 1U, capture);
output[count] = '\0';
fclose(capture);
}

char bytes[512] = {0};
FILE *file = cbm_fopen(plugin_path, "rb");
size_t got = file ? fread(bytes, 1U, sizeof(bytes) - 1U, file) : 0U;
if (file)
fclose(file);
bool warned = strstr(output, "cbm-augment.ts") != NULL && strstr(output, "not ours") != NULL;
bool untouched = got == sizeof(foreign) - 1U && memcmp(bytes, foreign, got) == 0;

test_rmdir_r(home);
if (!redirected)
FAIL("stdout capture failed");
if (!warned)
FAIL("dry-run must predict an untouched user-owned OpenCode module (#2204)");
if (!untouched)
FAIL("the user-owned OpenCode module must not be modified (#2204)");
PASS();
}
#endif

TEST(cli_agent_client_registry_routes_plan_install_and_uninstall) {
Expand Down Expand Up @@ -18360,6 +18433,7 @@ SUITE(cli) {
#endif
#ifndef _WIN32
RUN_TEST(cli_dry_run_predicts_refused_hook_script_issue1387);
RUN_TEST(cli_extension_leaves_user_owned_module_untouched_issue2204);
#endif
RUN_TEST(cli_agent_client_registry_routes_plan_install_and_uninstall);
RUN_TEST(cli_registry_installs_kimi_rovo_amp_durable_context);
Expand Down
39 changes: 39 additions & 0 deletions tests/test_config_text_edit.c
Original file line number Diff line number Diff line change
Expand Up @@ -725,13 +725,52 @@ TEST(config_text_write_owned_document_if_unchanged_rejects_stale_snapshot) {
PASS();
}

/* #2204: the generated OpenCode module must not be appended to a file the user
* owns. The probe separates "may be written" (absent, whitespace-only, or
* already carrying our markers) from "the user's file" (content, no markers) so
* the installer can leave it alone instead of producing an unparseable hybrid. */
TEST(config_text_managed_block_status_reports_foreign_module) {
char dir[CTE_PATH_CAP];
char path[CTE_PATH_CAP];
const char whitespace[] = " \n\t\n";
const char foreign[] = "import { spawn } from 'node:child_process';\n"
"export default { id: 'codebase-memory-augment', setup() {} };\n";
const char unbalanced[] = CTE_BEGIN "\nowned\n";
ASSERT_EQ(cte_fixture(dir, sizeof(dir), path, sizeof(path)), 0);

/* No file yet, an empty file, and a whitespace-only file are all writable. */
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
ASSERT_EQ(cte_write_bytes(path, "", 0U), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);
ASSERT_EQ(cte_write_bytes(path, whitespace, sizeof(whitespace) - 1U), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);

/* Our own block refreshes in place, so it is writable too. */
ASSERT_EQ(cbm_text_upsert_managed_block(path, CTE_BEGIN, CTE_END, "owned"), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 0);

/* A hand-ported module with no markers is the user's: refuse, never append. */
ASSERT_EQ(cte_write_bytes(path, foreign, sizeof(foreign) - 1U), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), 1);

/* Unbalanced markers and malformed marker arguments are unsafe states. */
ASSERT_EQ(cte_write_bytes(path, unbalanced, sizeof(unbalanced) - 1U), 0);
ASSERT_EQ(cbm_text_managed_block_status(path, CTE_BEGIN, CTE_END), -1);
ASSERT_EQ(cbm_text_managed_block_status(path, "same", "same"), -1);
ASSERT_EQ(cbm_text_managed_block_status(NULL, CTE_BEGIN, CTE_END), -1);

th_cleanup(dir);
PASS();
}

SUITE(config_text_edit) {
RUN_TEST(config_text_managed_insert_preserves_bom_comments_and_is_idempotent);
RUN_TEST(config_text_managed_replace_preserves_crlf_surroundings);
RUN_TEST(config_text_managed_no_final_newline_round_trip);
RUN_TEST(config_text_managed_remove_preserves_user_bytes);
RUN_TEST(config_text_managed_malformed_markers_fail_closed);
RUN_TEST(config_text_managed_rejects_unsafe_markers_and_owned_content);
RUN_TEST(config_text_managed_block_status_reports_foreign_module);
RUN_TEST(config_text_owned_document_write_remove_exact_only);
RUN_TEST(config_text_owned_document_migrates_exact_releases_only);
RUN_TEST(config_text_rejects_invalid_utf8_and_controls);
Expand Down
Loading