Files
Martin Vogel fb88365dc0 fix(cli): add no OpenCode 2 compaction note until one has run on a real host
The V2 compaction hook pushed a plain object into the compaction request's
event.messages. OpenCode's Message is a schema class at v2.2.5, and nobody has
run this path on a real 2.x host. The try/catch only guards the array push,
which cannot throw. If OpenCode rejects the object, that happens inside its own
compaction, out of the plugin's reach, so an untested note could break
compaction for every OpenCode 2 user. Losing the note costs far less.

Remove the V2 compaction registration and the sessionHooksRegistered flag it
needed. The V2 tool hook, proven on OpenCode 2.2.5, stays, and so does the
1.18.x experimental.session.compacting path. OpenCode 2 sessions still get the
session note on the first tool result of a session. A later change can add a
note after compaction together with a real 2.x run.

The replacement test pins that the generated module registers no session hook
and touches neither event.messages, a system property nor an instruction entry.

Refs #2204

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-11-01 12:55:46 +01:00

1517 lines
69 KiB
C

/* test_agent_clients.c — Agent-client registry, resolution, and owned MCP edits. */
#include "test_framework.h"
#include "test_helpers.h"
#include <cli/agent_clients.h>
#include <cli/client_adapter.h>
#include <foundation/constants.h>
#include <mcp/mcp.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
const char *paths[16];
size_t path_count;
const char *commands[16];
size_t command_count;
} agent_probe_t;
static bool agent_probe_contains(const char *value, const void *context, bool commands) {
const agent_probe_t *probe = (const agent_probe_t *)context;
const char *const *values = commands ? probe->commands : probe->paths;
size_t count = commands ? probe->command_count : probe->path_count;
for (size_t i = 0U; i < count; i++) {
if (strcmp(values[i], value) == 0) {
return true;
}
}
return false;
}
static bool agent_path_exists(const char *value, const void *context) {
return agent_probe_contains(value, context, false);
}
static bool agent_command_exists(const char *value, const void *context) {
return agent_probe_contains(value, context, true);
}
static cbm_agent_client_resolve_options_t agent_options(agent_probe_t *probe) {
cbm_agent_client_resolve_options_t options = {
.home_dir = "/home/tester",
.xdg_config_home = NULL,
.appdata_dir = NULL,
.glab_config_dir = NULL,
.kimi_code_home = NULL,
.continue_config_path = NULL,
.trae_config_path = NULL,
.roo_config_path = NULL,
.cody_config_path = NULL,
.omp_agent_dir = NULL,
.is_windows = false,
.path_exists = agent_path_exists,
.command_exists = agent_command_exists,
.probe_context = probe,
};
return options;
}
static char *agent_fixture(const char *initial, char **dir_out) {
char *dir = th_mktempdir("cbm_agent_clients");
if (!dir) {
return NULL;
}
size_t length = strlen(dir) + strlen("/config.json") + 1U;
char *path = (char *)malloc(length);
if (!path) {
th_cleanup(dir);
return NULL;
}
snprintf(path, length, "%s/config.json", dir);
if (initial && th_write_file(path, initial) != 0) {
free(path);
th_cleanup(dir);
return NULL;
}
*dir_out = dir;
return path;
}
static char *agent_read(const char *path) {
FILE *fp = fopen(path, "rb");
if (!fp || fseek(fp, 0L, SEEK_END) != 0) {
if (fp) {
fclose(fp);
}
return NULL;
}
long length = ftell(fp);
if (length < 0L || fseek(fp, 0L, SEEK_SET) != 0) {
fclose(fp);
return NULL;
}
char *text = (char *)malloc((size_t)length + 1U);
if (!text) {
fclose(fp);
return NULL;
}
size_t read_count = fread(text, 1U, (size_t)length, fp);
int close_rc = fclose(fp);
if (read_count != (size_t)length || close_rc != 0) {
free(text);
return NULL;
}
text[read_count] = '\0';
return text;
}
static size_t agent_occurrences(const char *text, const char *needle) {
size_t count = 0U;
size_t length = strlen(needle);
while (length > 0U && (text = strstr(text, needle)) != NULL) {
count++;
text += length;
}
return count;
}
static int agent_write_rovo_override(const char *config_path, const char *mcp_path) {
size_t length = strlen("mcp:\n mcpConfigPath: \"\"\n") + strlen(mcp_path) + 1U;
char *yaml = (char *)malloc(length);
if (!yaml) {
return -1;
}
int written = snprintf(yaml, length, "mcp:\n mcpConfigPath: \"%s\"\n", mcp_path);
int result = written >= 0 && (size_t)written < length ? th_write_file(config_path, yaml) : -1;
free(yaml);
return result;
}
static char *agent_deep_same_name_json(size_t depth) {
static const char prefix[] = "{\"mcpServers\":";
static const char leaf[] = "{\"codebase-memory-mcp\":{\"command\":\"foreign\",\"args\":[]}}";
size_t prefix_length = strlen(prefix);
size_t leaf_length = strlen(leaf);
if (depth > (SIZE_MAX - leaf_length - 2U) / (prefix_length + 1U)) {
return NULL;
}
size_t capacity = depth * (prefix_length + 1U) + leaf_length + 2U;
char *json = (char *)malloc(capacity);
if (!json) {
return NULL;
}
char *cursor = json;
for (size_t i = 0U; i < depth; i++) {
memcpy(cursor, prefix, prefix_length);
cursor += prefix_length;
}
memcpy(cursor, leaf, leaf_length);
cursor += leaf_length;
for (size_t i = 0U; i < depth; i++) {
*cursor++ = '}';
}
*cursor++ = '\n';
*cursor = '\0';
return json;
}
TEST(agent_clients_registry_is_stable_and_callback_driven) {
static const char *expected[] = {
"qoder", "kimi", "gitlab-duo", "rovo-dev", "amp", "devin",
"tabnine", "continue", "visual-studio", "trae", "roo-code", "amazon-q",
"codebuddy", "ibm-bob-ide", "ibm-bob-shell", "pochi", "pi", "sourcegraph-cody",
"omp",
};
static const uint32_t expected_capabilities[] = {
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_SKILL | CBM_AGENT_CAP_AGENT | CBM_AGENT_CAP_HOOK,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_INSTRUCTIONS | CBM_AGENT_CAP_SKILL | CBM_AGENT_CAP_HOOK,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_HOOK,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_INSTRUCTIONS | CBM_AGENT_CAP_SKILL | CBM_AGENT_CAP_AGENT,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_INSTRUCTIONS | CBM_AGENT_CAP_SKILL,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_INSTRUCTIONS | CBM_AGENT_CAP_SKILL | CBM_AGENT_CAP_HOOK,
CBM_AGENT_CAP_MCP,
CBM_AGENT_CAP_MCP,
CBM_AGENT_CAP_MCP,
CBM_AGENT_CAP_MCP,
CBM_AGENT_CAP_MCP,
CBM_AGENT_CAP_MCP,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_INSTRUCTIONS | CBM_AGENT_CAP_SKILL | CBM_AGENT_CAP_AGENT,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_INSTRUCTIONS | CBM_AGENT_CAP_SKILL,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_INSTRUCTIONS,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_INSTRUCTIONS | CBM_AGENT_CAP_SKILL | CBM_AGENT_CAP_AGENT,
CBM_AGENT_CAP_INSTRUCTIONS | CBM_AGENT_CAP_SKILL,
CBM_AGENT_CAP_MCP,
CBM_AGENT_CAP_MCP | CBM_AGENT_CAP_SKILL | CBM_AGENT_CAP_AGENT,
};
ASSERT_EQ(cbm_agent_client_count(), CBM_AGENT_CLIENT_COUNT);
ASSERT_EQ(CBM_AGENT_CLIENT_COUNT, sizeof(expected) / sizeof(expected[0]));
ASSERT_EQ(CBM_AGENT_CLIENT_COUNT,
sizeof(expected_capabilities) / sizeof(expected_capabilities[0]));
for (size_t i = 0U; i < CBM_AGENT_CLIENT_COUNT; i++) {
const cbm_agent_client_profile_t *profile = cbm_agent_client_at(i);
ASSERT_NOT_NULL(profile);
ASSERT_EQ(profile->id, (cbm_agent_client_id_t)i);
ASSERT_STR_EQ(profile->stable_id, expected[i]);
ASSERT_EQ(profile->capabilities, expected_capabilities[i]);
ASSERT_NOT_NULL(profile->display_name);
if (profile->id == CBM_AGENT_CLIENT_PI) {
ASSERT(!(profile->capabilities & CBM_AGENT_CAP_MCP));
ASSERT_NULL(profile->install_mcp);
ASSERT_NULL(profile->remove_mcp);
} else {
ASSERT(profile->capabilities & CBM_AGENT_CAP_MCP);
ASSERT_NOT_NULL(profile->install_mcp);
ASSERT_NOT_NULL(profile->remove_mcp);
}
ASSERT(cbm_agent_client_by_id(profile->id) == profile);
ASSERT(cbm_agent_client_by_stable_id(profile->stable_id) == profile);
}
ASSERT_NULL(cbm_agent_client_by_id(CBM_AGENT_CLIENT_COUNT));
ASSERT_NULL(cbm_agent_client_by_stable_id("glab"));
ASSERT_EQ(cbm_agent_client_by_id(CBM_AGENT_CLIENT_TRAE)->stability, CBM_AGENT_CONDITIONAL);
PASS();
}
TEST(agent_clients_visual_studio_cleanup_survives_missing_command) {
agent_probe_t probe = {.paths = {"/home/tester/.mcp.json"}, .path_count = 1U};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
options.is_windows = true;
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_VISUAL_STUDIO, &options));
ASSERT(cbm_agent_client_cleanup_candidate(CBM_AGENT_CLIENT_VISUAL_STUDIO, &options));
const char *foreign = "{\"servers\":{\"codebase-memory-mcp\":{\"type\":\"stdio\","
"\"command\":\"C:/User/tool.exe\",\"args\":[]}}}\n";
char *dir = NULL;
char *path = agent_fixture(foreign, &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_remove_mcp(CBM_AGENT_CLIENT_VISUAL_STUDIO, path,
"C:/Tools/codebase-memory-mcp.exe"),
CBM_AGENT_EDIT_FOREIGN);
char *after = agent_read(path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, foreign);
free(after);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_next_wave_metadata_matches_supported_surfaces) {
const cbm_agent_client_profile_t *continue_profile =
cbm_agent_client_by_id(CBM_AGENT_CLIENT_CONTINUE);
const cbm_agent_client_profile_t *visual_studio =
cbm_agent_client_by_id(CBM_AGENT_CLIENT_VISUAL_STUDIO);
const cbm_agent_client_profile_t *rovo = cbm_agent_client_by_id(CBM_AGENT_CLIENT_ROVO_DEV);
const cbm_agent_client_profile_t *gitlab = cbm_agent_client_by_id(CBM_AGENT_CLIENT_GITLAB_DUO);
const cbm_agent_client_profile_t *devin = cbm_agent_client_by_id(CBM_AGENT_CLIENT_DEVIN);
const cbm_agent_client_profile_t *roo = cbm_agent_client_by_id(CBM_AGENT_CLIENT_ROO_CODE);
const cbm_agent_client_profile_t *amazon_q = cbm_agent_client_by_id(CBM_AGENT_CLIENT_AMAZON_Q);
const cbm_agent_client_profile_t *codebuddy =
cbm_agent_client_by_id(CBM_AGENT_CLIENT_CODEBUDDY);
const cbm_agent_client_profile_t *ibm_bob_ide =
cbm_agent_client_by_id(CBM_AGENT_CLIENT_IBM_BOB_IDE);
const cbm_agent_client_profile_t *ibm_bob_shell =
cbm_agent_client_by_id(CBM_AGENT_CLIENT_IBM_BOB_SHELL);
const cbm_agent_client_profile_t *pochi = cbm_agent_client_by_id(CBM_AGENT_CLIENT_POCHI);
const cbm_agent_client_profile_t *pi = cbm_agent_client_by_id(CBM_AGENT_CLIENT_PI);
const cbm_agent_client_profile_t *cody =
cbm_agent_client_by_id(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY);
ASSERT_NOT_NULL(continue_profile);
ASSERT_NOT_NULL(visual_studio);
ASSERT_NOT_NULL(rovo);
ASSERT_NOT_NULL(gitlab);
ASSERT_NOT_NULL(devin);
ASSERT_NOT_NULL(roo);
ASSERT_NOT_NULL(amazon_q);
ASSERT_NOT_NULL(codebuddy);
ASSERT_NOT_NULL(ibm_bob_ide);
ASSERT_NOT_NULL(ibm_bob_shell);
ASSERT_NOT_NULL(pochi);
ASSERT_NOT_NULL(pi);
ASSERT_NOT_NULL(cody);
ASSERT_EQ(continue_profile->stability, CBM_AGENT_CONDITIONAL);
ASSERT_EQ(visual_studio->stability, CBM_AGENT_CONDITIONAL);
ASSERT(rovo->capabilities & CBM_AGENT_CAP_SKILL);
ASSERT(rovo->capabilities & CBM_AGENT_CAP_AGENT);
ASSERT(gitlab->capabilities & CBM_AGENT_CAP_HOOK);
ASSERT(!(devin->capabilities & CBM_AGENT_CAP_AGENT));
ASSERT_EQ(roo->stability, CBM_AGENT_CONDITIONAL);
ASSERT(roo->capabilities & CBM_AGENT_CAP_MCP);
ASSERT(amazon_q->capabilities & CBM_AGENT_CAP_MCP);
ASSERT_STR_EQ(amazon_q->display_name, "Amazon Q Developer IDE");
ASSERT_EQ(codebuddy->stability, CBM_AGENT_STABLE);
ASSERT(codebuddy->capabilities & CBM_AGENT_CAP_AGENT);
ASSERT(codebuddy->capabilities & CBM_AGENT_CAP_SKILL);
ASSERT_STR_EQ(codebuddy->display_name, "CodeBuddy Code CLI");
ASSERT_STR_EQ(codebuddy->detection_command, "codebuddy");
ASSERT_EQ(ibm_bob_ide->stability, CBM_AGENT_CONDITIONAL);
ASSERT_STR_EQ(ibm_bob_ide->display_name, "IBM Bob IDE");
ASSERT_NULL(ibm_bob_ide->detection_command);
ASSERT_EQ(ibm_bob_shell->stability, CBM_AGENT_STABLE);
ASSERT_STR_EQ(ibm_bob_shell->display_name, "IBM Bob Shell");
ASSERT_STR_EQ(ibm_bob_shell->detection_command, "bob");
ASSERT_EQ(pochi->stability, CBM_AGENT_STABLE);
ASSERT(pochi->capabilities & CBM_AGENT_CAP_AGENT);
ASSERT_STR_EQ(pochi->detection_command, "pochi");
ASSERT(!(pi->capabilities & CBM_AGENT_CAP_MCP));
ASSERT(pi->capabilities & CBM_AGENT_CAP_INSTRUCTIONS);
ASSERT(pi->capabilities & CBM_AGENT_CAP_SKILL);
ASSERT_NULL(pi->install_mcp);
ASSERT_NULL(pi->remove_mcp);
ASSERT_EQ(cody->stability, CBM_AGENT_OPT_IN);
ASSERT_EQ(cody->capabilities, CBM_AGENT_CAP_MCP);
ASSERT_NOT_NULL(cody->install_mcp);
ASSERT_NOT_NULL(cody->remove_mcp);
PASS();
}
TEST(agent_clients_resolve_documented_paths_and_precedence) {
agent_probe_t probe = {0};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
char path[512];
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_QODER, &options, path, sizeof(path)),
0);
ASSERT_STR_EQ(path, "/home/tester/.qoder/settings.json");
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_KIMI, &options, path, sizeof(path)),
0);
ASSERT_STR_EQ(path, "/home/tester/.kimi-code/mcp.json");
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_GITLAB_DUO, &options, path, sizeof(path)),
0);
ASSERT_STR_EQ(path, "/home/tester/.gitlab/duo/mcp.json");
options.kimi_code_home = "/opt/kimi home";
options.glab_config_dir = "/opt/gitlab";
options.xdg_config_home = "/xdg";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_KIMI, &options, path, sizeof(path)),
0);
ASSERT_STR_EQ(path, "/opt/kimi home/mcp.json");
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_GITLAB_DUO, &options, path, sizeof(path)),
0);
ASSERT_STR_EQ(path, "/opt/gitlab/duo/mcp.json");
options.glab_config_dir = NULL;
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_GITLAB_DUO, &options, path, sizeof(path)),
0);
ASSERT_STR_EQ(path, "/xdg/gitlab/duo/mcp.json");
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_AMP, &options, path, sizeof(path)), 0);
ASSERT_STR_EQ(path, "/home/tester/.config/agents/skills/codebase-memory/mcp.json");
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_DEVIN, &options, path, sizeof(path)),
0);
ASSERT_STR_EQ(path, "/home/tester/.config/devin/config.json");
options.is_windows = true;
options.appdata_dir = "/roaming";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_DEVIN, &options, path, sizeof(path)),
0);
ASSERT_STR_EQ(path, "/roaming/devin/config.json");
PASS();
}
TEST(agent_clients_resolve_rovo_override_and_conditional_paths) {
char *dir = th_mktempdir("cbm_agent_clients_rovo");
ASSERT_NOT_NULL(dir);
char config[1024];
char safe_override[1024];
ASSERT(snprintf(config, sizeof(config), "%s/.rovodev/config.yml", dir) > 0);
ASSERT(snprintf(safe_override, sizeof(safe_override), "%s/.rovodev/custom/rovo mcp.json", dir) >
0);
ASSERT_EQ(agent_write_rovo_override(config, safe_override), 0);
agent_probe_t probe = {.paths = {config}, .path_count = 1U};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
options.home_dir = dir;
#ifdef _WIN32
options.is_windows = true;
#endif
char path[1024];
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_ROVO_DEV, &options, path, sizeof(path)), 0);
ASSERT_STR_EQ(path, safe_override);
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_CONTINUE, &options, path, sizeof(path)), 1);
options.continue_config_path = config;
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_CONTINUE, &options, path, sizeof(path)), 0);
ASSERT_STR_EQ(path, config);
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_TRAE, &options, path, sizeof(path)),
1);
options.trae_config_path = "/missing/trae.json";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_TRAE, &options, path, sizeof(path)),
1);
probe.paths[probe.path_count++] = options.trae_config_path;
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_TRAE, &options, path, sizeof(path)),
0);
ASSERT_STR_EQ(path, options.trae_config_path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_rovo_override_rejects_relative_and_traversal_paths) {
static const char *invalid_paths[] = {
"mcp.json",
"../outside.json",
"~/.rovodev/../outside.json",
};
char *dir = th_mktempdir("cbm_agent_clients_rovo_relative");
ASSERT_NOT_NULL(dir);
char config[1024];
ASSERT(snprintf(config, sizeof(config), "%s/.rovodev/config.yml", dir) > 0);
agent_probe_t probe = {.paths = {config}, .path_count = 1U};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
options.home_dir = dir;
#ifdef _WIN32
options.is_windows = true;
#endif
for (size_t i = 0U; i < sizeof(invalid_paths) / sizeof(invalid_paths[0]); i++) {
ASSERT_EQ(agent_write_rovo_override(config, invalid_paths[i]), 0);
char resolved[1024] = "untouched-on-rejection";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_ROVO_DEV, &options, resolved,
sizeof(resolved)),
-1);
ASSERT_STR_EQ(resolved, "untouched-on-rejection");
}
th_cleanup(dir);
PASS();
}
TEST(agent_clients_rovo_override_rejects_absolute_paths_outside_user_root) {
char *dir = th_mktempdir("cbm_agent_clients_rovo_absolute");
ASSERT_NOT_NULL(dir);
char config[1024];
char outside[1024];
char prefix_collision[1024];
char absolute_traversal[1024];
ASSERT(snprintf(config, sizeof(config), "%s/.rovodev/config.yml", dir) > 0);
ASSERT(snprintf(outside, sizeof(outside), "%s/outside.json", dir) > 0);
ASSERT(snprintf(prefix_collision, sizeof(prefix_collision), "%s/.rovodev-shadow/mcp.json",
dir) > 0);
ASSERT(snprintf(absolute_traversal, sizeof(absolute_traversal),
"%s/.rovodev/nested/../../outside.json", dir) > 0);
const char *invalid_paths[] = {outside, prefix_collision, absolute_traversal};
agent_probe_t probe = {.paths = {config}, .path_count = 1U};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
options.home_dir = dir;
#ifdef _WIN32
options.is_windows = true;
#endif
for (size_t i = 0U; i < sizeof(invalid_paths) / sizeof(invalid_paths[0]); i++) {
ASSERT_EQ(agent_write_rovo_override(config, invalid_paths[i]), 0);
char resolved[1024] = "untouched-on-rejection";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_ROVO_DEV, &options, resolved,
sizeof(resolved)),
-1);
ASSERT_STR_EQ(resolved, "untouched-on-rejection");
}
th_cleanup(dir);
PASS();
}
TEST(agent_clients_rovo_compatibility_prefers_existing_documented_filename) {
agent_probe_t probe = {0};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
char path[512];
probe.paths[probe.path_count++] = "/home/tester/.rovodev/mcp_config.json";
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_ROVO_DEV, &options, path, sizeof(path)), 0);
ASSERT_STR_EQ(path, "/home/tester/.rovodev/mcp_config.json");
probe.paths[probe.path_count++] = "/home/tester/.rovodev/mcp.json";
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_ROVO_DEV, &options, path, sizeof(path)), 0);
ASSERT_STR_EQ(path, "/home/tester/.rovodev/mcp.json");
probe.path_count = 0U;
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_ROVO_DEV, &options, path, sizeof(path)), 0);
ASSERT_STR_EQ(path, "/home/tester/.rovodev/mcp.json");
PASS();
}
TEST(agent_clients_detection_avoids_generic_binary_false_positives) {
agent_probe_t probe = {.commands = {"glab", "acli"}, .command_count = 2U};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_GITLAB_DUO, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_ROVO_DEV, &options));
probe.commands[probe.command_count++] = "duo";
probe.commands[probe.command_count++] = "rovodev";
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_GITLAB_DUO, &options));
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_ROVO_DEV, &options));
probe.commands[probe.command_count++] = "cn";
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_CONTINUE, &options));
options.is_windows = false;
probe.commands[probe.command_count++] = "devenv";
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_VISUAL_STUDIO, &options));
options.is_windows = true;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_VISUAL_STUDIO, &options));
PASS();
}
TEST(agent_clients_detect_installed_client_directories_before_mcp_exists) {
agent_probe_t probe = {0};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_QODER, &options));
probe.paths[0] = "/home/tester/.qoder";
probe.path_count = 1U;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_QODER, &options));
probe.path_count = 0U;
options.kimi_code_home = "/opt/kimi-code-home";
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_KIMI, &options));
probe.paths[0] = options.kimi_code_home;
probe.path_count = 1U;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_KIMI, &options));
probe.path_count = 0U;
options.glab_config_dir = "/opt/gitlab";
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_GITLAB_DUO, &options));
probe.paths[0] = "/opt/gitlab/duo";
probe.path_count = 1U;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_GITLAB_DUO, &options));
probe.path_count = 0U;
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_ROVO_DEV, &options));
probe.paths[0] = "/home/tester/.rovodev";
probe.path_count = 1U;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_ROVO_DEV, &options));
probe.path_count = 0U;
options.glab_config_dir = NULL;
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_AMP, &options));
probe.paths[0] = "/home/tester/.config/amp";
probe.path_count = 1U;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_AMP, &options));
probe.path_count = 0U;
options.xdg_config_home = "/xdg";
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_DEVIN, &options));
probe.paths[0] = "/home/tester/.config/devin";
probe.path_count = 1U;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_DEVIN, &options));
probe.path_count = 0U;
options.xdg_config_home = NULL;
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_TABNINE, &options));
probe.paths[0] = "/home/tester/.tabnine";
probe.path_count = 1U;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_TABNINE, &options));
probe.path_count = 0U;
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_AMAZON_Q, &options));
probe.paths[0] = "/home/tester/.aws/amazonq";
probe.path_count = 1U;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_AMAZON_Q, &options));
probe.path_count = 0U;
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_PI, &options));
probe.paths[0] = "/home/tester/.pi/agent";
probe.path_count = 1U;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_PI, &options));
PASS();
}
TEST(agent_clients_marker_detection_remains_fail_closed_for_conditional_clients) {
agent_probe_t probe = {
.paths = {"/home/tester/.config/agents", "/home/tester/.continue", "/home/tester/.roo",
"/home/tester/.config/Code/User"},
.path_count = 4U,
.commands = {"glab", "acli", "code", "cody", "cn", "roo", "trae"},
.command_count = 7U,
};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_GITLAB_DUO, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_ROVO_DEV, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_AMP, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_CONTINUE, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_ROO_CODE, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_TRAE, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, &options));
options.continue_config_path = "/active/continue.yaml";
options.roo_config_path = "/active/roo.json";
options.trae_config_path = "/active/trae.json";
options.cody_config_path = "/active/cody-settings.json";
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_CONTINUE, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_ROO_CODE, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_TRAE, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, &options));
probe.paths[probe.path_count++] = options.continue_config_path;
probe.paths[probe.path_count++] = options.roo_config_path;
probe.paths[probe.path_count++] = options.trae_config_path;
probe.paths[probe.path_count++] = options.cody_config_path;
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_CONTINUE, &options));
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_ROO_CODE, &options));
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_TRAE, &options));
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, &options));
options.is_windows = true;
probe.paths[probe.path_count++] = "/home/tester/.mcp.json";
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_VISUAL_STUDIO, &options));
probe.commands[probe.command_count++] = "devenv";
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_VISUAL_STUDIO, &options));
PASS();
}
TEST(agent_clients_roo_code_requires_an_explicit_existing_config) {
agent_probe_t probe = {0};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
char resolved[512];
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_ROO_CODE, &options, resolved,
sizeof(resolved)),
1);
options.roo_config_path = "/work/project/.roo/mcp.json";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_ROO_CODE, &options, resolved,
sizeof(resolved)),
1);
probe.paths[probe.path_count++] = options.roo_config_path;
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_ROO_CODE, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, options.roo_config_path);
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_ROO_CODE, &options));
char *dir = NULL;
char *path = agent_fixture("{}\n", &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_ROO_CODE, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_OK);
char *installed = agent_read(path);
ASSERT_NOT_NULL(installed);
ASSERT_NOT_NULL(strstr(installed, "\"mcpServers\""));
ASSERT_NULL(strstr(installed, "alwaysAllow"));
free(installed);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_amazon_q_prefers_current_then_existing_legacy_config) {
agent_probe_t probe = {0};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
char resolved[512];
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_AMAZON_Q, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.aws/amazonq/default.json");
probe.paths[probe.path_count++] = "/home/tester/.aws/amazonq/mcp.json";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_AMAZON_Q, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.aws/amazonq/mcp.json");
probe.paths[probe.path_count++] = "/home/tester/.aws/amazonq/agents/default.json";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_AMAZON_Q, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.aws/amazonq/agents/default.json");
probe.paths[probe.path_count++] = "/home/tester/.aws/amazonq/default.json";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_AMAZON_Q, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.aws/amazonq/default.json");
char *dir = NULL;
char *path = agent_fixture("{\"permissions\":{\"keep\":true}}\n", &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_AMAZON_Q, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_OK);
char *installed = agent_read(path);
ASSERT_NOT_NULL(installed);
ASSERT_NOT_NULL(strstr(installed, "\"mcpServers\""));
ASSERT_NOT_NULL(strstr(installed, "\"permissions\":{\"keep\":true}"));
ASSERT_EQ(agent_occurrences(installed, "\"permissions\""), 1U);
ASSERT_NULL(strstr(installed, "alwaysAllow"));
ASSERT_NULL(strstr(installed, "autoApprove"));
free(installed);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_codebuddy_bob_and_pochi_use_documented_global_paths) {
agent_probe_t probe = {0};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
char resolved[512];
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_CODEBUDDY, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.codebuddy/.mcp.json");
probe.paths[probe.path_count++] = "/home/tester/.codebuddy.json";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_CODEBUDDY, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.codebuddy.json");
probe.paths[probe.path_count++] = "/home/tester/.codebuddy/mcp.json";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_CODEBUDDY, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.codebuddy/mcp.json");
probe.paths[probe.path_count++] = "/home/tester/.codebuddy/.mcp.json";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_CODEBUDDY, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.codebuddy/.mcp.json");
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_IBM_BOB_IDE, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.bob/mcp.json");
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_IBM_BOB_SHELL, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.bob/mcp_settings.json");
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_POCHI, &options, resolved, sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.pochi/config.jsonc");
probe.path_count = 0U;
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_CODEBUDDY, &options));
probe.commands[probe.command_count++] = "codebuddy";
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_CODEBUDDY, &options));
probe.command_count = 0U;
probe.paths[probe.path_count++] = "/home/tester/.codebuddy";
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_CODEBUDDY, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_IBM_BOB_IDE, &options));
probe.paths[probe.path_count++] = "/home/tester/.bob";
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_IBM_BOB_IDE, &options));
probe.paths[probe.path_count++] = "/home/tester/.bob/mcp.json";
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_IBM_BOB_IDE, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_IBM_BOB_SHELL, &options));
probe.commands[probe.command_count++] = "bob";
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_IBM_BOB_SHELL, &options));
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_POCHI, &options));
probe.commands[probe.command_count++] = "pochi";
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_POCHI, &options));
PASS();
}
TEST(agent_clients_pi_has_no_mcp_path_or_mutation) {
agent_probe_t probe = {.commands = {"pi"}, .command_count = 1U};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
char resolved[512];
ASSERT_EQ(
cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_PI, &options, resolved, sizeof(resolved)),
1);
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_PI, &options));
const char *original = "{\"keep\":true}\n";
char *dir = NULL;
char *path = agent_fixture(original, &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_PI, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_NOT_APPLICABLE);
char *after = agent_read(path);
ASSERT_STR_EQ(after, original);
free(after);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_cody_is_opt_in_and_requires_explicit_existing_settings) {
agent_probe_t probe = {.commands = {"code", "cody"}, .command_count = 2U};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
char resolved[512];
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, &options, resolved,
sizeof(resolved)),
1);
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, &options));
options.cody_config_path = "/home/tester/.config/Code/User/settings.json";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, &options, resolved,
sizeof(resolved)),
1);
ASSERT(!cbm_agent_client_detect(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, &options));
probe.paths[probe.path_count++] = options.cody_config_path;
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, options.cody_config_path);
ASSERT(cbm_agent_client_detect(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, &options));
PASS();
}
TEST(agent_clients_cody_uses_literal_dotted_key_without_feature_or_permission_edits) {
const char *initial = "{\n // user policy\n \"cody.enabled\": false,\n"
" \"cody.permissions\": {\"trusted\": false},\n"
" \"cody.experimental.agent\": false\n}\n";
char *dir = NULL;
char *path = agent_fixture(initial, &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_OK);
char *installed = agent_read(path);
ASSERT_NOT_NULL(installed);
ASSERT_NOT_NULL(strstr(installed, "\"cody.mcpServers\""));
ASSERT_EQ(agent_occurrences(installed, "\"mcpServers\""), 0U);
ASSERT_NOT_NULL(strstr(installed, "\"cody.enabled\": false"));
ASSERT_NOT_NULL(strstr(installed, "\"cody.permissions\": {\"trusted\": false}"));
ASSERT_NOT_NULL(strstr(installed, "\"cody.experimental.agent\": false"));
ASSERT_EQ(agent_occurrences(installed, "\"cody.enabled\""), 1U);
ASSERT_EQ(agent_occurrences(installed, "\"cody.permissions\""), 1U);
ASSERT_EQ(agent_occurrences(installed, "\"cody.experimental.agent\""), 1U);
ASSERT_NULL(strstr(installed, "alwaysAllow"));
ASSERT_NULL(strstr(installed, "autoApprove"));
free(installed);
const char *foreign = "{\"cody.mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"foreign\","
"\"args\":[]}},\"cody.enabled\":false}\n";
ASSERT_EQ(th_write_file(path, foreign), 0);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_SOURCEGRAPH_CODY, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_FOREIGN);
char *after = agent_read(path);
ASSERT_STR_EQ(after, foreign);
free(after);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_json_schemas_are_exact_and_policy_neutral) {
static const struct {
cbm_agent_client_id_t id;
const char *required;
} cases[] = {
{CBM_AGENT_CLIENT_QODER, "\"mcpServers\""},
{CBM_AGENT_CLIENT_KIMI, "\"mcpServers\""},
{CBM_AGENT_CLIENT_GITLAB_DUO, "\"type\": \"stdio\""},
{CBM_AGENT_CLIENT_ROVO_DEV, "\"transport\": \"stdio\""},
{CBM_AGENT_CLIENT_AMP, "\"codebase-memory-mcp\""},
{CBM_AGENT_CLIENT_DEVIN, "\"mcpServers\""},
{CBM_AGENT_CLIENT_TABNINE, "\"mcpServers\""},
{CBM_AGENT_CLIENT_VISUAL_STUDIO, "\"servers\""},
{CBM_AGENT_CLIENT_TRAE, "\"mcpServers\""},
{CBM_AGENT_CLIENT_CODEBUDDY, "\"mcpServers\""},
{CBM_AGENT_CLIENT_IBM_BOB_IDE, "\"mcpServers\""},
{CBM_AGENT_CLIENT_IBM_BOB_SHELL, "\"mcpServers\""},
{CBM_AGENT_CLIENT_POCHI, "\"mcp\""},
{CBM_AGENT_CLIENT_OMP, "\"type\": \"stdio\""},
};
const char *binary = "/opt/Codebase Memory/bin/cbm\\\"special";
for (size_t i = 0U; i < sizeof(cases) / sizeof(cases[0]); i++) {
char *dir = NULL;
char *path = agent_fixture("{\n // user-owned\n \"keep\": true,\n}\n", &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(cases[i].id, path, binary), CBM_AGENT_EDIT_OK);
char *first = agent_read(path);
ASSERT_NOT_NULL(first);
ASSERT_NOT_NULL(strstr(first, cases[i].required));
ASSERT_NOT_NULL(strstr(first, "\"command\""));
ASSERT_NOT_NULL(strstr(first, "\"args\": []"));
ASSERT_NULL(strstr(first, "approvedTools"));
ASSERT_NULL(strstr(first, "allowedTools"));
ASSERT_NULL(strstr(first, "enable_instructions"));
ASSERT_NULL(strstr(first, "autoApprove"));
ASSERT_NULL(strstr(first, "alwaysAllow"));
ASSERT_NULL(strstr(first, "allowlist"));
ASSERT_NULL(strstr(first, "permissions"));
ASSERT_EQ(cbm_agent_client_install_mcp(cases[i].id, path, binary), CBM_AGENT_EDIT_OK);
char *second = agent_read(path);
ASSERT_NOT_NULL(second);
ASSERT_STR_EQ(first, second);
free(first);
free(second);
free(path);
th_cleanup(dir);
}
PASS();
}
TEST(agent_clients_new_standard_json_profiles_preserve_foreign_entries) {
static const cbm_agent_client_id_t clients[] = {
CBM_AGENT_CLIENT_CODEBUDDY,
CBM_AGENT_CLIENT_IBM_BOB_IDE,
CBM_AGENT_CLIENT_IBM_BOB_SHELL,
CBM_AGENT_CLIENT_POCHI,
CBM_AGENT_CLIENT_OMP,
};
for (size_t i = 0U; i < sizeof(clients) / sizeof(clients[0]); i++) {
const char *foreign =
clients[i] == CBM_AGENT_CLIENT_POCHI
? "{\"mcp\":{\"codebase-memory-mcp\":{\"command\":\"foreign\","
"\"args\":[]}}}\n"
: "{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"foreign\","
"\"args\":[]}}}\n";
char *dir = NULL;
char *path = agent_fixture(foreign, &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(clients[i], path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_FOREIGN);
char *after = agent_read(path);
ASSERT_NOT_NULL(after);
ASSERT_STR_EQ(after, foreign);
free(after);
free(path);
th_cleanup(dir);
}
PASS();
}
TEST(agent_clients_refuse_foreign_and_preserve_modified_entries) {
const char *foreign =
"{\"mcpServers\":{\"codebase-memory-mcp\":{\"command\":\"foreign\",\"args\":[]}}}\n";
char *dir = NULL;
char *path = agent_fixture(foreign, &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_QODER, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_FOREIGN);
char *after = agent_read(path);
ASSERT_STR_EQ(after, foreign);
free(after);
const char *escaped_foreign =
"{mcpServers:{\"codebase\\u002dmemory-mcp\":{command:'foreign',args:[]}}}\n";
ASSERT_EQ(th_write_file(path, escaped_foreign), 0);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_QODER, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_FOREIGN);
after = agent_read(path);
ASSERT_STR_EQ(after, escaped_foreign);
free(after);
ASSERT_EQ(th_write_file(path, "{}\n"), 0);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_QODER, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_OK);
char *canonical = agent_read(path);
ASSERT_NOT_NULL(canonical);
char *command = strstr(canonical, "/usr/bin/cbm");
ASSERT_NOT_NULL(command);
command[0] = 'X';
ASSERT_EQ(th_write_file(path, canonical), 0);
ASSERT_EQ(cbm_agent_client_remove_mcp(CBM_AGENT_CLIENT_QODER, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_FOREIGN);
after = agent_read(path);
ASSERT_STR_EQ(after, canonical);
free(after);
free(canonical);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_omp_resolves_to_injected_agent_dir_when_provided) {
agent_probe_t probe = {0};
cbm_agent_client_resolve_options_t options = agent_options(&probe);
char resolved[512];
/* Default (no env) keeps the documented ~/.omp/agent path. */
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_OMP, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.omp/agent/mcp.json");
/* Named profile directory takes precedence over the home-relative default. */
options.omp_agent_dir = "/home/tester/.omp/profiles/work/agent";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_OMP, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/home/tester/.omp/profiles/work/agent/mcp.json");
/* PI_CODING_AGENT_DIR relocations also flow through the resolved option. */
options.omp_agent_dir = "/srv/omp-shared/agent";
ASSERT_EQ(cbm_agent_client_resolve_path(CBM_AGENT_CLIENT_OMP, &options, resolved,
sizeof(resolved)),
0);
ASSERT_STR_EQ(resolved, "/srv/omp-shared/agent/mcp.json");
PASS();
}
TEST(agent_clients_omp_profile_does_not_register_global_instructions_capability) {
const cbm_agent_client_profile_t *profile =
cbm_agent_client_by_id(CBM_AGENT_CLIENT_OMP);
ASSERT_NOT_NULL(profile);
ASSERT_EQ(profile->capabilities & CBM_AGENT_CAP_INSTRUCTIONS, 0U);
ASSERT_NEQ(profile->capabilities & CBM_AGENT_CAP_MCP, 0U);
ASSERT_NEQ(profile->capabilities & CBM_AGENT_CAP_SKILL, 0U);
ASSERT_NEQ(profile->capabilities & CBM_AGENT_CAP_AGENT, 0U);
PASS();
}
TEST(agent_clients_remove_only_canonical_and_missing_is_noop) {
char *dir = NULL;
char *path = agent_fixture("{\"keep\":true}\n", &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_remove_mcp(CBM_AGENT_CLIENT_GITLAB_DUO, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_OK);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_GITLAB_DUO, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_OK);
ASSERT_EQ(cbm_agent_client_remove_mcp(CBM_AGENT_CLIENT_GITLAB_DUO, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_OK);
char *after = agent_read(path);
ASSERT_NOT_NULL(after);
ASSERT_NULL(strstr(after, "codebase-memory-mcp"));
ASSERT_NOT_NULL(strstr(after, "\"keep\":true"));
free(after);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_registry_callbacks_apply_the_selected_schema) {
char *dir = NULL;
char *path = agent_fixture("{}\n", &dir);
ASSERT_NOT_NULL(path);
const cbm_agent_client_profile_t *profile =
cbm_agent_client_by_id(CBM_AGENT_CLIENT_VISUAL_STUDIO);
ASSERT_NOT_NULL(profile);
ASSERT_EQ(profile->install_mcp(profile->id, path, "C:/Tools/cbm.exe"), CBM_AGENT_EDIT_OK);
char *installed = agent_read(path);
ASSERT_NOT_NULL(installed);
ASSERT_NOT_NULL(strstr(installed, "\"servers\""));
ASSERT_NOT_NULL(strstr(installed, "\"type\": \"stdio\""));
free(installed);
ASSERT_EQ(profile->remove_mcp(profile->id, path, "C:/Tools/cbm.exe"), CBM_AGENT_EDIT_OK);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_malformed_json_fails_byte_identically) {
const char *malformed = "{\"mcpServers\": [}\n";
char *dir = NULL;
char *path = agent_fixture(malformed, &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_TABNINE, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_ERROR);
char *after = agent_read(path);
ASSERT_STR_EQ(after, malformed);
free(after);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_deep_same_name_json_fails_closed_byte_identically) {
char *deep = agent_deep_same_name_json(256U);
ASSERT_NOT_NULL(deep);
char *dir = NULL;
char *path = agent_fixture(deep, &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_QODER, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_ERROR);
char *after_install = agent_read(path);
ASSERT_NOT_NULL(after_install);
ASSERT_STR_EQ(after_install, deep);
free(after_install);
ASSERT_EQ(cbm_agent_client_remove_mcp(CBM_AGENT_CLIENT_QODER, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_ERROR);
char *after_remove = agent_read(path);
ASSERT_NOT_NULL(after_remove);
ASSERT_STR_EQ(after_remove, deep);
free(after_remove);
free(path);
th_cleanup(dir);
free(deep);
PASS();
}
TEST(agent_clients_continue_uses_owned_yaml_sequence_item) {
const char *initial = "name: Local\nmcpServers:\n - name: other\n command: other\n";
char *dir = NULL;
char *path = agent_fixture(initial, &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(
cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_CONTINUE, path, "/opt/cbm path/#quoted"),
CBM_AGENT_EDIT_OK);
char *first = agent_read(path);
ASSERT_NOT_NULL(first);
ASSERT_NOT_NULL(strstr(first, " - name: codebase-memory-mcp\n"));
ASSERT_NOT_NULL(strstr(first, " command: \"/opt/cbm path/#quoted\"\n"));
ASSERT_NOT_NULL(strstr(first, " - name: other\n"));
ASSERT_EQ(agent_occurrences(first, "name: codebase-memory-mcp"), 1U);
ASSERT_EQ(
cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_CONTINUE, path, "/opt/cbm path/#quoted"),
CBM_AGENT_EDIT_OK);
char *second = agent_read(path);
ASSERT_STR_EQ(first, second);
ASSERT_EQ(cbm_agent_client_remove_mcp(CBM_AGENT_CLIENT_CONTINUE, path, "/opt/cbm path/#quoted"),
CBM_AGENT_EDIT_OK);
char *removed = agent_read(path);
ASSERT_NULL(strstr(removed, "name: codebase-memory-mcp"));
ASSERT_NOT_NULL(strstr(removed, "name: other"));
free(first);
free(second);
free(removed);
free(path);
th_cleanup(dir);
PASS();
}
TEST(agent_clients_continue_refuses_foreign_same_name_and_nonsequence_section) {
const char *foreign = "mcpServers:\n - name: \"codebase-memory-mcp\"\n"
" command: foreign\n args: []\n";
char *dir = NULL;
char *path = agent_fixture(foreign, &dir);
ASSERT_NOT_NULL(path);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_CONTINUE, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_FOREIGN);
char *after = agent_read(path);
ASSERT_STR_EQ(after, foreign);
free(after);
const char *wrong_shape = "mcpServers: []\nkeep: true\n";
ASSERT_EQ(th_write_file(path, wrong_shape), 0);
ASSERT_EQ(cbm_agent_client_install_mcp(CBM_AGENT_CLIENT_CONTINUE, path, "/usr/bin/cbm"),
CBM_AGENT_EDIT_ERROR);
after = agent_read(path);
ASSERT_STR_EQ(after, wrong_shape);
free(after);
free(path);
th_cleanup(dir);
PASS();
}
/* ── Generated client adapters ──────────────────────────────────── */
/* The generator exists so a client extension cannot carry a hand-picked subset
* of the tool surface. #534 shipped one mirroring 7 of 15 tools; every tool
* added afterwards would have been silently missing for that client. This pins
* the property that makes generation worth doing: EVERY registry tool appears. */
TEST(client_adapter_pi_registers_every_registry_tool) {
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
int count = cbm_mcp_tool_count();
ASSERT_GT(count, 0);
for (int i = 0; i < count; i++) {
const char *name = cbm_mcp_tool_name(i);
ASSERT_NOT_NULL(name);
char needle[128];
snprintf(needle, sizeof(needle), "name: '%s'", name);
/* A missing tool here means the adapter drifted from the registry —
* exactly the failure this design removes. */
ASSERT_NOT_NULL(strstr(js, needle));
}
/* The body must NOT carry the ownership markers: cbm_text_upsert_managed_block
* adds them and rejects content that already has them, so emitting them here
* would make every install fail. */
ASSERT_NULL(strstr(js, CBM_ADAPTER_MARKER_START));
ASSERT_NULL(strstr(js, CBM_ADAPTER_MARKER_END));
ASSERT_NOT_NULL(strstr(js, "Generated by codebase-memory-mcp"));
free(js);
PASS();
}
/* #1550: Pi loads an extension by calling its DEFAULT export as a factory. The
* generator emitted a named `register` export instead, so the file failed to
* load — and a Pi extension that fails to load takes every pi command with it
* (`pi doctor` included), not just cbm's tools. The blast radius is why this is
* pinned rather than left to the registry-drift test above, which passed the
* whole time this was broken: it checked WHICH tools were listed, never whether
* the file Pi loads is loadable. */
TEST(client_adapter_pi_default_exports_its_factory_issue1550) {
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "export default function (pi)"));
/* The old shape must be gone: a bare `export function register(pi)` is the
* exact form Pi rejects. */
ASSERT_NULL(strstr(js, "export function register(pi)"));
free(js);
PASS();
}
/* Pi's ToolDefinition requires `parameters` and `execute`. The adapter used to
* emit `{ name, run }`, which Pi accepted but treated as a tool with no
* parameter schema — strict providers (xAI/Grok) then reject the request with
* a 422 "missing field parameters", and the tool is uncallable because Pi
* invokes `execute`, never `run`. Pin the corrected shape. */
TEST(client_adapter_pi_emits_parameters_and_execute) {
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "execute: async (toolCallId, params, signal, _onUpdate, ctx) => {"));
ASSERT_NOT_NULL(strstr(js, ", params, signal ?? ctx?.signal)"));
ASSERT_NULL(strstr(js, "execute: async (args, ctx) => {"));
ASSERT_NULL(strstr(js, ", args, ctx?.signal)"));
ASSERT_NOT_NULL(strstr(js, "@earendil-works/pi-coding-agent"));
ASSERT_NOT_NULL(strstr(js, "result.content"));
ASSERT_NULL(strstr(js, "run: (args, ctx)"));
ASSERT_NOT_NULL(strstr(js, "parameters:"));
/* TypeBox parameters schema is embedded with Type.Object syntax. */
ASSERT_NOT_NULL(strstr(js, "Type.Object("));
ASSERT_NOT_NULL(strstr(js, "Type.String("));
/* #2787: String enums must use Type.String({ enum: [...] }) without anyOf/const */
ASSERT_NOT_NULL(strstr(js, "Type.String({ enum: ["));
ASSERT_NULL(strstr(js, "Type.Union([Type.Literal"));
ASSERT_NULL(strstr(js, "Type.Literal("));
/* A numeric registry property must remain numeric in the generated schema. */
ASSERT_NOT_NULL(strstr(js, "depth: Type.Optional(Type.Integer("));
/* TypeBox import should be present. */
ASSERT_NOT_NULL(strstr(js, "import { Type } from 'typebox';"));
/* Raw JSON output is required so the bridge can parse the MCP result; the
* human-readable path would leave `call` with nothing to JSON.parse. */
ASSERT_NOT_NULL(strstr(js, "'cli', '--json'"));
free(js);
PASS();
}
/* #1834: raw JSON in argv selects the deprecated compatibility path. The Pi
* bridge must keep arguments out of process listings and feed the existing
* stdin transport only after all child handlers are attached. */
TEST(client_adapter_pi_sends_arguments_over_stdin_issue1834) {
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
const char *spawn = strstr(js, "spawn(BIN, ['cli', '--json', tool], {");
const char *stdio = strstr(js, "stdio: ['pipe', 'pipe', 'pipe']");
const char *stdout_handler = strstr(js, "child.stdout.on('data'");
const char *stdin_error_handler = strstr(js, "child.stdin.on('error'");
const char *error_handler = strstr(js, "child.on('error'");
const char *close_handler = strstr(js, "child.on('close'");
const char *stdin_end = strstr(js, "child.stdin.end(JSON.stringify(args ?? {}));");
ASSERT_NOT_NULL(spawn);
ASSERT_NOT_NULL(stdio);
ASSERT_NOT_NULL(stdout_handler);
ASSERT_NOT_NULL(stdin_error_handler);
ASSERT_NOT_NULL(error_handler);
ASSERT_NOT_NULL(close_handler);
ASSERT_NOT_NULL(stdin_end);
ASSERT(stdout_handler < stdin_end);
ASSERT(stdin_error_handler < stdin_end);
ASSERT(error_handler < stdin_end);
ASSERT(close_handler < stdin_end);
ASSERT_NOT_NULL(strstr(js, "let stdinError;"));
ASSERT_NOT_NULL(strstr(js, "stdinError = e;"));
ASSERT_NOT_NULL(strstr(js, "if (stdinError)"));
/* These are the exact deprecated transport shapes being retired. */
ASSERT_NULL(strstr(js, "tool, JSON.stringify(args ?? {})]"));
ASSERT_NULL(strstr(js, "stdio: ['ignore', 'pipe', 'pipe']"));
free(js);
PASS();
}
/* Result wrap + abort: Pi's TUI reads result.content; a spawn/parse failure
* must throw rather than return a result without content; AbortSignal must
* kill the cli child. String-shape only; a live Pi process is not gated. */
TEST(client_adapter_pi_wraps_result_and_honors_abort) {
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "return { content, details: result ?? {} };"));
ASSERT_NOT_NULL(strstr(js, "if (result && typeof result === 'object' && result.error)"));
ASSERT_NOT_NULL(strstr(js, "throw new Error(String(result.error));"));
ASSERT_NOT_NULL(strstr(js, "Array.isArray(result.content)"));
ASSERT_NOT_NULL(
strstr(js, "[{ type: 'text', text: JSON.stringify(result ?? null, null, 2) }]"));
ASSERT_NOT_NULL(strstr(js, "signal?.addEventListener('abort', onAbort, { once: true })"));
ASSERT_NOT_NULL(strstr(js, "if (!child.killed) child.kill();"));
free(js);
PASS();
}
/* #2787: Pi extension must emit string enums as Type.String({ enum: [...] })
* rather than Type.Union([Type.Literal(...)]) which TypeBox serializes as
* anyOf/const, breaking Gemini / Antigravity function declaration validation. */
TEST(client_adapter_pi_emits_string_enum_issue2787) {
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NULL(strstr(js, "Type.Union([Type.Literal"));
ASSERT_NULL(strstr(js, "Type.Literal("));
ASSERT_NOT_NULL(strstr(js, "Type.String({ enum: ["));
ASSERT_NOT_NULL(strstr(js, "'full'"));
ASSERT_NOT_NULL(strstr(js, "'moderate'"));
ASSERT_NOT_NULL(strstr(js, "'fast'"));
ASSERT_NOT_NULL(strstr(js, "'cross-repo-intelligence'"));
free(js);
PASS();
}
/* #616 rejected `"` in its path template but not `\`, so a Windows home like
* C:\Users\urs\bin produced `\u` — an invalid unicode escape — and the whole
* auto-loaded plugin failed to parse. A broken plugin in an auto-load directory
* is worse than an absent one, so the escaping gets direct coverage. */
TEST(client_adapter_escapes_windows_paths_and_quotes) {
char out[256];
ASSERT_TRUE(cbm_client_adapter_escape_js("C:\\Users\\urs\\bin\\cbm.exe", out, sizeof(out)));
ASSERT_STR_EQ(out, "C:\\\\Users\\\\urs\\\\bin\\\\cbm.exe");
/* The backslash before "urs" is DOUBLED, which is what stops JS reading
* `\u` as a unicode escape — the exact break in #616. Asserting the
* doubled form is the property; a bare `\u` substring still appears (as
* escaped-backslash + 'u') and would be a misleading thing to test. */
ASSERT_NOT_NULL(strstr(out, "\\\\urs"));
ASSERT_TRUE(cbm_client_adapter_escape_js("/home/o'brien/bin/cbm", out, sizeof(out)));
ASSERT_STR_EQ(out, "/home/o\\'brien/bin/cbm");
/* A newline would terminate the literal outright. */
ASSERT_TRUE(cbm_client_adapter_escape_js("/tmp/a\nb", out, sizeof(out)));
ASSERT_STR_EQ(out, "/tmp/a\\nb");
/* Truncation must fail closed rather than emit half a literal. */
char tiny[4];
ASSERT_FALSE(cbm_client_adapter_escape_js("C:\\Users", tiny, sizeof(tiny)));
ASSERT_STR_EQ(tiny, "");
/* And a path that cannot be escaped must abort generation, not produce a
* module with a corrupt BIN. */
char huge[CBM_SZ_2K];
memset(huge, 'x', sizeof(huge) - 1);
huge[sizeof(huge) - 1] = '\0';
ASSERT_NULL(cbm_client_adapter_pi(huge));
PASS();
}
/* hook-augment requires hook_event_name and accepts Grep/Glob only under
* PreToolUse. #616's payload omitted it, so the plugin produced zero bytes and
* was a silent no-op for six weeks. Pin the field into the generated payload. */
TEST(client_adapter_opencode_sends_the_required_hook_event) {
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'PreToolUse'"));
ASSERT_NOT_NULL(strstr(js, "tool.execute.after"));
ASSERT_NOT_NULL(strstr(js, "const args = input?.args ?? {};"));
ASSERT_NOT_NULL(strstr(js, "tool_input: args"));
ASSERT_NULL(strstr(js, "output?.args"));
/* OpenCode reaches the tools over MCP already; this adapter must not
* register any, or we reintroduce the second tool surface. */
ASSERT_NULL(strstr(js, "registerTool"));
ASSERT_NULL(strstr(js, CBM_ADAPTER_MARKER_START));
free(js);
PASS();
}
/* The richer OpenCode adapter carries every context surface the plugin API
* documents: session-start tier routing on the first tool result of each
* session, post-read coverage notes, and post-compaction reinjection through
* the documented experimental surface. It must unwrap hook-augment's Claude
* JSON envelope so plain text — not raw JSON — reaches the model. */
TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) {
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'SessionStart'"));
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'PostToolUse'"));
ASSERT_NOT_NULL(strstr(js, "tool_name: 'Read'"));
ASSERT_NOT_NULL(strstr(js, "'experimental.session.compacting'"));
ASSERT_NOT_NULL(strstr(js, "additionalContext"));
/* file_path is the key hook-augment's default dialect reads; OpenCode's
* read tool argues filePath, so the adapter must map it. */
ASSERT_NOT_NULL(strstr(js, "file_path: filePath"));
/* Session context may only be injected once per session id. */
ASSERT_NOT_NULL(strstr(js, "seen.has(sid)"));
free(js);
PASS();
}
/* #2077/#2089/#2204: the two OpenCode runtimes read different entries of the
* default export. 1.18.x dispatches the hooks returned by `server(ctx)`; 2.x
* ignores `server` and calls `setup(ctx)`, whose context provides the tool hook
* domain. The registration fails open, and the server() tool hook yields once
* it is registered. OpenCode 2 adds no note to a compaction (pinned by the
* compaction test below). */
TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) {
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "export default {"));
ASSERT_NOT_NULL(strstr(js, "id: 'codebase-memory-augment'"));
/* OpenCode 1.18.x server runtime. */
ASSERT_NOT_NULL(strstr(js, "server: async (ctx) => {"));
/* OpenCode 2 registers through the setup context; the domains exist on
* 2.x, so setup is no longer the empty placeholder #2089 pinned. */
ASSERT_NOT_NULL(strstr(js, "async setup(ctx) {"));
ASSERT_NOT_NULL(strstr(js, "ctx.tool.hook('execute.after'"));
/* content is the surface the model reads; prefer it over output, and never
* synthesize content that would hide the tool's own result. */
const char *content_first = strstr(js, "if (typeof result.content === 'string') {");
const char *output_fallback = strstr(js, "if (typeof result.output === 'string') {");
ASSERT_NOT_NULL(content_first);
ASSERT_NOT_NULL(output_fallback);
ASSERT_TRUE(content_first < output_fallback);
ASSERT_NULL(strstr(js, "if (result.content === undefined)"));
/* The V2 hooks read the session id with optional chaining (fail open). */
ASSERT_NOT_NULL(strstr(js, "event?.sessionID"));
/* The plugin instance's location is not the session's location in a
* multi-project server: resolve each session's directory. */
ASSERT_NOT_NULL(strstr(js, "const dir = await dirFor("));
ASSERT_NOT_NULL(strstr(js, "ctx.session.get({ sessionID })"));
ASSERT_NOT_NULL(strstr(js, "dirs.has(sessionID)"));
ASSERT_NOT_NULL(strstr(js, "if (!dir) return;"));
/* No fallback to the plugin instance's location: that is not the session's
* location in a multi-project server. */
ASSERT_NULL(strstr(js, "ctx?.location?.directory"));
ASSERT_NULL(strstr(js, "pendingReinject"));
/* The registration fails open; the server() tool hook yields once it is in. */
ASSERT_NOT_NULL(strstr(js, "toolHooksRegistered = true;"));
ASSERT_NOT_NULL(strstr(js, "if (toolHooksRegistered) return;"));
ASSERT_NOT_NULL(strstr(js, "catch {"));
ASSERT_NOT_NULL(strstr(js, "event.result = appendResult(event.result, extra);"));
/* The empty setup placeholder and the old named export must both be gone. */
ASSERT_NULL(strstr(js, "setup() {}"));
ASSERT_NULL(strstr(js, "export const CodebaseMemory"));
free(js);
PASS();
}
/* #2204: OpenCode 2 gets no note after compaction until a compaction channel has
* run on a real 2.x host. A plain object pushed into `event.messages` would be
* rejected, if at all, inside OpenCode's own compaction, where the plugin's
* try/catch cannot reach it, so an untested channel could break compaction for
* every OpenCode 2 user. The note must not reach the system prompt either: not
* through `event.system`, and not through a session instruction entry. The
* 1.18.x server() path keeps its documented compaction surface. */
TEST(client_adapter_opencode_v2_adds_no_note_to_the_compaction) {
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NULL(strstr(js, "ctx.session.hook("));
ASSERT_NULL(strstr(js, "event.messages"));
ASSERT_NULL(strstr(js, "sessionHooksRegistered"));
ASSERT_NULL(strstr(js, ".system"));
ASSERT_NULL(strstr(js, "instructions"));
ASSERT_NULL(strstr(js, "entry.put"));
ASSERT_NOT_NULL(strstr(js, "'experimental.session.compacting'"));
free(js);
PASS();
}
/* #2204: Tool.Result allows `content: []`. appendResult adds a text part to a
* parts array only when the array already holds a part; an empty array falls
* through to the string `output` (or the unchanged result), so the note never
* becomes the whole content of a tool result. */
TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content) {
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
const char *parts =
strstr(js, "if (Array.isArray(result.content) && result.content.length > 0) {");
const char *add_part = strstr(js, "content.push({ type: 'text', text: extra });");
const char *output_fallback = strstr(js, "if (typeof result.output === 'string') {");
ASSERT_NOT_NULL(parts);
ASSERT_NOT_NULL(add_part);
ASSERT_NOT_NULL(output_fallback);
/* The only part-adding push sits inside the non-empty branch; the string
* output fallback, where `content: []` lands, comes after it. */
ASSERT_TRUE(parts < add_part);
ASSERT_TRUE(add_part < output_fallback);
ASSERT_NULL(strstr(add_part + 1, "content.push("));
/* No unguarded array branch is left. */
ASSERT_NULL(strstr(js, "if (Array.isArray(result.content)) {"));
free(js);
PASS();
}
/* augment() is awaited inline inside 'tool.execute.after', so a hook-augment
* that never exits would stall every grep/glob/read completion in the host
* session until the agent is restarted. The generated plugin must bound the
* wait: SIGKILL the child after AUGMENT_TIMEOUT_MS and resolve with '' (fail
* open). The promise keeps the first resolution, so the kill-triggered
* 'close' event arriving late cannot smuggle in partial output. Stream
* errors fire on child.stdin, not the child — the emitted listener and the
* guarded end() keep a failed write (EPIPE, ENOENT) from crashing the host
* as an unhandled 'error' event (reproduced under Bun 1.4 with a >64KB
* payload and a non-reading child). */
TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child) {
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NOT_NULL(strstr(js, "const AUGMENT_TIMEOUT_MS = 10000;"));
ASSERT_NOT_NULL(strstr(js, "const timer = setTimeout("));
ASSERT_NOT_NULL(strstr(js, "child.kill('SIGKILL');"));
/* error and close must both stop the timer once they own the outcome. */
ASSERT_NOT_NULL(strstr(js, "clearTimeout(timer);"));
/* stream-level failures must not escape as unhandled 'error' events. */
ASSERT_NOT_NULL(strstr(js, "child.stdin.on('error', () => {});"));
free(js);
PASS();
}
/* Empty/NULL inputs must not produce a module at all. */
TEST(client_adapter_rejects_missing_binary_path) {
ASSERT_NULL(cbm_client_adapter_pi(NULL));
ASSERT_NULL(cbm_client_adapter_pi(""));
ASSERT_NULL(cbm_client_adapter_opencode(NULL));
ASSERT_NULL(cbm_client_adapter_opencode(""));
PASS();
}
SUITE(agent_clients) {
RUN_TEST(agent_clients_registry_is_stable_and_callback_driven);
RUN_TEST(agent_clients_next_wave_metadata_matches_supported_surfaces);
RUN_TEST(agent_clients_resolve_documented_paths_and_precedence);
RUN_TEST(agent_clients_resolve_rovo_override_and_conditional_paths);
RUN_TEST(agent_clients_rovo_override_rejects_relative_and_traversal_paths);
RUN_TEST(agent_clients_rovo_override_rejects_absolute_paths_outside_user_root);
RUN_TEST(agent_clients_rovo_compatibility_prefers_existing_documented_filename);
RUN_TEST(agent_clients_detection_avoids_generic_binary_false_positives);
RUN_TEST(agent_clients_visual_studio_cleanup_survives_missing_command);
RUN_TEST(agent_clients_detect_installed_client_directories_before_mcp_exists);
RUN_TEST(agent_clients_marker_detection_remains_fail_closed_for_conditional_clients);
RUN_TEST(agent_clients_roo_code_requires_an_explicit_existing_config);
RUN_TEST(agent_clients_amazon_q_prefers_current_then_existing_legacy_config);
RUN_TEST(agent_clients_codebuddy_bob_and_pochi_use_documented_global_paths);
RUN_TEST(agent_clients_pi_has_no_mcp_path_or_mutation);
RUN_TEST(agent_clients_cody_is_opt_in_and_requires_explicit_existing_settings);
RUN_TEST(agent_clients_cody_uses_literal_dotted_key_without_feature_or_permission_edits);
RUN_TEST(agent_clients_json_schemas_are_exact_and_policy_neutral);
RUN_TEST(agent_clients_new_standard_json_profiles_preserve_foreign_entries);
RUN_TEST(agent_clients_refuse_foreign_and_preserve_modified_entries);
RUN_TEST(agent_clients_omp_resolves_to_injected_agent_dir_when_provided);
RUN_TEST(agent_clients_omp_profile_does_not_register_global_instructions_capability);
RUN_TEST(agent_clients_remove_only_canonical_and_missing_is_noop);
RUN_TEST(agent_clients_registry_callbacks_apply_the_selected_schema);
RUN_TEST(agent_clients_malformed_json_fails_byte_identically);
RUN_TEST(agent_clients_deep_same_name_json_fails_closed_byte_identically);
RUN_TEST(agent_clients_continue_uses_owned_yaml_sequence_item);
RUN_TEST(agent_clients_continue_refuses_foreign_same_name_and_nonsequence_section);
RUN_TEST(client_adapter_pi_registers_every_registry_tool);
RUN_TEST(client_adapter_pi_default_exports_its_factory_issue1550);
RUN_TEST(client_adapter_pi_emits_parameters_and_execute);
RUN_TEST(client_adapter_pi_sends_arguments_over_stdin_issue1834);
RUN_TEST(client_adapter_pi_wraps_result_and_honors_abort);
RUN_TEST(client_adapter_pi_emits_string_enum_issue2787);
RUN_TEST(client_adapter_escapes_windows_paths_and_quotes);
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);
RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction);
RUN_TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077);
RUN_TEST(client_adapter_opencode_v2_adds_no_note_to_the_compaction);
RUN_TEST(client_adapter_opencode_v2_append_result_never_creates_note_only_content);
RUN_TEST(client_adapter_opencode_bounds_a_hung_hook_augment_child);
RUN_TEST(client_adapter_rejects_missing_binary_path);
}