<!-- CURSOR_SUMMARY --> > [!NOTE] > **Medium Risk** > Touches authentication/login flow, credentials-file permissions, and subprocess lifecycle behavior across platforms; while mostly defensive, regressions could impact login or process management on Windows/macOS runners. > > **Overview** > Improves Windows/cross-platform reliability across the CLI and MCP server: OAuth login now binds the callback server to `127.0.0.1`, avoids slow loopback reverse-DNS, adds a configurable callback timeout (`--timeout` + env default), and opens URLs via a Windows-friendly `_open_browser` to avoid flashing console windows. > > Centralizes CLI output via a shared `console` that forces UTF-8 on Windows, standardizes UTF-8 file reads/writes throughout, tightens credentials-file permissions on Windows using `icacls`, and adds shared Windows subprocess helpers for **no-window** process creation and graceful termination (used by `deploy`, MCP reload, and usage-tracking worker). > > Updates client configuration UX/robustness (Windows AppData resolution via `platformdirs`, Cursor config path fallbacks + compatibility writes, overwrite warnings, absolute `uv` path for GUI clients, safer path display) and improves `deploy` child-process handling to avoid pipe-buffer deadlocks while giving better debug-aware error messages. > > Expands CI to run tests on Linux/Windows/macOS, adds a no-auth CLI integration workflow, disables usage tracking in toolkits CI, and adds extensive regression tests for Windows signals, subprocess cleanup, UTF-8, and config-path edge cases; bumps `arcade-core` to `4.4.2` and `arcade-mcp-server` to `1.17.2` (with updated dependency pin). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0fabd8ca1cd647039ba6ddbdf3f7809c330bab9e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
336 lines
13 KiB
Python
336 lines
13 KiB
Python
import subprocess
|
|
import sys
|
|
from unittest.mock import MagicMock, patch
|
|
from urllib.error import HTTPError
|
|
from urllib.request import urlopen
|
|
|
|
from arcade_cli.authn import OAuthCallbackServer, _open_browser, oauth_callback_server
|
|
|
|
|
|
def test_oauth_callback_server_success() -> None:
|
|
state = "test-state"
|
|
with oauth_callback_server(state, port=0) as server:
|
|
url = f"{server.get_redirect_uri()}?code=abc123&state={state}"
|
|
with urlopen(url) as response:
|
|
assert response.status == 200
|
|
response.read()
|
|
assert server.wait_for_result(timeout=1.0) is True
|
|
|
|
assert server.result["code"] == "abc123"
|
|
|
|
|
|
def test_oauth_callback_server_timeout() -> None:
|
|
state = "test-timeout"
|
|
with oauth_callback_server(state, port=0) as server:
|
|
assert server.wait_for_result(timeout=0.05) is False
|
|
|
|
assert "Timed out" in server.result["error"]
|
|
|
|
|
|
def test_oauth_callback_server_binds_to_loopback() -> None:
|
|
"""The callback server must bind to 127.0.0.1 (loopback) to avoid
|
|
Windows Firewall prompts and keep redirect host aligned with bind host."""
|
|
state = "test-bind"
|
|
with oauth_callback_server(state, port=0) as server:
|
|
assert server.httpd is not None
|
|
host, _port = server.httpd.server_address
|
|
assert host == "127.0.0.1", f"Expected 127.0.0.1 but got {host}"
|
|
# Also confirm the redirect URI host matches the bound loopback host.
|
|
redirect = server.get_redirect_uri()
|
|
assert redirect.startswith("http://127.0.0.1:")
|
|
server.shutdown_server()
|
|
|
|
|
|
def test_oauth_callback_server_state_mismatch() -> None:
|
|
"""Requests with a mismatched state parameter should return an error."""
|
|
state = "correct-state"
|
|
with oauth_callback_server(state, port=0) as server:
|
|
url = f"{server.get_redirect_uri()}?code=abc&state=wrong-state"
|
|
try:
|
|
with urlopen(url) as response:
|
|
response.read()
|
|
except HTTPError:
|
|
pass # Expected — handler returns 400 for state mismatch.
|
|
server.wait_for_result(timeout=1.0)
|
|
|
|
assert "error" in server.result
|
|
|
|
|
|
def test_oauth_callback_server_missing_code() -> None:
|
|
"""Requests without a code parameter should produce an error result."""
|
|
state = "no-code-state"
|
|
with oauth_callback_server(state, port=0) as server:
|
|
url = f"{server.get_redirect_uri()}?state={state}"
|
|
try:
|
|
with urlopen(url) as response:
|
|
response.read()
|
|
except HTTPError:
|
|
pass # Expected — handler returns 400 for missing code.
|
|
server.wait_for_result(timeout=1.0)
|
|
|
|
assert "error" in server.result
|
|
|
|
|
|
def test_oauth_callback_server_wait_until_ready() -> None:
|
|
"""wait_until_ready() should return True once the server is listening."""
|
|
state = "ready-test"
|
|
server = OAuthCallbackServer(state, port=0)
|
|
|
|
import threading
|
|
|
|
t = threading.Thread(target=server.run_server, daemon=True)
|
|
t.start()
|
|
|
|
assert server.wait_until_ready(timeout=5.0) is True
|
|
assert server.httpd is not None
|
|
assert server.port != 0 # Ephemeral port was assigned.
|
|
|
|
server.shutdown_server()
|
|
t.join(timeout=2)
|
|
|
|
|
|
def test_oauth_callback_server_wait_until_ready_timeout() -> None:
|
|
"""wait_until_ready() should return False if the server never starts."""
|
|
state = "ready-timeout"
|
|
server = OAuthCallbackServer(state, port=0)
|
|
# Don't start the server — ready_event never gets set.
|
|
assert server.wait_until_ready(timeout=0.05) is False
|
|
|
|
|
|
def test_perform_oauth_login_hides_auth_url_when_browser_succeeds() -> None:
|
|
"""When browser launch succeeds, status output should not include auth URL."""
|
|
status_messages: list[str] = []
|
|
|
|
def capture_status(msg: str) -> None:
|
|
status_messages.append(msg)
|
|
|
|
# We need to mock the entire OAuth flow since we can't hit a real coordinator.
|
|
with (
|
|
patch("arcade_cli.authn.fetch_cli_config") as mock_config,
|
|
patch("arcade_cli.authn.create_oauth_client"),
|
|
patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
|
|
patch("arcade_cli.authn._open_browser") as mock_browser,
|
|
patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
|
|
):
|
|
mock_config.return_value = MagicMock()
|
|
mock_gen_url.return_value = ("https://example.com/auth?state=abc", "verifier123")
|
|
mock_browser.return_value = True
|
|
|
|
mock_server = MagicMock()
|
|
mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
|
|
mock_server.result = {"error": "timeout for test"}
|
|
mock_server.wait_for_result.return_value = False
|
|
mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
|
|
mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)
|
|
|
|
from arcade_cli.authn import OAuthLoginError, perform_oauth_login
|
|
|
|
try:
|
|
perform_oauth_login(
|
|
"https://fake-coordinator.example.com",
|
|
on_status=capture_status,
|
|
callback_timeout_seconds=1,
|
|
)
|
|
except OAuthLoginError:
|
|
pass
|
|
|
|
url_messages = [m for m in status_messages if "https://example.com/auth" in m]
|
|
assert len(url_messages) == 0, (
|
|
"Auth URL should be hidden when browser launch succeeds. "
|
|
f"Got status messages: {status_messages}"
|
|
)
|
|
|
|
|
|
def test_perform_oauth_login_shows_url_when_browser_fails() -> None:
|
|
"""When _open_browser fails, the URL should still be shown."""
|
|
status_messages: list[str] = []
|
|
|
|
def capture_status(msg: str) -> None:
|
|
status_messages.append(msg)
|
|
|
|
with (
|
|
patch("arcade_cli.authn.fetch_cli_config") as mock_config,
|
|
patch("arcade_cli.authn.create_oauth_client"),
|
|
patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
|
|
patch("arcade_cli.authn._open_browser") as mock_browser,
|
|
patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
|
|
):
|
|
mock_config.return_value = MagicMock()
|
|
mock_gen_url.return_value = ("https://example.com/auth?state=xyz", "verifier456")
|
|
mock_browser.return_value = False # Browser failed
|
|
|
|
mock_server = MagicMock()
|
|
mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
|
|
mock_server.result = {"error": "timeout for test"}
|
|
mock_server.wait_for_result.return_value = False
|
|
mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
|
|
mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)
|
|
|
|
from arcade_cli.authn import OAuthLoginError, perform_oauth_login
|
|
|
|
try:
|
|
perform_oauth_login(
|
|
"https://fake-coordinator.example.com",
|
|
on_status=capture_status,
|
|
callback_timeout_seconds=1,
|
|
)
|
|
except OAuthLoginError:
|
|
pass
|
|
|
|
url_messages = [m for m in status_messages if "https://example.com/auth" in m]
|
|
assert len(url_messages) >= 1
|
|
assert any("Open this link to log in" in m for m in status_messages)
|
|
# When browser fails, the message should say "Could not open a browser"
|
|
browser_fail_msgs = [m for m in status_messages if "Could not open a browser" in m]
|
|
assert len(browser_fail_msgs) >= 1
|
|
|
|
|
|
def test_perform_oauth_login_timeout_clamps_negative() -> None:
|
|
"""Negative --timeout values should be clamped to the default."""
|
|
from arcade_cli.authn import DEFAULT_OAUTH_TIMEOUT_SECONDS
|
|
|
|
status_messages: list[str] = []
|
|
|
|
def capture_status(msg: str) -> None:
|
|
status_messages.append(msg)
|
|
|
|
with (
|
|
patch("arcade_cli.authn.fetch_cli_config") as mock_config,
|
|
patch("arcade_cli.authn.create_oauth_client"),
|
|
patch("arcade_cli.authn.generate_authorization_url") as mock_gen_url,
|
|
patch("arcade_cli.authn._open_browser") as mock_browser,
|
|
patch("arcade_cli.authn.oauth_callback_server") as mock_server_ctx,
|
|
):
|
|
mock_config.return_value = MagicMock()
|
|
mock_gen_url.return_value = ("https://example.com/auth", "v")
|
|
mock_browser.return_value = True
|
|
|
|
mock_server = MagicMock()
|
|
mock_server.get_redirect_uri.return_value = "http://localhost:9999/callback"
|
|
mock_server.result = {"error": "timeout"}
|
|
mock_server.wait_for_result.return_value = False
|
|
mock_server_ctx.return_value.__enter__ = MagicMock(return_value=mock_server)
|
|
mock_server_ctx.return_value.__exit__ = MagicMock(return_value=False)
|
|
|
|
from arcade_cli.authn import OAuthLoginError, perform_oauth_login
|
|
|
|
try:
|
|
perform_oauth_login(
|
|
"https://fake.example.com",
|
|
on_status=capture_status,
|
|
callback_timeout_seconds=-5,
|
|
)
|
|
except OAuthLoginError:
|
|
pass
|
|
|
|
# The timeout message should show the default, not -5.
|
|
timeout_msgs = [m for m in status_messages if "timeout:" in m.lower()]
|
|
assert any(str(DEFAULT_OAUTH_TIMEOUT_SECONDS) in m for m in timeout_msgs), (
|
|
f"Expected default timeout {DEFAULT_OAUTH_TIMEOUT_SECONDS} in messages: {timeout_msgs}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _open_browser() — CMD-window suppression on Windows
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestOpenBrowser:
|
|
"""Tests for the _open_browser helper that suppresses CMD flash on Windows.
|
|
|
|
On Windows the priority order is:
|
|
1. ctypes ShellExecuteW (direct Win32 API, no console)
|
|
2. rundll32 url.dll (GUI binary, no console)
|
|
3. webbrowser.open (stdlib fallback)
|
|
|
|
os.startfile is intentionally omitted: it is a thin CPython wrapper around
|
|
ShellExecuteExW, making it redundant with step 1.
|
|
"""
|
|
|
|
def test_delegates_to_webbrowser_on_non_windows(self) -> None:
|
|
"""On non-Windows, _open_browser should use webbrowser.open."""
|
|
with (
|
|
patch.object(sys, "platform", "linux"),
|
|
patch("arcade_cli.authn.webbrowser") as mock_wb,
|
|
):
|
|
mock_wb.open.return_value = True
|
|
result = _open_browser("https://example.com")
|
|
assert result is True
|
|
mock_wb.open.assert_called_once_with("https://example.com")
|
|
|
|
def test_tries_ctypes_shellexecute_first_on_windows(self) -> None:
|
|
"""On Windows, _open_browser should try ctypes ShellExecuteW first."""
|
|
import ctypes
|
|
|
|
# On non-Windows, ctypes.windll doesn't exist; provide a mock
|
|
mock_shell32 = MagicMock()
|
|
mock_shell32.ShellExecuteW = MagicMock(return_value=42)
|
|
mock_windll = MagicMock()
|
|
mock_windll.shell32 = mock_shell32
|
|
|
|
with patch.object(sys, "platform", "win32"), patch.object(
|
|
ctypes, "windll", mock_windll, create=True
|
|
):
|
|
result = _open_browser("https://example.com")
|
|
assert result is True
|
|
|
|
def test_falls_back_to_rundll32_on_windows(self) -> None:
|
|
"""If ctypes fails, try rundll32 url.dll."""
|
|
import ctypes
|
|
|
|
mock_shell32 = MagicMock()
|
|
mock_shell32.ShellExecuteW = MagicMock(side_effect=Exception("ctypes failed"))
|
|
mock_windll = MagicMock()
|
|
mock_windll.shell32 = mock_shell32
|
|
|
|
with (
|
|
patch.object(sys, "platform", "win32"),
|
|
patch.object(ctypes, "windll", mock_windll, create=True),
|
|
patch("arcade_cli.authn.subprocess.Popen") as mock_popen,
|
|
patch("arcade_cli.authn.subprocess.STARTUPINFO", create=True) as mock_si_cls,
|
|
patch("arcade_cli.authn.subprocess.STARTF_USESHOWWINDOW", 1, create=True),
|
|
patch("arcade_cli.authn.subprocess.DEVNULL", subprocess.DEVNULL),
|
|
):
|
|
mock_si = MagicMock()
|
|
mock_si.dwFlags = 0
|
|
mock_si_cls.return_value = mock_si
|
|
mock_popen.return_value = MagicMock()
|
|
|
|
result = _open_browser("https://example.com")
|
|
assert result is True
|
|
mock_popen.assert_called_once()
|
|
cmd = mock_popen.call_args[0][0]
|
|
assert cmd[0] == "rundll32"
|
|
|
|
def test_falls_back_to_webbrowser_if_all_else_fails_on_windows(self) -> None:
|
|
"""If ctypes and rundll32 both fail, use webbrowser.open (step 3)."""
|
|
import ctypes
|
|
|
|
mock_shell32 = MagicMock()
|
|
mock_shell32.ShellExecuteW = MagicMock(side_effect=Exception("ctypes failed"))
|
|
mock_windll = MagicMock()
|
|
mock_windll.shell32 = mock_shell32
|
|
|
|
with (
|
|
patch.object(sys, "platform", "win32"),
|
|
patch.object(ctypes, "windll", mock_windll, create=True),
|
|
patch("arcade_cli.authn.subprocess.Popen", side_effect=Exception("fail")),
|
|
patch("arcade_cli.authn.subprocess.STARTUPINFO", create=True, return_value=MagicMock()),
|
|
patch("arcade_cli.authn.subprocess.STARTF_USESHOWWINDOW", 1, create=True),
|
|
patch("arcade_cli.authn.subprocess.DEVNULL", -1),
|
|
patch("arcade_cli.authn.webbrowser") as mock_wb,
|
|
):
|
|
mock_wb.open.return_value = True
|
|
result = _open_browser("https://example.com")
|
|
assert result is True
|
|
mock_wb.open.assert_called_once()
|
|
|
|
def test_returns_false_if_everything_fails(self) -> None:
|
|
"""If all methods fail, _open_browser should return False."""
|
|
with (
|
|
patch.object(sys, "platform", "linux"),
|
|
patch("arcade_cli.authn.webbrowser") as mock_wb,
|
|
):
|
|
mock_wb.open.side_effect = Exception("no browser")
|
|
result = _open_browser("https://example.com")
|
|
assert result is False
|