Merge pull request 'fix: harden restart_claude_desktop — Linux refusal, macOS quit-wait, Windows MSIX guard (#33)' (#43) from fix/33-restart-hardening into main
This commit was merged in pull request #43.
This commit is contained in:
+74
-40
@@ -970,11 +970,13 @@ class _FakeCompletedProcess:
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_commands(monkeypatch):
|
||||
"""macOS: pkill -x "Claude" then open -a Claude, in that order."""
|
||||
"""macOS: pkill -x "Claude", wait for exit (pgrep), then open -a Claude."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
if cmd[0] == "pgrep":
|
||||
return _FakeCompletedProcess(returncode=1) # already exited
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
@@ -982,14 +984,14 @@ def test_restart_claude_desktop_macos_commands(monkeypatch):
|
||||
result = c.restart_claude_desktop()
|
||||
assert result.success
|
||||
assert calls[0] == ["pkill", "-x", "Claude"]
|
||||
assert calls[1] == ["open", "-a", "Claude"]
|
||||
assert calls[-1] == ["open", "-a", "Claude"]
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_pkill_not_running_is_fine(monkeypatch):
|
||||
"""pkill exiting non-zero (nothing to kill) must NOT be treated as failure."""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "pkill":
|
||||
if cmd[0] in ("pkill", "pgrep"):
|
||||
return _FakeCompletedProcess(returncode=1) # no matching process
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
@@ -1013,12 +1015,12 @@ def test_restart_claude_desktop_macos_relaunch_failure_reported(monkeypatch):
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkeypatch):
|
||||
"""pkill raising OSError (binary missing) must be swallowed, not propagated --
|
||||
relaunch is still attempted."""
|
||||
"""pkill/pgrep raising OSError (binary missing) must be swallowed, not
|
||||
propagated -- relaunch is still attempted."""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "pkill":
|
||||
raise OSError("pkill not found")
|
||||
if cmd[0] in ("pkill", "pgrep"):
|
||||
raise OSError("binary not found")
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
@@ -1027,7 +1029,45 @@ def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkey
|
||||
assert result.success
|
||||
|
||||
|
||||
def test_restart_claude_desktop_windows_commands(monkeypatch):
|
||||
def test_restart_claude_desktop_macos_gives_up_if_app_wont_quit(monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(c, "_run_quiet", lambda cmd: None)
|
||||
monkeypatch.setattr(c, "_macos_claude_running", lambda: True) # never exits
|
||||
monkeypatch.setattr(c, "_MACOS_QUIT_WAIT_S", 0.2)
|
||||
res = c.restart_claude_desktop()
|
||||
assert res.success is False
|
||||
assert "quit" in res.detail.lower()
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_waits_for_exit_then_relaunches(monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(c, "_run_quiet", lambda cmd: None)
|
||||
alive = iter([True, True, False]) # exits on the third poll
|
||||
monkeypatch.setattr(c, "_macos_claude_running", lambda: next(alive))
|
||||
|
||||
launched = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
launched.append(cmd)
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
res = c.restart_claude_desktop()
|
||||
assert res.success is True
|
||||
assert launched == [["open", "-a", "Claude"]]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def windows_shortcut(tmp_path, monkeypatch):
|
||||
"""A fake %APPDATA% containing the Claude Start-menu shortcut."""
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path))
|
||||
lnk = tmp_path / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk"
|
||||
lnk.parent.mkdir(parents=True)
|
||||
lnk.write_bytes(b"")
|
||||
return lnk
|
||||
|
||||
|
||||
def test_restart_claude_desktop_windows_commands(monkeypatch, windows_shortcut):
|
||||
"""Windows: taskkill the process, then relaunch via the Start-menu shortcut."""
|
||||
calls = []
|
||||
|
||||
@@ -1044,7 +1084,7 @@ def test_restart_claude_desktop_windows_commands(monkeypatch):
|
||||
assert calls[1][-1].endswith("Claude.lnk")
|
||||
|
||||
|
||||
def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch):
|
||||
def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch, windows_shortcut):
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "cmd":
|
||||
return _FakeCompletedProcess(returncode=1, stderr="not found")
|
||||
@@ -1057,43 +1097,37 @@ def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch):
|
||||
assert "not found" in result.detail
|
||||
|
||||
|
||||
def test_restart_claude_desktop_linux_commands(monkeypatch):
|
||||
"""Linux: pkill claude, then relaunch via a detached Popen (no waiting)."""
|
||||
run_calls = []
|
||||
popen_calls = []
|
||||
def test_restart_claude_desktop_windows_missing_shortcut_refuses_before_killing(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
killed = []
|
||||
monkeypatch.setattr(c, "_run_quiet", lambda cmd: killed.append(cmd))
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path)) # no Claude.lnk under here (MSIX case)
|
||||
res = c.restart_claude_desktop()
|
||||
assert res.success is False
|
||||
assert "shortcut" in res.detail.lower()
|
||||
assert killed == [] # Claude must NOT be killed when it can't be relaunched
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
class _FakePopen:
|
||||
def __init__(self, cmd, **kwargs):
|
||||
popen_calls.append((cmd, kwargs))
|
||||
|
||||
def test_restart_supported_only_on_desktop_platforms(monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
assert c.restart_supported()
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
assert c.restart_supported()
|
||||
monkeypatch.setattr(c.sys, "platform", "linux")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(c.subprocess, "Popen", _FakePopen)
|
||||
result = c.restart_claude_desktop()
|
||||
assert result.success
|
||||
assert run_calls == [["pkill", "claude"]]
|
||||
cmd, kwargs = popen_calls[0]
|
||||
assert cmd == ["claude"]
|
||||
assert kwargs.get("start_new_session") is True
|
||||
assert not c.restart_supported()
|
||||
|
||||
|
||||
def test_restart_claude_desktop_linux_popen_failure_reported(monkeypatch):
|
||||
def fake_run(cmd, **kwargs):
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
raise OSError("no such file or directory")
|
||||
|
||||
def test_restart_claude_desktop_linux_refuses_without_touching_processes(monkeypatch):
|
||||
"""There is no Claude Desktop on Linux; 'pkill claude' would substring-match
|
||||
running Claude Code CLI sessions. The restart must refuse outright."""
|
||||
killed = []
|
||||
monkeypatch.setattr(c, "_run_quiet", lambda cmd: killed.append(cmd))
|
||||
monkeypatch.setattr(c.sys, "platform", "linux")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(c.subprocess, "Popen", fake_popen)
|
||||
result = c.restart_claude_desktop()
|
||||
assert not result.success
|
||||
assert "Claude Desktop" in result.detail
|
||||
res = c.restart_claude_desktop()
|
||||
assert res.success is False
|
||||
assert killed == [] # nothing killed, nothing spawned
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user