Skip to content

Commit b27dc9c

Browse files
authored
Merge pull request #43 from jacob-bd/release-hardening
fix: harden Windows daemon and setup failure paths
2 parents 5ab298b + f9f8781 commit b27dc9c

8 files changed

Lines changed: 101 additions & 32 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ pwm serve-mcp --transport streamable-http # Start HTTP MCP on 127.0.0.1:8000/mc
278278
pwm serve-mcp --status # Check daemon status
279279
pwm serve-mcp --stop # Stop the daemon
280280
pwm setup add codex --http # Configure Codex for Streamable HTTP
281+
pwm serve-mcp --transport sse # Start the legacy SSE transport
281282
```
282283

283284
The HTTP transports bind to loopback by default. Keep the daemon on a loopback

skills/perplexity-web-mcp/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ pwm serve-mcp --transport streamable-http # HTTP MCP on 127.0.0.1:8000/mcp
283283
pwm serve-mcp --status # Check the daemon
284284
pwm serve-mcp --stop # Stop the daemon
285285
pwm setup add codex --http # Configure Codex for Streamable HTTP
286+
pwm serve-mcp --transport sse # Start the legacy SSE transport
286287
```
287288

288289
The HTTP transports bind to loopback by default. Keep the daemon on a loopback

src/perplexity_web_mcp/cli/setup.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,9 @@ def _setup_codex(streamable_http: bool = False, port: int = 8000) -> bool:
258258
console.print(f"[yellow]Warning:[/yellow] Could not read Codex config: {exc}")
259259
return False
260260
mcp_servers = config.get("mcp_servers", {})
261+
if not isinstance(mcp_servers, dict):
262+
console.print("[yellow]Warning:[/yellow] Codex mcp_servers configuration is not a table.")
263+
return False
261264
existing_key = next(
262265
(key for key in (MCP_SERVER_KEY, "perplexity-web-mcp") if key in mcp_servers),
263266
None,
@@ -891,6 +894,9 @@ def setup_add(client, streamable_http, port):
891894
else:
892895
success = _setup_json_client(client)
893896

897+
if client == "codex" and not success:
898+
raise click.ClickException("Could not configure Codex MCP server.")
899+
894900
if success:
895901
console.print(f"\n[dim]Restart {info['name']} to activate the MCP server.[/dim]")
896902

src/perplexity_web_mcp/data/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ pwm serve-mcp --transport streamable-http # HTTP MCP on 127.0.0.1:8000/mcp
283283
pwm serve-mcp --status # Check the daemon
284284
pwm serve-mcp --stop # Stop the daemon
285285
pwm setup add codex --http # Configure Codex for Streamable HTTP
286+
pwm serve-mcp --transport sse # Start the legacy SSE transport
286287
```
287288

288289
The HTTP transports bind to loopback by default. Keep the daemon on a loopback

src/perplexity_web_mcp/mcp/server.py

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -784,40 +784,38 @@ def get_daemon_pid_path(port: int) -> Path:
784784

785785

786786
def _is_windows_pid_running(pid: int) -> bool:
787-
"""Check process state without sending a signal on Windows.
788-
789-
Indeterminate results are treated as running so a valid daemon lock is not
790-
discarded merely because the process cannot be inspected.
791-
"""
792-
import ctypes
793-
from ctypes import wintypes
794-
795-
synchronize = 0x00100000
796-
error_invalid_parameter = 87
797-
wait_object_0 = 0x00000000
798-
wait_timeout = 0x00000102
799-
800-
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
801-
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
802-
kernel32.OpenProcess.restype = wintypes.HANDLE
803-
kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
804-
kernel32.WaitForSingleObject.restype = wintypes.DWORD
805-
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
806-
kernel32.CloseHandle.restype = wintypes.BOOL
807-
808-
handle = kernel32.OpenProcess(synchronize, False, pid)
809-
if not handle:
810-
return ctypes.get_last_error() != error_invalid_parameter
811-
812787
try:
813-
wait_result = kernel32.WaitForSingleObject(handle, 0)
814-
if wait_result == wait_object_0:
815-
return False
816-
if wait_result == wait_timeout:
788+
import ctypes
789+
from ctypes import wintypes
790+
791+
synchronize = 0x00100000
792+
error_invalid_parameter = 87
793+
wait_object_0 = 0x00000000
794+
wait_timeout = 0x00000102
795+
796+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
797+
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
798+
kernel32.OpenProcess.restype = wintypes.HANDLE
799+
kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
800+
kernel32.WaitForSingleObject.restype = wintypes.DWORD
801+
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
802+
kernel32.CloseHandle.restype = wintypes.BOOL
803+
804+
handle = kernel32.OpenProcess(synchronize, False, pid)
805+
if not handle:
806+
return ctypes.get_last_error() != error_invalid_parameter
807+
808+
try:
809+
wait_result = kernel32.WaitForSingleObject(handle, 0)
810+
if wait_result == wait_object_0:
811+
return False
812+
if wait_result == wait_timeout:
813+
return True
817814
return True
815+
finally:
816+
kernel32.CloseHandle(handle)
817+
except Exception:
818818
return True
819-
finally:
820-
kernel32.CloseHandle(handle)
821819

822820

823821
def is_pid_running(pid: int) -> bool:
@@ -852,11 +850,23 @@ def get_running_daemon_pid(port: int) -> int | None:
852850
return None
853851
try:
854852
pid = int(pid_path.read_text().strip())
853+
except (OSError, ValueError):
854+
try:
855+
pid_path.unlink(missing_ok=True)
856+
except OSError:
857+
pass
858+
return None
859+
860+
try:
855861
if is_pid_running(pid):
856862
return pid
857-
pid_path.unlink(missing_ok=True)
858863
except Exception:
864+
return pid
865+
866+
try:
859867
pid_path.unlink(missing_ok=True)
868+
except OSError:
869+
pass
860870
return None
861871

862872

tests/test_mcp_server.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,25 @@ def test_windows_live_pid_file_is_retained_and_blocks_duplicate(tmp_path: Path,
178178
probe.assert_called_once_with(4242)
179179

180180

181+
def test_windows_pid_probe_treats_unexpected_api_error_as_running() -> None:
182+
with (
183+
patch.object(server.sys, "platform", "win32"),
184+
patch.object(ctypes, "WinDLL", side_effect=OSError("probe unavailable"), create=True),
185+
):
186+
assert server.is_pid_running(4242) is True
187+
188+
189+
def test_windows_pid_probe_error_preserves_pid_file(tmp_path: Path, monkeypatch) -> None:
190+
monkeypatch.setattr(server, "CONFIG_DIR", tmp_path)
191+
pid_path = server.get_daemon_pid_path(8994)
192+
pid_path.write_text("4242", encoding="utf-8")
193+
194+
with patch.object(server, "is_pid_running", side_effect=RuntimeError("probe unavailable")):
195+
assert server.get_running_daemon_pid(8994) == 4242
196+
197+
assert pid_path.exists()
198+
199+
181200
@pytest.mark.skipif(sys.platform != "win32", reason="requires Windows process APIs")
182201
def test_windows_pid_probe_preserves_live_child() -> None:
183202
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])

tests/test_setup.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,12 @@ def test_add_codex_sse_remains_a_legacy_alias(self) -> None:
258258
assert result.exit_code == 0
259259
mock_setup.assert_called_once_with(streamable_http=True, port=8000)
260260

261+
def test_add_codex_http_propagates_setup_failure(self) -> None:
262+
with patch("perplexity_web_mcp.cli.setup._setup_codex", return_value=False):
263+
result = self._run("add", "codex", "--http")
264+
265+
assert result.exit_code == 1
266+
261267
def test_remove_codex_removes_mcp(self) -> None:
262268
with patch("perplexity_web_mcp.cli.setup._remove_codex", return_value=True) as mock_remove:
263269
result = self._run("remove", "codex")
@@ -514,6 +520,22 @@ def test_setup_codex_http_refuses_disabled_matching_config(tmp_path: Path) -> No
514520
assert config_path.read_text() == original
515521

516522

523+
def test_setup_codex_http_refuses_non_table_mcp_servers(tmp_path: Path) -> None:
524+
from perplexity_web_mcp.cli.setup import _setup_codex
525+
526+
config_path = tmp_path / "config.toml"
527+
original = "mcp_servers = []\n"
528+
config_path.write_text(original)
529+
530+
with (
531+
patch("perplexity_web_mcp.cli.setup._codex_config_path", return_value=tmp_path),
532+
patch("perplexity_web_mcp.cli.setup.shutil.which", return_value=None),
533+
):
534+
assert _setup_codex(streamable_http=True) is False
535+
536+
assert config_path.read_text() == original
537+
538+
517539
def test_setup_codex_http_writes_mcp_endpoint_without_codex_cli(tmp_path: Path) -> None:
518540
from perplexity_web_mcp.cli.setup import _setup_codex
519541

tests/test_skill.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,15 @@ def test_uninstall_removes_directory(self, skill_source: Path, dest_dir: Path) -
289289
assert result is True
290290
assert not installed.exists()
291291

292+
def test_uninstall_routes_through_readonly_safe_removal(self, skill_source: Path, dest_dir: Path) -> None:
293+
_install_skill(skill_source, dest_dir)
294+
installed = dest_dir / SKILL_DIR_NAME
295+
296+
with patch("perplexity_web_mcp.cli.skill._remove_tree") as remove_tree:
297+
assert _uninstall_skill(dest_dir) is True
298+
299+
remove_tree.assert_called_once_with(installed)
300+
292301
def test_uninstall_removes_readonly_tree(self, skill_source: Path, dest_dir: Path) -> None:
293302
_install_skill(skill_source, dest_dir)
294303
installed = dest_dir / SKILL_DIR_NAME

0 commit comments

Comments
 (0)