diff --git a/Resources/bin/cmux-claude-wrapper b/Resources/bin/cmux-claude-wrapper index 6eb25c72b8b3..0ca66ca1f272 100755 --- a/Resources/bin/cmux-claude-wrapper +++ b/Resources/bin/cmux-claude-wrapper @@ -548,6 +548,13 @@ reconcile_claude_config_dir_for_resume() { done } +reconcile_claude_config_dir_for_resume_unless_preserved() { + should_preserve_claude_auth_selection_key "CLAUDE_CONFIG_DIR" && return 0 + local resume_session_id + resume_session_id="$(extract_claude_resume_session_id "$@" || true)" + reconcile_claude_config_dir_for_resume "$resume_session_id" +} + clear_inherited_claude_auth_selection_env() { if [[ "${IN_CMUX:-0}" == "1" ]]; then local key @@ -585,7 +592,7 @@ if [[ "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]]; then fi clear_inherited_claude_auth_selection_env if [[ "$IN_CMUX" == "1" ]]; then - reconcile_claude_config_dir_for_resume "$(extract_claude_resume_session_id "$@")" + reconcile_claude_config_dir_for_resume_unless_preserved "$@" fi REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; } cmux_claude_wrapper_exec_resolved_claude "$REAL_CLAUDE" "$@" @@ -599,7 +606,7 @@ if [[ "$IN_CMUX" == "0" ]] || ! cmux_socket_available; then fi clear_inherited_claude_auth_selection_env if [[ "$IN_CMUX" == "1" ]]; then - reconcile_claude_config_dir_for_resume "$(extract_claude_resume_session_id "$@")" + reconcile_claude_config_dir_for_resume_unless_preserved "$@" fi REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; } exec_real_claude_passthrough "$@" @@ -851,7 +858,9 @@ fi # For an explicit `--resume `, point CLAUDE_CONFIG_DIR at the config root that # actually holds the transcript so an inherited/foreign config dir cannot turn the # resume into "No conversation found". https://github.com/manaflow-ai/cmux/issues/6194 -reconcile_claude_config_dir_for_resume "$(extract_claude_resume_session_id "$@")" +# Account managers such as `sr claude` can intentionally pin CLAUDE_CONFIG_DIR to +# a different auth/profile root and ask cmux to preserve it. +reconcile_claude_config_dir_for_resume_unless_preserved "$@" # Export the wrapper's PID. Because we exec claude below, this PID becomes # the actual claude process PID, which hooks use for stale-session detection. diff --git a/tests/test_claude_wrapper_hooks.py b/tests/test_claude_wrapper_hooks.py index 7a74156a1aa7..bd760d9b8cf6 100644 --- a/tests/test_claude_wrapper_hooks.py +++ b/tests/test_claude_wrapper_hooks.py @@ -1410,6 +1410,45 @@ def test_live_socket_preserves_claude_auth_for_resume_launch(failures: list[str] expect("--session-id" not in real_argv, f"resume auth env: expected no injected session id, got {real_argv}", failures) +def test_preserved_claude_config_dir_skips_resume_self_heal(failures: list[str]) -> None: + session_id = "582054f9-2f87-4e90-ad8d-2f532ed8c61b" + + for socket_state in ("live", "stale"): + expected: dict[str, str] = {} + + def setup_env(tmp: Path) -> dict[str, str]: + home = tmp / "home" + default_root = home / ".claude" + (default_root / "projects" / "-work").mkdir(parents=True) + (default_root / "projects" / "-work" / f"{session_id}.jsonl").write_text( + "{}\n", encoding="utf-8" + ) + selected_root = home / ".subrouter" / "codex" / "claude" / "aziz-claude-1" + (selected_root / "projects").mkdir(parents=True) + expected["path"] = str(selected_root) + return { + "HOME": str(home), + "CLAUDE_CONFIG_DIR": str(selected_root), + "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV": "1", + "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS": "CLAUDE_CONFIG_DIR", + } + + code, auth_env, real_argv, stderr = run_wrapper_auth_env( + argv=["--resume", session_id, "--fork-session"], + inherited_env={}, + socket_state=socket_state, + setup_env=setup_env, + ) + expect(code == 0, f"preserved resume config dir {socket_state}: wrapper exited {code}: {stderr}", failures) + expect( + auth_env.get("CLAUDE_CONFIG_DIR") == expected["path"], + f"preserved resume config dir {socket_state}: expected CLAUDE_CONFIG_DIR to remain on the selected profile root " + f"{expected['path']!r}, got {auth_env.get('CLAUDE_CONFIG_DIR')!r}", + failures, + ) + expect(real_argv[-3:] == ["--resume", session_id, "--fork-session"], f"preserved resume config dir {socket_state}: expected resume+fork argv, got {real_argv}", failures) + + def test_live_socket_preserves_only_listed_claude_auth_keys(failures: list[str]) -> None: inherited = { "CLAUDE_CONFIG_DIR": "/tmp/claude-config", @@ -1883,6 +1922,7 @@ def main() -> int: test_live_socket_resume_keeps_correct_claude_config_dir(failures) test_live_socket_resume_self_heal_ignores_prompt_text_after_double_dash(failures) test_live_socket_preserves_claude_auth_for_resume_launch(failures) + test_preserved_claude_config_dir_skips_resume_self_heal(failures) test_live_socket_preserves_only_listed_claude_auth_keys(failures) test_live_socket_auto_preserves_vertex_auth_when_truthy(failures) test_live_socket_auto_preserves_bedrock_auth_when_truthy(failures)