diff --git a/README.md b/README.md index b41939a8f..c6ab67ee2 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ overwrite user-modified agents. | Agent | Activation | MCP config | Durable context / augmentation | |-------|------------|------------|--------------------------------| | Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, and post-`Read` coverage | -| Codex CLI | Detected | `$CODEX_HOME/config.toml` | `AGENTS.md`, skill, three read-only agents; `SessionStart` + `SubagentStart` | +| Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart` | | Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` | | Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill | | OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection | @@ -536,6 +536,11 @@ overwrite user-modified agents. | Oh My Pi (omp) | Detected | Effective agent directory (`OMP_PROFILE` / `PI_CODING_AGENT_DIR`; default `~/.omp/agent/mcp.json`) | Skill and three direct-MCP graph-tool subagents (Scout/Verify/Auditor); preserves user `AGENTS.md` | | Sourcegraph Cody | Explicit opt-in | Existing `$CBM_CODY_CONFIG_PATH` | MCP only | +For Codex, install keeps only a tiny managed activation pointer in global +`$CODEX_HOME/AGENTS.md`; all detailed behavior lives in the installed `codebase-memory` skill. +Fresh installs create the pointer, upgrades replace the legacy full managed block while preserving +all user-owned bytes, and uninstall removes only the managed pointer. + ### Sessions, compaction, and subagents Hooks installed by this project are fail-open and context-only. Claude Code's diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index b93a1f8a3..ca27edcc6 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -144,11 +144,12 @@ run_no_crash() { TMPDIR=$(smoke_mktemp_dir) DRYRUN_HOME="" +CODEX_LIFECYCLE_HOME="" # On MSYS2/Windows, convert POSIX path to native Windows path for the binary if command -v cygpath &>/dev/null; then TMPDIR=$(cygpath -m "$TMPDIR") fi -trap 'smoke_rmtree "$TMPDIR" "${DRYRUN_HOME:-}"' EXIT +trap 'smoke_rmtree "$TMPDIR" "${DRYRUN_HOME:-}" "${CODEX_LIFECYCLE_HOME:-}"' EXIT CLI_STDERR=$(smoke_mktemp_file) # 10 of the cli call sites assign directly (VAR=$(cli ...)). Under @@ -1432,6 +1433,7 @@ echo '# Personal Rovo guidance' > "$ROVO_INSTRUCTIONS" PHASE8_INSTALL_RC=0 PHASE8_INSTALL_LOG=$(smoke_mktemp_file) HOME="$FAKE_HOME" \ + CODEX_HOME="$FAKE_HOME/.codex" \ XDG_CONFIG_HOME="$FAKE_HOME/.config" \ APPDATA="$FAKE_HOME/AppData/Roaming" \ LOCALAPPDATA="$FAKE_HOME/AppData/Local" \ @@ -1704,12 +1706,139 @@ if ! grep -q 'existing_section' "$FAKE_HOME/.codex/config.toml"; then fi echo "OK 8f-h: Codex TOML (MCP + preserved existing)" -# 8i: Codex instructions -if [ ! -f "$FAKE_HOME/.codex/AGENTS.md" ] || ! grep -q 'codebase-memory-mcp' "$FAKE_HOME/.codex/AGENTS.md"; then - echo "FAIL 8i: Codex AGENTS.md missing" - exit 1 -fi -echo "OK 8i: Codex instructions" +# 8i: Codex keeps only a tiny global activation pointer; the installed skill +# owns all detailed behavior. Exercise the complete lifecycle in a second HOME +# so Codex-only reinstalls cannot perturb the all-agent fixture below. +CODEX_POINTER_EXPECTED=$(smoke_mktemp_file) +printf '%s\n' \ + '' \ + 'For structural codebase exploration, use the installed `codebase-memory` skill.' \ + '' > "$CODEX_POINTER_EXPECTED" +if [ ! -f "$FAKE_HOME/.codex/AGENTS.md" ] || + [ "$(smoke_file_sha256 "$FAKE_HOME/.codex/AGENTS.md")" != \ + "$(smoke_file_sha256 "$CODEX_POINTER_EXPECTED")" ]; then + echo "FAIL 8i: fresh Codex install did not create the exact activation pointer" + exit 1 +fi + +CODEX_LIFECYCLE_HOME=$(smoke_mktemp_dir) +CODEX_LIFECYCLE_ROOT="$CODEX_LIFECYCLE_HOME/.codex" +CODEX_INSTRUCTIONS="$CODEX_LIFECYCLE_ROOT/AGENTS.md" +mkdir -p "$CODEX_LIFECYCLE_ROOT" + +CODEX_FRESH_LOG=$(smoke_mktemp_file) +HOME="$CODEX_LIFECYCLE_HOME" \ + CODEX_HOME="$CODEX_LIFECYCLE_ROOT" \ + XDG_CONFIG_HOME="$CODEX_LIFECYCLE_HOME/.config" \ + APPDATA="$CODEX_LIFECYCLE_HOME/AppData/Roaming" \ + LOCALAPPDATA="$CODEX_LIFECYCLE_HOME/AppData/Local" \ + "$BINARY" install --skip-binary --clients=codex -y > "$CODEX_FRESH_LOG" 2>&1 +if [ ! -f "$CODEX_INSTRUCTIONS" ] || + [ "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" != \ + "$(smoke_file_sha256 "$CODEX_POINTER_EXPECTED")" ] || + ! grep -q '\[mcp_servers.codebase-memory-mcp\]' "$CODEX_LIFECYCLE_ROOT/config.toml" || + ! grep -q 'search_graph' "$CODEX_LIFECYCLE_ROOT/skills/codebase-memory/SKILL.md" || + [ ! -s "$CODEX_LIFECYCLE_ROOT/agents/codebase-memory-scout.toml" ] || + [ ! -s "$CODEX_LIFECYCLE_ROOT/agents/codebase-memory.toml" ] || + [ ! -s "$CODEX_LIFECYCLE_ROOT/agents/codebase-memory-auditor.toml" ] || + ! grep -q 'SessionStart' "$CODEX_LIFECYCLE_ROOT/config.toml" || + ! grep -q 'SubagentStart' "$CODEX_LIFECYCLE_ROOT/config.toml"; then + echo "FAIL 8i: isolated fresh Codex install lost the pointer or another surface" + exit 1 +fi + +printf '%s\n' \ + '# Personal Codex guidance' \ + '' \ + 'legacy managed guidance' \ + '' \ + '# Keep this line' > "$CODEX_INSTRUCTIONS" +CODEX_EXPECTED_MIGRATED=$(smoke_mktemp_file) +printf '%s\n' \ + '# Personal Codex guidance' \ + '' \ + 'For structural codebase exploration, use the installed `codebase-memory` skill.' \ + '' \ + '# Keep this line' > "$CODEX_EXPECTED_MIGRATED" +CODEX_EXPECTED_USER=$(smoke_mktemp_file) +printf '%s\n' '# Personal Codex guidance' '# Keep this line' > "$CODEX_EXPECTED_USER" +CODEX_LEGACY_SHA=$(smoke_file_sha256 "$CODEX_INSTRUCTIONS") + +CODEX_PLAN=$(smoke_mktemp_file) +HOME="$CODEX_LIFECYCLE_HOME" \ + CODEX_HOME="$CODEX_LIFECYCLE_ROOT" \ + XDG_CONFIG_HOME="$CODEX_LIFECYCLE_HOME/.config" \ + APPDATA="$CODEX_LIFECYCLE_HOME/AppData/Roaming" \ + LOCALAPPDATA="$CODEX_LIFECYCLE_HOME/AppData/Local" \ + "$BINARY" install --plan --skip-binary --clients=codex > "$CODEX_PLAN" +CODEX_INSTRUCTION_PATH=$(json_get "$CODEX_PLAN" \ + "next((str(x) for x in d.get('instruction_files_planned', []) if str(x).replace('\\\\','/').endswith('/.codex/AGENTS.md')), '')") +CODEX_CLEANUP_COUNT=$(json_get "$CODEX_PLAN" "len(d.get('cleanup_actions_planned', []))") +if ! exact_path_match "$CODEX_INSTRUCTION_PATH" "$CODEX_INSTRUCTIONS" || + [ "$CODEX_CLEANUP_COUNT" != "0" ] || + [ "$CODEX_LEGACY_SHA" != "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" ]; then + echo "FAIL 8i: Codex plan did not describe a non-mutating pointer upsert" + exit 1 +fi + +CODEX_DRY_LOG=$(smoke_mktemp_file) +HOME="$CODEX_LIFECYCLE_HOME" \ + CODEX_HOME="$CODEX_LIFECYCLE_ROOT" \ + XDG_CONFIG_HOME="$CODEX_LIFECYCLE_HOME/.config" \ + APPDATA="$CODEX_LIFECYCLE_HOME/AppData/Roaming" \ + LOCALAPPDATA="$CODEX_LIFECYCLE_HOME/AppData/Local" \ + "$BINARY" install --dry-run --skip-binary --clients=codex -y > "$CODEX_DRY_LOG" 2>&1 +if ! grep -q 'managed activation pointer' "$CODEX_DRY_LOG" || + [ "$CODEX_LEGACY_SHA" != "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" ]; then + echo "FAIL 8i: Codex dry-run did not preview a byte-identical pointer migration" + exit 1 +fi + +CODEX_MIGRATE_LOG=$(smoke_mktemp_file) +HOME="$CODEX_LIFECYCLE_HOME" \ + CODEX_HOME="$CODEX_LIFECYCLE_ROOT" \ + XDG_CONFIG_HOME="$CODEX_LIFECYCLE_HOME/.config" \ + APPDATA="$CODEX_LIFECYCLE_HOME/AppData/Roaming" \ + LOCALAPPDATA="$CODEX_LIFECYCLE_HOME/AppData/Local" \ + "$BINARY" install --skip-binary --clients=codex -y > "$CODEX_MIGRATE_LOG" 2>&1 +if [ ! -f "$CODEX_INSTRUCTIONS" ] || + [ "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" != \ + "$(smoke_file_sha256 "$CODEX_EXPECTED_MIGRATED")" ]; then + echo "FAIL 8i: Codex migration did not replace only the legacy managed block" + exit 1 +fi + +HOME="$CODEX_LIFECYCLE_HOME" \ + CODEX_HOME="$CODEX_LIFECYCLE_ROOT" \ + XDG_CONFIG_HOME="$CODEX_LIFECYCLE_HOME/.config" \ + APPDATA="$CODEX_LIFECYCLE_HOME/AppData/Roaming" \ + LOCALAPPDATA="$CODEX_LIFECYCLE_HOME/AppData/Local" \ + "$BINARY" install --skip-binary --clients=codex -y > /dev/null 2>&1 +if [ ! -f "$CODEX_INSTRUCTIONS" ] || + [ "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" != \ + "$(smoke_file_sha256 "$CODEX_EXPECTED_MIGRATED")" ] || + [ "$(grep -c '' "$CODEX_INSTRUCTIONS")" -ne 1 ]; then + echo "FAIL 8i: Codex reinstall changed or duplicated the activation pointer" + exit 1 +fi +CODEX_UNINSTALL_LOG=$(smoke_mktemp_file) +if ! HOME="$CODEX_LIFECYCLE_HOME" \ + CODEX_HOME="$CODEX_LIFECYCLE_ROOT" \ + XDG_CONFIG_HOME="$CODEX_LIFECYCLE_HOME/.config" \ + APPDATA="$CODEX_LIFECYCLE_HOME/AppData/Roaming" \ + LOCALAPPDATA="$CODEX_LIFECYCLE_HOME/AppData/Local" \ + "$BINARY" uninstall -y -n > "$CODEX_UNINSTALL_LOG" 2>&1; then + echo "FAIL 8i: Codex uninstall returned nonzero" + cat "$CODEX_UNINSTALL_LOG" + exit 1 +fi +if [ ! -f "$CODEX_INSTRUCTIONS" ] || + [ "$(smoke_file_sha256 "$CODEX_INSTRUCTIONS")" != \ + "$(smoke_file_sha256 "$CODEX_EXPECTED_USER")" ]; then + echo "FAIL 8i: Codex uninstall did not remove only the activation pointer" + exit 1 +fi +echo "OK 8i: isolated Codex activation-pointer lifecycle" # 8j-l: Gemini MCP + hooks + merge CMD=$(json_get "$FAKE_HOME/.gemini/settings.json" "d['mcpServers']['codebase-memory-mcp']['command']") @@ -2728,6 +2857,7 @@ if [[ "$BINARY" == *.exe ]]; then UNINSTALL_BINARY="$SELF_PATH" fi HOME="$FAKE_HOME" \ + CODEX_HOME="$FAKE_HOME/.codex" \ XDG_CONFIG_HOME="$FAKE_HOME/.config" \ APPDATA="$FAKE_HOME/AppData/Roaming" \ LOCALAPPDATA="$FAKE_HOME/AppData/Local" \ diff --git a/src/cli/cli.c b/src/cli/cli.c index 249b43e6f..d85ebf0b6 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1393,18 +1393,7 @@ static const char skill_content[] = "5. `search_graph` results default to 50 per page — check `has_more` and use `offset`.\n"; static const char codex_instructions_content[] = - "# Codebase Knowledge Graph\n" - "\n" - "This project uses codebase-memory-mcp to maintain a knowledge graph of the codebase.\n" - "Use the MCP tools to explore and understand the code:\n" - "\n" - "- `search_graph` — find functions, classes, routes by pattern\n" - "- `trace_path` — trace who calls a function or what it calls\n" - "- `get_code_snippet` — read function source code\n" - "- `query_graph` — run Cypher queries for complex patterns\n" - "- `get_architecture` — high-level project summary\n" - "\n" - "Always prefer graph tools over grep for code discovery.\n"; + "For structural codebase exploration, use the installed `codebase-memory` skill.\n"; /* Old skill names — cleaned up during install to remove stale directories. */ static const char *old_skill_names[] = { @@ -8054,6 +8043,24 @@ static bool install_generic_agent_config(const char *label, const char *binary_p return mcp_installed; } +static bool install_codex_activation_pointer(const char *path, bool dry_run) { + if (!path) { + return false; + } + if (g_install_plan) { + plan_record("Codex CLI", "instructions", path); + return true; + } + return dry_run || cbm_upsert_instructions(path, codex_instructions_content) == CLI_OK; +} + +static void report_codex_activation_pointer_install(const char *path, bool installed) { + printf(" instructions: %s (managed activation pointer)\n", path); + if (!installed) { + record_agent_config_error(false, "Codex CLI", "instructions_install", path); + } +} + static void install_windsurf_config(const char *binary_path, const char *config_path, const char *rules_path, bool dry_run) { if (g_install_plan) { @@ -8997,6 +9004,8 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const snprintf(ip, sizeof(ip), "%s/AGENTS.md", config_dir); snprintf(skills_dir, sizeof(skills_dir), "%s/skills", config_dir); snprintf(ap, sizeof(ap), "%s/agents/codebase-memory.toml", config_dir); + /* A broken hook config must not keep legacy full guidance active. */ + bool pointer_installed = install_codex_activation_pointer(ip, dry_run); char command[CLI_BUF_8K]; char command_windows[CLI_BUF_8K]; char hooks_json[CLI_BUF_1K]; @@ -9024,10 +9033,16 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const record_agent_config_error_with_reason( false, "Codex CLI", commands_ok ? "hook_preflight" : "hook_command_build", cp, reason); + if (!g_install_plan) { + report_codex_activation_pointer_install(ip, pointer_installed); + } goto codex_install_done; } - install_generic_agent_config("Codex CLI", binary_path, cp, ip, dry_run, + install_generic_agent_config("Codex CLI", binary_path, cp, NULL, dry_run, cbm_upsert_codex_mcp); + if (!g_install_plan) { + report_codex_activation_pointer_install(ip, pointer_installed); + } install_agent_skill("Codex CLI", skills_dir, force, dry_run); install_tiered_agent_profiles( (cbm_tiered_profile_set_t){ @@ -10953,6 +10968,17 @@ static void uninstall_agent_mcp_instr(mcp_uninstall_args_t paths, bool dry_run, } } +static bool uninstall_codex_activation_pointer(const char *path, bool dry_run) { + return path && (dry_run || cbm_remove_instructions(path) == CLI_OK); +} + +static void report_codex_activation_pointer_uninstall(const char *path, bool removed) { + printf(" instructions: removed managed activation pointer\n"); + if (!removed) { + record_agent_config_error(true, "Codex CLI", "instructions_uninstall", path); + } +} + static void uninstall_agent_skill(const char *label, const char *skills_dir, bool dry_run) { int removed = cbm_remove_skills(skills_dir, dry_run); printf(" %s skill: %d removed\n", label, removed); @@ -11387,6 +11413,7 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char snprintf(ip, sizeof(ip), "%s/AGENTS.md", config_dir); snprintf(skills_dir, sizeof(skills_dir), "%s/skills", config_dir); snprintf(ap, sizeof(ap), "%s/agents/codebase-memory.toml", config_dir); + bool pointer_removed = uninstall_codex_activation_pointer(ip, dry_run); cbm_agent_installed_binary_path(home, installed_binary, sizeof(installed_binary)); char hook_command[CLI_BUF_8K]; char hook_command_windows[CLI_BUF_8K]; @@ -11407,10 +11434,12 @@ static void uninstall_cli_agents(const cbm_detected_agents_t *agents, const char ? NULL : cbm_toml_codex_hook_failure_name(preflight_failure); record_agent_config_error_with_reason(true, "Codex CLI", "hook_preflight", cp, reason); + report_codex_activation_pointer_uninstall(ip, pointer_removed); goto codex_toml_done; } - uninstall_agent_mcp_instr((mcp_uninstall_args_t){"Codex CLI", cp, ip}, dry_run, + uninstall_agent_mcp_instr((mcp_uninstall_args_t){"Codex CLI", cp, NULL}, dry_run, cbm_remove_codex_mcp_owned); + report_codex_activation_pointer_uninstall(ip, pointer_removed); if (!dry_run && cbm_reconcile_codex_hooks_command(cp, hook_command, hook_command_windows, CBM_TOML_CODEX_HOOK_REMOVE, false) != CLI_OK) { diff --git a/tests/test_cli.c b/tests/test_cli.c index f1507bd2a..2fbcc20d2 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -499,6 +499,14 @@ static bool test_plan_has_hook_for_agent(yyjson_val *root, const char *agent) { return false; } +static const char test_codex_activation_pointer[] = + "For structural codebase exploration, use the installed `codebase-memory` skill.\n"; + +static const char test_codex_activation_block[] = + "\n" + "For structural codebase exploration, use the installed `codebase-memory` skill.\n" + "\n"; + static size_t test_count_substring(const char *text, const char *needle) { size_t count = 0U; size_t needle_len = strlen(needle); @@ -2728,11 +2736,11 @@ TEST(cli_skill_files_content) { } TEST(cli_codex_instructions) { - /* Port of TestCodexInstructionsCreation */ const char *instr = cbm_get_codex_instructions(); ASSERT_NOT_NULL(instr); - ASSERT(strstr(instr, "Codebase Knowledge Graph") != NULL); - ASSERT(strstr(instr, "trace_path") != NULL); + ASSERT_STR_EQ(instr, test_codex_activation_pointer); + ASSERT_NULL(strstr(instr, "search_graph")); + ASSERT_NULL(strstr(instr, "trace_path")); PASS(); } @@ -4822,6 +4830,7 @@ TEST(cli_install_plan_receipt_no_mutation_issue388) { } } } + free(json); /* Critical: building the plan must NOT have created any config file. */ @@ -8195,23 +8204,169 @@ TEST(cli_codex_respects_codex_home) { char *saved = save_test_env("CODEX_HOME"); cbm_setenv("CODEX_HOME", codex_home, 1); + char expected_instructions[640]; + snprintf(expected_instructions, sizeof(expected_instructions), "%s/AGENTS.md", codex_home); + const char *user_instructions = "# Personal Codex guidance\n"; + ASSERT_EQ(write_test_file(expected_instructions, user_instructions), 0); + cbm_detected_agents_t agents = cbm_detect_agents(tmpdir); char *json = cbm_build_install_plan_json(tmpdir, "/usr/local/bin/codebase-memory-mcp"); char expected_config[640]; - char expected_instructions[640]; snprintf(expected_config, sizeof(expected_config), "%s/config.toml", codex_home); - snprintf(expected_instructions, sizeof(expected_instructions), "%s/AGENTS.md", codex_home); - bool plans_config = json && strstr(json, expected_config) != NULL; - bool plans_instructions = json && strstr(json, expected_instructions) != NULL; + yyjson_doc *plan_doc = json ? yyjson_read(json, strlen(json), 0) : NULL; + yyjson_val *plan_root = plan_doc ? yyjson_doc_get_root(plan_doc) : NULL; + bool plans_config = test_json_string_array_contains(plan_root, "config_files_planned", + expected_config); + bool plans_instructions = test_json_string_array_contains( + plan_root, "instruction_files_planned", expected_instructions); + bool plans_cleanup = json && strstr(json, "remove_managed_block_if_present") != NULL; + char *instructions_after = read_test_file_alloc(expected_instructions); + bool plan_preserved_user_file = + instructions_after && strcmp(instructions_after, user_instructions) == 0; + free(instructions_after); + yyjson_doc_free(plan_doc); free(json); restore_test_env("CODEX_HOME", saved); test_rmdir_r(tmpdir); if (!agents.codex) FAIL("Codex detection must honor CODEX_HOME"); - if (!plans_config || !plans_instructions) - FAIL("Codex install plan must place config and AGENTS.md under CODEX_HOME"); + if (!plans_config || !plans_instructions || plans_cleanup || !plan_preserved_user_file) + FAIL("Codex plan must include the managed activation pointer under CODEX_HOME without " + "mutating existing user content"); + PASS(); +} + +TEST(cli_codex_install_uses_global_activation_pointer_issue1689) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-agents-pointer-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_home[512]; + char agents_path[640]; + char config_path[640]; + char skill_path[768]; + char profile_path[768]; + char binary_path[768]; + snprintf(codex_home, sizeof(codex_home), "%s/.codex", tmpdir); + snprintf(agents_path, sizeof(agents_path), "%s/AGENTS.md", codex_home); + snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_home); + snprintf(skill_path, sizeof(skill_path), "%s/skills/codebase-memory/SKILL.md", codex_home); + snprintf(profile_path, sizeof(profile_path), "%s/agents/codebase-memory.toml", codex_home); +#ifdef _WIN32 + snprintf(binary_path, sizeof(binary_path), "%s/.local/bin/codebase-memory-mcp.exe", tmpdir); +#else + snprintf(binary_path, sizeof(binary_path), "%s/.local/bin/codebase-memory-mcp", tmpdir); +#endif + ASSERT_EQ(test_mkdirp(codex_home), 0); + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("CODEX_HOME", codex_home, 1); + + struct stat state; + int fresh_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false); + char *fresh_agents = read_test_file_alloc(agents_path); + bool fresh_pointer_installed = fresh_rc == 0 && fresh_agents && + strcmp(fresh_agents, test_codex_activation_block) == 0; + char *config = read_test_file_alloc(config_path); + bool other_surfaces_installed = + fresh_rc == 0 && config && strstr(config, "[mcp_servers.codebase-memory-mcp]") && + strstr(config, "SessionStart") && stat(skill_path, &state) == 0 && + stat(profile_path, &state) == 0; + free(fresh_agents); + free(config); + + const char *user_only = "# Personal Codex guidance\nKeep this byte-for-byte.\n"; + ASSERT_EQ(write_test_file(agents_path, user_only), 0); + int dry_rc = cbm_install_agent_configs(tmpdir, binary_path, false, true); + char *after_dry = read_test_file_alloc(agents_path); + int unowned_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false); + char *after_unowned = read_test_file_alloc(agents_path); + char expected_user_pointer[1024]; + int user_pointer_written = snprintf(expected_user_pointer, sizeof(expected_user_pointer), + "%s%s", user_only, test_codex_activation_block); + bool unowned_preserved = + user_pointer_written > 0 && (size_t)user_pointer_written < sizeof(expected_user_pointer) && + dry_rc == 0 && unowned_rc == 0 && after_dry && after_unowned && + strcmp(after_dry, user_only) == 0 && + strcmp(after_unowned, expected_user_pointer) == 0; + free(after_dry); + free(after_unowned); + + const char *legacy = "# Before\n\nlegacy\n" + "\n# After\n"; + char expected_migration[1024]; + int migration_written = snprintf(expected_migration, sizeof(expected_migration), + "# Before\n%s# After\n", test_codex_activation_block); + ASSERT_EQ(write_test_file(agents_path, legacy), 0); + int managed_dry_rc = cbm_install_agent_configs(tmpdir, binary_path, false, true); + char *after_managed_dry = read_test_file_alloc(agents_path); + int managed_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false); + char *after_managed = read_test_file_alloc(agents_path); + int repeat_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false); + char *after_repeat = read_test_file_alloc(agents_path); + bool managed_migrated_once = + migration_written > 0 && (size_t)migration_written < sizeof(expected_migration) && + managed_dry_rc == 0 && after_managed_dry && strcmp(after_managed_dry, legacy) == 0 && + managed_rc == 0 && repeat_rc == 0 && after_managed && after_repeat && + strcmp(after_managed, expected_migration) == 0 && + strcmp(after_repeat, expected_migration) == 0 && + test_count_substring(after_repeat, "") == 1U; + free(after_managed_dry); + free(after_managed); + free(after_repeat); + + const char *managed_only = "\nlegacy\n" + "\n"; + ASSERT_EQ(write_test_file(agents_path, managed_only), 0); + int managed_only_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false); + char *after_managed_only = read_test_file_alloc(agents_path); + bool marker_only_migrated = managed_only_rc == 0 && after_managed_only && + strcmp(after_managed_only, test_codex_activation_block) == 0 && + stat(agents_path, &state) == 0; + free(after_managed_only); + + const char *malformed = "# Keep\n\nunterminated\n"; + ASSERT_EQ(write_test_file(agents_path, malformed), 0); + ASSERT_EQ(remove(config_path), 0); + ASSERT_EQ(remove(skill_path), 0); + ASSERT_EQ(remove(profile_path), 0); + int malformed_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false); + char *after_malformed = read_test_file_alloc(agents_path); + config = read_test_file_alloc(config_path); + bool malformed_preserved = malformed_rc != 0 && after_malformed && + strcmp(after_malformed, malformed) == 0 && config && + strstr(config, "[mcp_servers.codebase-memory-mcp]") && + strstr(config, "SessionStart") && stat(skill_path, &state) == 0 && + stat(profile_path, &state) == 0; + free(config); + free(after_malformed); + + ASSERT_EQ(write_test_file(agents_path, expected_user_pointer), 0); + char *uninstall_argv[] = {"uninstall", "--yes"}; + int uninstall_rc = cli_test_cmd_uninstall(2, uninstall_argv); + char *after_uninstall = read_test_file_alloc(agents_path); + bool uninstall_preserved_foreign = uninstall_rc == 0 && after_uninstall && + strcmp(after_uninstall, user_only) == 0; + free(after_uninstall); + + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + + if (!fresh_pointer_installed || !other_surfaces_installed || !unowned_preserved || + !managed_migrated_once || !marker_only_migrated || !malformed_preserved || + !uninstall_preserved_foreign) + FAIL("Codex install must keep only a tiny managed activation pointer, preserve foreign " + "bytes, migrate legacy blocks, fail closed on malformed markers, and remove the " + "pointer on uninstall"); PASS(); } @@ -10346,8 +10501,10 @@ TEST(cli_codex_migrates_to_single_hook_representation) { char hooks_path[640]; char config_path[640]; + char agents_path[640]; snprintf(hooks_path, sizeof(hooks_path), "%s/hooks.json", codex_dir); snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_dir); + snprintf(agents_path, sizeof(agents_path), "%s/AGENTS.md", codex_dir); int first_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false); char *first = read_test_file_alloc(config_path); int repeat_rc = cbm_install_agent_configs(tmpdir, binary_path, false, false); @@ -10387,14 +10544,17 @@ TEST(cli_codex_migrates_to_single_hook_representation) { snprintf(agent_path, sizeof(agent_path), "%s/agents/codebase-memory.toml", codex_dir); struct stat state; hooks = read_test_file_alloc(hooks_path); + char *agents_after_uninstall = read_test_file_alloc(agents_path); bool independent_cleanup = uninstall_rc != 0 && stat(binary_path, &state) == 0 && stat(skill_path, &state) != 0 && stat(agent_path, &state) != 0 && - hooks && !strstr(hooks, "hook-augment"); + hooks && !strstr(hooks, "hook-augment") && agents_after_uninstall && + agents_after_uninstall[0] == '\0'; free(hooks); + free(agents_after_uninstall); char bad_home[256]; snprintf(bad_home, sizeof(bad_home), "/tmp/cli-codex-preflight-XXXXXX"); - bool no_partial = false; + bool pointer_only_on_preflight_failure = false; if (cbm_mkdtemp(bad_home)) { char bad_codex[512]; char bad_config[640]; @@ -10408,18 +10568,110 @@ TEST(cli_codex_migrates_to_single_hook_representation) { cbm_setenv("PATH", bad_home, 1); int bad_rc = cbm_install_agent_configs(bad_home, binary_path, false, false); char *bad_after = read_test_file_alloc(bad_config); - no_partial = bad_rc != 0 && bad_after && strcmp(bad_after, ambiguous) == 0 && - stat(bad_agents, &state) != 0; + char *bad_agents_after = read_test_file_alloc(bad_agents); + pointer_only_on_preflight_failure = + bad_rc != 0 && bad_after && strcmp(bad_after, ambiguous) == 0 && bad_agents_after && + strcmp(bad_agents_after, test_codex_activation_block) == 0; free(bad_after); + free(bad_agents_after); test_rmdir_r(bad_home); } restore_test_env("HOME", saved_home); restore_test_env("PATH", saved_path); restore_test_env("CODEX_HOME", saved_codex); test_rmdir_r(tmpdir); - if (!lifecycle_ok || !migrated || !independent_cleanup || !no_partial) - FAIL("Codex lifecycle preflight must be idempotent, transactional, and independently " - "clean owned side files"); + if (!lifecycle_ok || !migrated || !independent_cleanup || + !pointer_only_on_preflight_failure) + FAIL("Codex lifecycle preflight must be idempotent, preserve the activation pointer " + "contract, and independently clean owned side files"); + PASS(); +} + +TEST(cli_codex_pointer_migration_precedes_hook_preflight_issue1689) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-codex-cleanup-preflight-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + char codex_dir[512]; + char config_path[640]; + char agents_path[640]; + char skill_path[768]; + char profile_path[768]; + snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); + snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_dir); + snprintf(agents_path, sizeof(agents_path), "%s/AGENTS.md", codex_dir); + snprintf(skill_path, sizeof(skill_path), "%s/skills/codebase-memory/SKILL.md", codex_dir); + snprintf(profile_path, sizeof(profile_path), "%s/agents/codebase-memory.toml", codex_dir); + if (test_mkdirp(codex_dir) != 0) { + test_rmdir_r(tmpdir); + FAIL("failed to create Codex cleanup preflight fixture directory"); + } + + const char *ambiguous = + "[hooks]\nSessionStart = [{ matcher = 'startup|resume|clear|compact', hooks = [" + "{ type = 'command', command = 'codebase-memory-mcp hook-augment' }, " + "{ type = 'command', command = 'foreign' }] }]\n"; + const char *legacy = "# Before\n\nlegacy\n" + "\n# After\n"; + char legacy_migrated[1024]; + int migrated_written = snprintf(legacy_migrated, sizeof(legacy_migrated), + "# Before\n%s# After\n", test_codex_activation_block); + if (migrated_written <= 0 || (size_t)migrated_written >= sizeof(legacy_migrated)) { + test_rmdir_r(tmpdir); + FAIL("failed to build expected Codex pointer migration"); + } + if (write_test_file(config_path, ambiguous) != 0 || + write_test_file(agents_path, legacy) != 0) { + test_rmdir_r(tmpdir); + FAIL("failed to write Codex cleanup preflight fixture"); + } + + char *saved_home = save_test_env("HOME"); + char *saved_path = save_test_env("PATH"); + char *saved_codex = save_test_env("CODEX_HOME"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_unsetenv("CODEX_HOME"); + + char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp"); + yyjson_doc *plan_doc = plan ? yyjson_read(plan, strlen(plan), 0) : NULL; + yyjson_val *plan_root = plan_doc ? yyjson_doc_get_root(plan_doc) : NULL; + bool plans_pointer = + test_json_string_array_contains(plan_root, "instruction_files_planned", agents_path); + char *config_after_plan = read_test_file_alloc(config_path); + char *agents_after_plan = read_test_file_alloc(agents_path); + bool plan_preserved_files = config_after_plan && agents_after_plan && + strcmp(config_after_plan, ambiguous) == 0 && + strcmp(agents_after_plan, legacy) == 0; + + int install_rc = + cbm_install_agent_configs(tmpdir, "/opt/codebase-memory-mcp", false, false); + char *config_after_install = read_test_file_alloc(config_path); + char *agents_after_install = read_test_file_alloc(agents_path); + struct stat state; + bool preflight_failed_closed = + install_rc != 0 && config_after_install && strcmp(config_after_install, ambiguous) == 0 && + stat(skill_path, &state) != 0 && stat(profile_path, &state) != 0; + bool pointer_migrated = + agents_after_install && strcmp(agents_after_install, legacy_migrated) == 0; + + free(config_after_plan); + free(agents_after_plan); + free(config_after_install); + free(agents_after_install); + if (plan_doc) { + yyjson_doc_free(plan_doc); + } + free(plan); + restore_test_env("HOME", saved_home); + restore_test_env("PATH", saved_path); + restore_test_env("CODEX_HOME", saved_codex); + test_rmdir_r(tmpdir); + + if (!plans_pointer || !plan_preserved_files || !preflight_failed_closed || !pointer_migrated) + FAIL("Codex legacy instructions must migrate to the activation pointer independently of " + "hook preflight while other config surfaces fail closed"); PASS(); } @@ -10488,13 +10740,15 @@ TEST(cli_codex_preflight_reports_heading_and_reason) { fclose(capture); } char *after = read_test_file_alloc(config_path); - struct stat state; - bool unchanged = after && strcmp(after, ambiguous) == 0 && stat(agents_path, &state) != 0; + char *agents_after = read_test_file_alloc(agents_path); + bool unchanged = after && strcmp(after, ambiguous) == 0 && agents_after && + strcmp(agents_after, test_codex_activation_block) == 0; bool diagnostic = strstr(output, "Codex CLI:\nerror: agent_config agent=Codex CLI op=hook_preflight path=") != NULL && strstr(output, "reason=ambiguous_hook_ownership") != NULL; free(after); + free(agents_after); restore_test_env("HOME", saved_home); restore_test_env("PATH", saved_path); @@ -14580,6 +14834,7 @@ SUITE(cli) { RUN_TEST(cli_claude_user_scope_avoids_nested_mcp_json); RUN_TEST(cli_codex_respects_codex_home); RUN_TEST(cli_grok_respects_grok_home); + RUN_TEST(cli_codex_install_uses_global_activation_pointer_issue1689); RUN_TEST(cli_gemini_session_hook_uses_json_for_all_sources); RUN_TEST(cli_gemini_installs_dedicated_graph_subagent); RUN_TEST(cli_antigravity_does_not_imply_gemini); @@ -14625,6 +14880,7 @@ SUITE(cli) { RUN_TEST(cli_claude_hook_scripts_shell_quote_binary_path); RUN_TEST(cli_claude_hook_commands_use_exec_form_with_custom_config_dir); RUN_TEST(cli_codex_migrates_to_single_hook_representation); + RUN_TEST(cli_codex_pointer_migration_precedes_hook_preflight_issue1689); #ifndef _WIN32 RUN_TEST(cli_codex_preflight_reports_heading_and_reason); #endif