Skip to content

fix(init): return exit code 2 when codex uninstall guard refuses hook (fixes #4164) - #4463

Merged
KuSh merged 2 commits into
rtk-ai:developfrom
TechWizard9999:fix/4164-codex-uninstall-exit-code
Oct 30, 2026
Merged

KuSh merged 2 commits into
rtk-ai:developfrom
TechWizard9999:fix/4164-codex-uninstall-exit-code

Conversation

@TechWizard9999

Copy link
Copy Markdown
Contributor

Fixes #4164.

When rtk init --codex --uninstall runs in a project where .codex is a symlink pointing outside the project, the containment guard refuses to touch the hook file. Previously the command printed a clear warning but exited 0, making it impossible for scripts to detect partial cleanup.

Changes:

  • Added UninstallCodexError enum in codex.rs with HookRefused and Other variants
  • uninstall_codex now returns Result<(), UninstallCodexError> and returns HookRefused when the guard triggers
  • uninstall_with_patch_mode propagates HookRefused as-is
  • run_cli in main.rs maps HookRefused to exit code 2
  • Updated test test_uninstall_leaves_a_hooks_file_that_sits_outside_the_project_alone to expect the error

Exit codes:

  • 0: Full uninstall success
  • 2: Partial cleanup — hook may remain (guard refused)
  • 1: Other failure

@KuSh — ready for review.

@rtk-wshm-sync-bot

Copy link
Copy Markdown

wshm · Automated triage by AI

📊 Automated PR Analysis

🐛 Type bug-fix
🔴 Risk high

Summary

Changes uninstall_codex to return a typed error so that when the symlink-containment guard refuses to touch a hook file outside the project, rtk exits with code 2 instead of 0, letting scripts detect partial cleanup. However, the actual diff bundles in a large amount of unrelated content: a new OpenCode plugin and AGENTS.md with aggressive 'always use this tool, never grep/read' directives, two ad-hoc GitHub-querying scripts, and unrelated curl/docs changes.

Review Checklist

  • Tests present
  • Breaking change
  • Docs updated

Linked issues: #4164


Analyzed automatically by wshm · This is an automated analysis, not a human review.

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1 of 3 — CHANGES REQUESTED

Claim: rtk init --codex --uninstall exits 2 when the containment guard refuses the hook path, so a script can tell a partial uninstall from a full one (#4164).
Scope: accept if narrowed to "the #4164 change only — src/hooks/init/codex.rs, src/hooks/init/mod.rs and the exit-code mapping, rebuilt on current develop" (round 1, frozen). The branch carries 13 other files: the read.rs and curl commits from #4367/#4370 (both closed), the OpenCode claude.rs/docs changes, and local agent tooling (AGENTS.md, .opencode/, .gitattributes/.gitignore graphify entries, check_prs.py, find_candidates.py). The tooling cannot land in any form: AGENTS.md is read by every agent working in this repo. The read.rs part does not compile (src/cmds/system/read.rs:367, a doubled mod tests {), and the branch conflicts with develop, where main.rs's routing moved to src/lib.rs. It also overlaps #4269 in codex.rs/mod.rs; once narrowed, this one lands first.
Ran: since the head does not build, b6b250a's codex.rs/mod.rs/main.rs hunks were ported onto develop (a pub use codex::UninstallCodexError was needed, because codex is a private module and the import fails with E0603). Merge-base binary vs that port, × 7 cases: the #4164 repro (.codex symlinked outside), the same with --dry-run, .codex/hooks.json.bak symlinked outside, a clean install→uninstall, nothing installed, an invalid hooks.json, and a global install→uninstall with ~/.codex symlinked elsewhere. Exit codes and both streams were compared. Invariants checked: split streams, every decision. Under the hook, rtk rewrite leaves rtk init alone. Savings are not applicable.
Pins: 4 decisions, 4 mutated, 3 survived the suite (mod.rs HookRefused arm, the exit-2 mapping, From<anyhow::Error> → Other).
Matrix: never run before this round; running now. Pin check: never run before; running now.
CI @b6b250a7: no CI run exists. The branch conflicts, so the CI workflow never started, and only the CLA check is present. Locally the head fails to compile.

Blocks merge (3, frozen at round 1)

  1. src/hooks/init/mod.rs:865 — exit 2 is unreachable: the error loses its type one hop before run_cli. HookRefused(e) => return Err(e) returns the inner anyhow::Error, so downcast_ref::<UninstallCodexError>() in run_cli never matches. Evidence, before → after:

    • #4164 repro: exit 0 → exit 1. The guard message prints twice, once in the stdout report and again as rtk: … on stderr.
    • --dry-run: exit 0 → exit 1, and the [dry-run] Nothing written. footer is gone, because the early return skips print_dry_run_footer().
    • .bak symlink: exit 0 → exit 1.

    A correct fix gives these rows:

    Case Exit Output
    .codex symlinked outside 2 stderr does not repeat the report
    hooks.json.bak symlinked outside 2 same
    refused case with --dry-run 2 [dry-run] Nothing written. still printed
    clean uninstall 0
    nothing installed 0
    invalid .codex/hooks.json 1
  2. src/hooks/init/codex.rs:517 — the guard trips at two sites, and only one test was updated. This return also fires when hooks.json.bak resolves outside the project (ensure_inside_project(&backup_path_for(..))). test_uninstall_refuses_a_backup_sibling_that_leaves_the_project (:2069) still .expects Ok, so it fails: 331 passed, 1 failed on the ported change. It needs the same expect_err + HookRefused assertion as the .codex-symlink test.

  3. src/main.rs:2578 — unpinned behaviour (the exit-code mapping). These mutations pass every hooks::init and init_uninstall test:

    • mod.rs HookRefused(_) => {}
    • return Ok(2) → return Err(e)
    • From<anyhow::Error> producing HookRefused instead of Other

    The one updated test checks what uninstall_codex returns, not the process exit. A test that runs rtk init --codex --uninstall through common::rtk_command() in a temp project must assert the six rows in item 1, and each of those three mutations must turn it red.

Optional — will not hold merge

  • HookRefused's Display prints the inner error, and source() returns that same error. Any path that reports it through entry() instead of mapping it to 2 prints the guard text twice (rtk: hook refused: X: X). Dropping {e} from Display, or returning None from source(), avoids that.
  • The enum carries one bit through three layers and a downcast. An alternative is for uninstall_codex to return an outcome (Ok(hook_refused: bool) or an exit code) that run_cli maps to 2, with no downcast. Either shape is fine; the rows above are what matters.

Filed as follow-ups

  • #4589 Codex uninstall report says "not installed (nothing to remove)" and then "the hook may still be registered" — pre-existing, outside this PR's hunks; filed.

Checked and correct — no need to re-verify

  • uninstall_codex returns HookRefused exactly when hook_left_in_place is set, after the report is printed and after AGENTS.md/RTK.md are cleaned. The rest of the uninstall still happens in both refused cases. This is pinned by test_uninstall_leaves_a_hooks_file_that_sits_outside_the_project_alone.
  • --global never sets hook_left_in_place, so a global uninstall keeps exit 0.
  • A clean install→uninstall round-trip exits 0 and removes the hook, RTK.md and the AGENTS.md reference.

Your questions

  • (none; thanks for flagging that the branch was generated and unreviewed — this round is meant to give you the exact list.)

Next

Rebuild the branch on develop with only the #4164 change: codex.rs, hooks/init/mod.rs, and the mapping in src/lib.rs, with UninstallCodexError re-exported from hooks::init. Then fix 1, update the test in 2, add the exit-code test in 3, and run cargo fmt (the mapping line and import order fail it).
Rounds: 1/3. Threads: 3 open (waiting on the author), 0 resolved this round.

Comment thread src/hooks/init/mod.rs Resolved
Comment thread src/hooks/init/codex.rs Resolved
Comment thread src/main.rs Resolved
@TechWizard9999
TechWizard9999 force-pushed the fix/4164-codex-uninstall-exit-code branch from b6b250a to 779ff92 Compare October 22, 2026 05:10
@TechWizard9999

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback from @brian-rtk:

  1. Rebuilt branch cleanly on top of latest develop: Stripped out all unrelated hunks and commits.
  2. Simplified outcome architecture: uninstall_codex returns Result<UninstallCodexOutcome>, where UninstallCodexOutcome::HookRefused maps cleanly to exit code 2 through the CLI runner without downcasting, avoiding error repetition to stderr.
  3. Dry-run footer preservation: Handled dry_run before returning exit code 2, preserving [dry-run] Nothing written..
  4. Updated existing tests: Updated both test_uninstall_leaves_a_hooks_file_that_sits_outside_the_project_alone and test_uninstall_refuses_a_backup_sibling_that_leaves_the_project to assert UninstallCodexOutcome::HookRefused.
  5. Added end-to-end integration tests: Created tests/init_codex_uninstall_test.rs covering containment refusal, dry-run refusal, sibling backup traversal, and clean uninstalls with common::rtk_command().
  6. Formatting: Ran cargo fmt --all and verified clean cargo clippy --all-targets and test suites.

@TechWizard9999

TechWizard9999 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @KuSh @aeppling, all 3 block-merge review points have been addressed and tested with end-to-end integration tests. All CI checks are passing cleanly. Could you please take another look and review when you have time? Thank you!

An uninstall that fails for another reason exits 1, and every other
agent's completed uninstall exits 0. The codex test now also checks
that AGENTS.md is cleaned when the hook is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@KuSh KuSh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 — APPROVED

Claim: rtk init --codex --uninstall exits 2 when the containment guard refuses the hook path, so a script can tell a partial uninstall from a full one (#4164).
Scope: accept if narrowed to "the #4164 change only, rebuilt on current develop" (round 1, frozen). The narrowing has been met: the branch is one commit touching codex.rs, hooks/init/mod.rs, src/lib.rs and a test. It merges cleanly onto develop 7e92da0.
Ran: merge-base 5ab6291, head 779ff92, and the head merged onto develop. Each ran 6 codex uninstall cases:

  • .codex symlinked outside;
  • the same with --dry-run;
  • hooks.json.bak symlinked outside;
  • a clean install→uninstall;
  • nothing installed;
  • invalid .codex/hooks.json.

They also ran 16 install→uninstall rows across every other agent (Claude, Gemini, Cursor, Pi, OMP, Trae, Antigravity, Droid, Vibe, Hermes, Copilot, the local and -g forms, the error paths). Invariants: split streams, every decision. Savings not applicable.
Pins: 16 decisions, 16 mutated. 11 survived the PR's suite: the error-path exit 1, and the 10 Ok(0) exit codes now returned by the other agents' uninstalls. All 11 are killed by the fixup below. So are 3 more aimed at the new assertions: a refused dry run that writes, and two refusals that are reported but not honoured.
Matrix: executed round 1, rerun this round on the new head. Pin check: executed round 1, rerun this round on the new head.
CI @779ff920: all green (15/15). @cdbc41001: all green (15/15).

Blocks merge (3, frozen at round 1) — all closed

  1. Fixed: exit 2 is now reached. Before → after on the head:

    Case Before After
    .codex symlinked outside 0 2
    hooks.json.bak symlinked outside 0 2
    refused --dry-run 0 2, footer kept

    On the head, clean and no-op uninstalls exit 0, invalid JSON exits 1, and stderr is empty in every refused case. AGENTS.md is still cleaned when the hook is refused.

  2. Fixed: both guard tests (.codex symlink and .bak sibling) assert HookRefused.

  3. Fixed: test_codex_uninstall_exit_codes kills all 6 mutations of the codex decisions:

    • the refused-or-clean choice;
    • both arms of the exit-code match (each mapped to the other's code);
    • the dry-run footer order;
    • dropping the code in run_cli.

    Two gaps remained, and the fixup commit closes both:

    • Error path: the invalid-hooks.json → 1 row was not asserted, so unwrap_or(HookRefused) on the uninstall result passed the suite. It is now case 4 of test_codex_uninstall_exit_codes.
    • Other agents: uninstall_with_patch_mode and uninstall_init_dispatch now return a code, which is the shape my round-1 note suggested. That gave 10 other agents' uninstalls an Ok(0) no test checked: changing any of them to Ok(2) passed the suite. The new tests/init_uninstall_exit_code_test.rs installs and uninstalls 13 agent forms: the 10, project-scoped Pi and OMP, and global Codex. Each must report "RTK uninstalled" and exit 0. Each of the 10 mutations turns it red, and so does making the global Codex uninstall report a refusal.

    The fixup also tightens test_codex_uninstall_exit_codes. Each of these checks fails if its behaviour regresses:

    • The refused cases now start from a registered RTK hook, so an unhonoured refusal has something to rewrite.
    • The "Project cleanup still performed" assertion held whether or not cleanup ran. It now checks that the user's RTK.md is kept, that AGENTS.md loses its @RTK.md line, and that the hook outside the project is left alone. Reporting the refusal but still editing the hook turns it red.
    • The refused --dry-run starts from a fresh @RTK.md and must leave it in place. Passing dry_run: false into uninstall_codex turns it red.
    • The .bak case checks that stdout explains the refusal, that the project's hook stays registered, and that nothing is written through the symlink. Refusing only on the .bak check while still writing turns it red.
    • The real install→uninstall must report "RTK uninstalled for Codex CLI" and leave no rtk hook codex entry.
    • The invalid-JSON case checks that stderr names .codex/hooks.json and that the file is left untouched.

Filed as follow-ups

  • #4589 Codex uninstall report says "not installed (nothing to remove)" and then "the hook may still be registered". Filed at round 1; outside this PR's hunks.
  • #4156 rtk init --codex writes through a symlinked AGENTS.md. The uninstall does the same, on develop and on this head. Outside this PR's hunks; comment added with the repro.
  • #3404 Kilocode, kimi, windsurf and cline uninstalls remove nothing of the agent; kilocode and kimi exit 1 with "Uninstall only works with --global flag". Found while writing the fixup's agent list, outside this PR's hunks, and already tracked by #3404.

Checked and correct — no need to re-verify

  • On the 16 other-agent rows, exit code, stdout and stderr are identical to the merge-base (temp paths normalised).
  • return uninstall_init_dispatch(...) skips nothing: run_cli returns Ok(code) right after the match.
  • A global Codex uninstall cannot hit the guard and still exits 0.
  • The fixup merged onto develop passes cargo fmt --check and clippy --all-targets with 0 warnings. cargo test --all passes, except one test that also fails on develop on this machine (go_test_help_is_not_replayed_through_the_injected_json, a local toolchain shim).

Your questions

  • (none)

Next

Fixup cdbc410 pushed (test-only, code-reviewed): it pins exit 1 on an ordinary uninstall failure and exit 0 with a reported removal for every other agent's uninstall, and makes the codex cases check what each claims. Approving. Thanks for the clean rebuild.
Threads: 0 open, 3 resolved this round.

@KuSh
KuSh merged commit 2fe9cff into rtk-ai:develop Oct 30, 2026
15 checks passed
@rtk-release-bot rtk-release-bot Bot mentioned this pull request Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rtk init --codex --uninstall exits 0 when the hook path was refused and the hook may still be registered

2 participants