diff --git a/simdrive/CHANGELOG.md b/simdrive/CHANGELOG.md index 88bc8ce1..18c4f70d 100644 --- a/simdrive/CHANGELOG.md +++ b/simdrive/CHANGELOG.md @@ -13,6 +13,37 @@ filter catch what slips through. --> +## [1.0.0b11] — 2026-06-23 + +### Fixed — MCP auto-restart no longer breaks the stdio session + +- **Auto-restart is disabled while serving as an MCP server.** When the running + server detected it was older than the wheel on disk, it used to re-exec itself + to pick up the new code. Under an MCP stdio client (Claude Code, etc.) that + re-exec desynced the transport — every following tool call failed with + `MCP error -32602: Invalid request parameters`, unrecoverable without a `/mcp` + reconnect. simdrive now detects MCP-server mode and **does not restart**; + instead it returns a clear, actionable warning telling you to reconnect your + MCP client (or restart the session) to load the new version. The safe + self-restart behaviour is preserved for non-MCP (embedded/CLI) callers. +- **`SIMDRIVE_NO_AUTO_RESTART` is now documented** (README + the drift warning + text): set it for MCP-driver sessions to suppress the version-drift + auto-restart everywhere. + +### Improved — clearer diagnostics at the host-AX boundary + +- **`perform_accessibility_action`** now hints at the cause when *zero* custom + actions are found anywhere in the target window: if you're driving a + Readium/WKWebView reader, web-content accessibility is not bridged to host-AX + on the simulator — use the on-device XCTest backend for web-content a11y. The + hint is only added when the window genuinely has no custom actions (not when a + specific named action is simply absent). +- **`set_text`** now documents its backend boundary: it commits **UIKit** fields + (including `UIAlertController` prompts), but does **not** reliably commit a + SwiftUI `SecureField`/`TextField` bound to `@State` — the field may display the + text while `@State` stays empty. For SwiftUI `@State`-bound fields, tap the + field and use `type_text` / HID keystrokes instead. + ## [1.0.0b10] — 2026-06-22 ### Added — text entry into alert fields + accessibility journeys diff --git a/simdrive/README.md b/simdrive/README.md index 66296a26..22fefd00 100644 --- a/simdrive/README.md +++ b/simdrive/README.md @@ -154,6 +154,13 @@ session_start(bundle_id="com.example.app", udid="", target="device" WDA bootstrap on iOS 26.x has some rough edges; the simulator (`target="simulator"`, default) is the fully supported path. +## Environment variables + +| Variable | Effect | +| --- | --- | +| `SIMDRIVE_ALLOW_PHYSICAL_DEVICE=1` | Allow driving a paired physical iPhone/iPad (see above). | +| `SIMDRIVE_NO_AUTO_RESTART=1` | Suppress the version-drift auto-restart. When the running server is older than the wheel on disk (after `pip install -U simdrive`), simdrive normally re-execs itself to pick up the new code. **Set this for MCP-driver sessions** (Claude Code, etc.): an auto-restart re-execs the process and desyncs the MCP stdio transport, after which every tool call fails `MCP error -32602: Invalid request parameters` until you reconnect (`/mcp`). When simdrive detects it is serving as an MCP stdio server it now suppresses the auto-restart automatically and tells you to reconnect; this env var makes that the default everywhere (incl. embedded/CLI contexts). Truthy values: `1`, `true`, `yes`, `on`. | + ## Known limitations See `docs/LIMITATIONS.md` for: `type_text` first-character drop workaround, diff --git a/simdrive/pyproject.toml b/simdrive/pyproject.toml index 53e274fe..93c9021a 100644 --- a/simdrive/pyproject.toml +++ b/simdrive/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "simdrive" -version = "1.0.0b10" +version = "1.0.0b11" description = "SimDrive — reproduce and validate iOS bugs in 60 seconds with Claude. MCP-native iOS automation; 32 vision-first tools; record once, replay free in CI. 14-day trial, then $29/mo Pro." readme = "README.md" license = "Elastic-2.0" diff --git a/simdrive/src/simdrive/ax.py b/simdrive/src/simdrive/ax.py index 9e7812f9..f51b2dbc 100644 --- a/simdrive/src/simdrive/ax.py +++ b/simdrive/src/simdrive/ax.py @@ -277,6 +277,38 @@ def _find_action_carrier(root: Any, action_label: str, depth: int = 0, maxdepth: return None +def _window_has_any_custom_action(root: Any, depth: int = 0, maxdepth: int = 60) -> bool: + """True if *any* element in the subtree carries at least one custom action. + + Used to distinguish two failure modes when a named action isn't found: + - The window has custom actions, just not the one requested (the caller + likely mistyped the label or it's on a different screen). + - The window has ZERO custom actions anywhere — the strong signal that + the a11y tree isn't bridged to host-AX at all (e.g. a Readium / + WKWebView reader, whose web-content accessibility never reaches the + macOS AXUIElement tree on the simulator). + """ + if depth > maxdepth: + return False + for name in _action_names(root): + if _custom_action_label(name) is not None: + return True + for child in _children(root): + if _window_has_any_custom_action(child, depth + 1, maxdepth): + return True + return False + + +# b11 FIX 2 — appended to the "not found" error when the target window has +# ZERO custom actions anywhere (the WKWebView/Readium host-AX boundary). +_WKWEBVIEW_HINT = ( + " No custom actions found anywhere in the target window — if this is a " + "Readium/WKWebView reader, web-content accessibility is NOT bridged to " + "host-AX on the simulator; use the on-device XCTest backend (runner/) for " + "web-content a11y." +) + + def perform_action( device_name: str, name: str, @@ -317,9 +349,15 @@ def perform_action( carrier = _find_action_carrier(window, name) if carrier is None: + error = f"custom action {name!r} not found in the target window" + # b11 FIX 2: if the window has NO custom actions at all (not merely a + # missing named one), hint at the WKWebView/Readium host-AX boundary. + # Keep it accurate — only when zero actions exist window-wide. + if not _window_has_any_custom_action(window): + error += _WKWEBVIEW_HINT return { "ok": False, - "error": f"custom action {name!r} not found in the target window", + "error": error, } matched = next( @@ -367,6 +405,14 @@ def set_text( *label*, else the first editable field (text field / secure field / text area) in the window — which is the alert's field when a prompt is up. + Backend boundary (b11): this works for **UIKit** fields, including + `UIAlertController` modal-alert text fields. It does **NOT** reliably commit + a **SwiftUI** `SecureField`/`TextField` whose text is bound to `@State`: + writing `AXValue` can make the field *display* the text while the `@State` + binding stays empty, so the app never observes the input. For SwiftUI + `@State`-bound fields, tap the field and drive it with `type_text` / + HID keystrokes instead. + Returns ``{"ok": True, "value": text}`` or ``{"ok": False, "error": ...}``. """ from ApplicationServices import AXUIElementSetAttributeValue # type: ignore[import] diff --git a/simdrive/src/simdrive/server.py b/simdrive/src/simdrive/server.py index 2447dc10..f323b2be 100644 --- a/simdrive/src/simdrive/server.py +++ b/simdrive/src/simdrive/server.py @@ -297,6 +297,30 @@ def _disk_version() -> str | None: _RESTART_LOOP_GUARD_MAX: int = 3 # halt auto-restart after this many consecutive restarts _RESTART_COUNT_ENV: str = "SIMDRIVE_RESTART_COUNT" # carried across execv to detect loops +# b11 FIX 1 — auto-restart is UNSAFE while we are serving as an MCP stdio +# server. os.execv keeps the same PID and fds, but the MCP client on the +# other end of the stdio pipe holds an *initialized session*; replacing the +# process tears down that session mid-stream, so every subsequent JSON-RPC +# request the client sends fails `MCP error -32602: Invalid request +# parameters` until the user reconnects (/mcp). That's strictly worse than the +# drift it was trying to cure. We therefore gate auto-restart on this flag: +# it is flipped True only inside `_serve_async` (the one and only code path +# that owns the stdio transport). Direct/sync callers (CLI smokes, pytest, +# `call_tool` from a non-stdio embedder) leave it False, so the safe +# self-restart behaviour is preserved for them. +_MCP_SERVER_MODE: bool = False + + +def _in_mcp_server_mode() -> bool: + """True when this process is actively serving the MCP stdio transport. + + Set by ``_serve_async`` before it hands stdin/stdout to the MCP server. + When True, auto-restart (os.execv) MUST be suppressed: re-execing the + process desyncs the client's initialized stdio session and yields an + unrecoverable ``-32602`` on every subsequent tool call. + """ + return _MCP_SERVER_MODE + def _is_upgrade(disk: str | None, loaded: str | None) -> bool: """Return True only when ``disk`` parses strictly greater than ``loaded``. @@ -324,6 +348,8 @@ def _check_version_drift() -> str | None: sees the issue on the very next tool call after `pip install --upgrade`. The warning text is context-aware: + - In MCP-SERVER MODE → never auto-restart (it desyncs the stdio + transport, -32602); tell the operator to reconnect /mcp. - On UPGRADE with auto-restart enabled → "auto-restarting" language. - On UPGRADE with auto-restart disabled → "restart manually" language. - On DOWNGRADE → never auto-restart; warning notes the version skew. @@ -334,15 +360,31 @@ def _check_version_drift() -> str | None: if disk == _LOADED_VERSION: return None if _is_upgrade(disk, _LOADED_VERSION): + # b11 FIX 1: while serving as an MCP stdio server, auto-restart is + # disabled unconditionally — re-execing the process desyncs the + # client's stdio session and makes every later tool call fail + # -32602 with no recovery short of a client /mcp reconnect. + if _in_mcp_server_mode(): + return ( + f"simdrive version drift: loaded {_LOADED_VERSION}, disk {disk}. " + "Auto-restart is disabled in MCP-server mode because it desyncs " + "the stdio transport (MCP error -32602: Invalid request parameters). " + f"Reconnect your MCP client (/mcp) or restart the session to load {disk}. " + "Set SIMDRIVE_NO_AUTO_RESTART=1 to make this the default everywhere." + ) if _auto_restart_disabled(): return ( f"Loaded simdrive {_LOADED_VERSION} but disk version is {disk}. " - f"Restart manually to pick up version {disk} (auto-restart is disabled)." + f"Restart manually to pick up version {disk} (auto-restart is disabled). " + "SIMDRIVE_NO_AUTO_RESTART suppresses the version-drift auto-restart; " + "set it for MCP-driver sessions." ) return ( f"Loaded simdrive {_LOADED_VERSION} but disk version is {disk}. " f"Auto-restarting to pick up disk version {disk}. " - "Set SIMDRIVE_NO_AUTO_RESTART=1 to disable and restart manually." + "Set SIMDRIVE_NO_AUTO_RESTART=1 to disable and restart manually " + "(recommended for MCP-driver sessions, where auto-restart desyncs " + "the stdio transport)." ) # Disk version is older than (or not strictly newer than) loaded — likely a # downgrade or unparseable. Warn but never auto-restart. @@ -487,7 +529,9 @@ def _maybe_handle_drift(result: dict) -> None: No-op when there's no drift, when the result isn't a dict, or when the handler already set its own ``_simdrive_warning`` field. - Auto-restart is gated by THREE conditions, all must hold: + Auto-restart is gated by FOUR conditions, all must hold: + 0. We are NOT serving as an MCP stdio server (b11 FIX 1) — re-execing + the process desyncs the client's stdio session (-32602). 1. Disk version is strictly newer than loaded version (upgrade only). 2. ``SIMDRIVE_NO_AUTO_RESTART`` is not set to a truthy value. 3. We have not already restarted ≥ ``_RESTART_LOOP_GUARD_MAX`` times @@ -503,6 +547,12 @@ def _maybe_handle_drift(result: dict) -> None: return if "_simdrive_warning" not in result: result["_simdrive_warning"] = warning + # b11 FIX 1 — never auto-restart while owning the MCP stdio transport. + # The drift warning above already carries the actionable "reconnect /mcp" + # guidance; we surface it and stop, with NO `_simdrive_action` and NO + # scheduled re-exec, so the stdio session the client holds stays intact. + if _in_mcp_server_mode(): + return if _auto_restart_disabled(): return disk = _disk_version() @@ -2404,8 +2454,12 @@ def tool_load_journey(arguments: dict) -> dict: "API — for fields that HID `type_text` can't reach, notably " "UIAlertController prompts (e.g. a 'Go to Page' dialog). Targets the field " "by `identifier`/`label`, else the first editable field in the window " - "(which is the alert's field when a prompt is up). The value propagates to " - "the field's binding, so the app receives it. Returns {ok, value}." + "(which is the alert's field when a prompt is up). Returns {ok, value}. " + "WORKS for UIKit fields (incl. UIAlertController). DOES NOT reliably commit " + "a SwiftUI `SecureField`/`TextField` `@State` binding: set_text writes the " + "AX value, which the field may DISPLAY while the @State stays empty, so the " + "app never sees the input. For SwiftUI @State-bound fields, tap the field " + "and use `type_text` (HID keystrokes) instead." ), "inputSchema": { "type": "object", @@ -3068,7 +3122,11 @@ async def call_tool_async(name: str, arguments: dict) -> dict: async def _serve_async() -> None: """Run as an MCP stdio server.""" - global _MCP_SERVER + global _MCP_SERVER, _MCP_SERVER_MODE + + # b11 FIX 1 — mark MCP-server mode so the version-drift handler suppresses + # auto-restart (os.execv would desync the client's stdio session → -32602). + _MCP_SERVER_MODE = True from mcp.server import Server from mcp.server.stdio import stdio_server diff --git a/simdrive/tests/test_ax_module.py b/simdrive/tests/test_ax_module.py index cf6c0b40..1d185429 100644 --- a/simdrive/tests/test_ax_module.py +++ b/simdrive/tests/test_ax_module.py @@ -65,6 +65,82 @@ def test_find_action_carrier_recurses_to_deep_child(monkeypatch): assert ax._find_action_carrier(root, "Toggle toolbar") is None +# ── b11 FIX 2: WKWebView host-AX boundary hint when zero custom actions ─────── + + +def test_window_has_any_custom_action_true_when_present(monkeypatch): + monkeypatch.setattr(ax, "_action_names", lambda e: e.actions) + monkeypatch.setattr(ax, "_children", lambda e: e.children) + deep = _FakeEl(actions=["Name:Next page\nTarget:0x0\nSelector:(null)"]) + root = _FakeEl(children=[_FakeEl(actions=["AXPress"]), _FakeEl(children=[deep])]) + assert ax._window_has_any_custom_action(root) is True + + +def test_window_has_any_custom_action_false_when_only_builtins(monkeypatch): + monkeypatch.setattr(ax, "_action_names", lambda e: e.actions) + monkeypatch.setattr(ax, "_children", lambda e: e.children) + # Only built-in AX actions (AXPress/AXShowMenu) — no custom actions at all, + # the WKWebView/Readium signature where web-AX isn't bridged to host-AX. + root = _FakeEl( + actions=["AXPress"], + children=[_FakeEl(actions=["AXShowMenu"]), _FakeEl(children=[_FakeEl()])], + ) + assert ax._window_has_any_custom_action(root) is False + + +def test_perform_action_adds_wkwebview_hint_when_zero_actions(monkeypatch): + """Zero custom actions window-wide => error carries the WKWebView hint.""" + window = _FakeEl(actions=["AXPress"], children=[_FakeEl(actions=["AXShowMenu"])]) + monkeypatch.setattr(ax, "select_window", lambda dev: window) + monkeypatch.setattr(ax, "_action_names", lambda e: e.actions) + monkeypatch.setattr(ax, "_children", lambda e: e.children) + # AXUIElementPerformAction is imported lazily; provide a stub module so the + # lazy `from ApplicationServices import ...` succeeds without pyobjc. + monkeypatch.setitem( + __import__("sys").modules, + "ApplicationServices", + types.SimpleNamespace(AXUIElementPerformAction=lambda *a: 0), + ) + + result = ax.perform_action("iPhone 15", "Next page") + assert result["ok"] is False + assert "not found" in result["error"] + assert "WKWebView" in result["error"] + assert "XCTest" in result["error"] + + +def test_perform_action_no_hint_when_other_actions_exist(monkeypatch): + """A missing named action but other custom actions present => NO hint. + + Accuracy guard: the WKWebView hint must only fire when the window has zero + custom actions, not when the requested label simply isn't among the (real) + custom actions that DO exist. + """ + other = _FakeEl(actions=["Name:Where am I?\nTarget:0x0\nSelector:(null)"]) + window = _FakeEl(children=[other]) + monkeypatch.setattr(ax, "select_window", lambda dev: window) + monkeypatch.setattr(ax, "_action_names", lambda e: e.actions) + monkeypatch.setattr(ax, "_children", lambda e: e.children) + monkeypatch.setitem( + __import__("sys").modules, + "ApplicationServices", + types.SimpleNamespace(AXUIElementPerformAction=lambda *a: 0), + ) + + result = ax.perform_action("iPhone 15", "Next page") + assert result["ok"] is False + assert "not found" in result["error"] + assert "WKWebView" not in result["error"] + + +def test_set_text_docstring_documents_swiftui_boundary(): + """b11 FIX 3: set_text docstring warns about SwiftUI @State binding.""" + doc = ax.set_text.__doc__ or "" + assert "SwiftUI" in doc + assert "@State" in doc + assert "type_text" in doc + + # ── announcement buffer: soft pid scoping (never drops to a false empty) ────── diff --git a/simdrive/tests/test_b11_mcp_server_mode_no_restart.py b/simdrive/tests/test_b11_mcp_server_mode_no_restart.py new file mode 100644 index 00000000..5103443e --- /dev/null +++ b/simdrive/tests/test_b11_mcp_server_mode_no_restart.py @@ -0,0 +1,123 @@ +"""b11 FIX 1 — auto-restart must be suppressed while serving as an MCP server. + +Background: when simdrive runs as an MCP stdio server and detects that the +on-disk wheel is newer than the loaded code, the pre-b11 behaviour scheduled an +``os.execv`` self-restart. Re-execing the process while the MCP client holds an +initialized stdio session DESYNCS the transport — every subsequent JSON-RPC +request fails ``MCP error -32602: Invalid request parameters``, unrecoverable +without a client ``/mcp`` reconnect. That is strictly worse than the drift it +was trying to cure. + +The fix gates auto-restart on ``_MCP_SERVER_MODE`` (set True only inside +``_serve_async``, the one code path that owns the stdio transport): + + - In MCP-server mode → NEVER restart; surface a clear, actionable warning + (no ``_simdrive_action``, no scheduled re-exec). + - Outside MCP-server mode (CLI smokes, embedders, pytest) → the safe + self-restart behaviour is preserved. + +These tests force upgrade-drift and assert the scheduler is never called when +the server is in MCP-server mode, and that it still fires when it is not. +""" +from __future__ import annotations + +import asyncio + +import pytest + +from simdrive import server + + +class _ExecRecorder: + """Records re-exec scheduling instead of replacing the process.""" + + def __init__(self) -> None: + self.calls = 0 + + def __call__(self, *args, **kwargs) -> None: + self.calls += 1 + + +@pytest.fixture +def force_upgrade_drift(monkeypatch): + """Make _maybe_handle_drift see a strict upgrade and capture scheduling.""" + monkeypatch.setattr(server, "_disk_version", lambda: "9.9.9") + monkeypatch.setattr(server, "_is_upgrade", lambda disk, loaded: True) + monkeypatch.setattr(server, "_LOADED_VERSION", "1.0.0", raising=False) + rec = _ExecRecorder() + monkeypatch.setattr(server, "_schedule_self_restart", rec) + monkeypatch.setattr(server, "_RESTART_SCHEDULED", False, raising=False) + monkeypatch.delenv("SIMDRIVE_NO_AUTO_RESTART", raising=False) + monkeypatch.delenv(server._RESTART_COUNT_ENV, raising=False) + return rec + + +@pytest.fixture +def mcp_server_mode(monkeypatch): + """Flip the MCP-server-mode flag for the duration of one test.""" + monkeypatch.setattr(server, "_MCP_SERVER_MODE", True, raising=False) + + +# --------------------------------------------------------------------------- +# In MCP-server mode: NO restart, but a clear warning. +# --------------------------------------------------------------------------- + + +def test_mcp_server_mode_does_not_schedule_restart(force_upgrade_drift, mcp_server_mode): + """Drift in MCP-server mode => warning only, NO action, NO scheduler call.""" + result = server.call_tool("version", {}) + assert "_simdrive_action" not in result + assert force_upgrade_drift.calls == 0 + warning = result.get("_simdrive_warning") + assert warning is not None + # The warning must be actionable: name the transport hazard + the fix. + assert "-32602" in warning + assert "/mcp" in warning.lower() + assert "9.9.9" in warning + + +def test_mcp_server_mode_async_does_not_schedule_restart(force_upgrade_drift, mcp_server_mode): + """Same suppression on the async (MCP hot-path) dispatcher.""" + + async def _go(): + return await server.call_tool_async("version", {}) + + result = asyncio.run(_go()) + assert "_simdrive_action" not in result + assert force_upgrade_drift.calls == 0 + assert "-32602" in result.get("_simdrive_warning", "") + + +def test_check_version_drift_mcp_mode_message_is_actionable(monkeypatch, mcp_server_mode): + """The drift string itself (in MCP mode) names the desync + recovery.""" + monkeypatch.setattr(server, "_disk_version", lambda: "9.9.9") + monkeypatch.setattr(server, "_LOADED_VERSION", "1.0.0", raising=False) + msg = server._check_version_drift() + assert msg is not None + assert "9.9.9" in msg + assert "-32602" in msg + assert "mcp-server mode" in msg.lower() + assert "/mcp" in msg.lower() + # Must NOT promise an auto-restart it will never perform. + assert "auto-restarting to pick up" not in msg.lower() + + +# --------------------------------------------------------------------------- +# Outside MCP-server mode: safe self-restart preserved. +# --------------------------------------------------------------------------- + + +def test_non_mcp_mode_still_schedules_restart(force_upgrade_drift, monkeypatch): + """When NOT in MCP-server mode, upgrade-drift still schedules a re-exec.""" + monkeypatch.setattr(server, "_MCP_SERVER_MODE", False, raising=False) + result = server.call_tool("version", {}) + assert result.get("_simdrive_action") == "restarting" + assert force_upgrade_drift.calls == 1 + + +def test_in_mcp_server_mode_helper_reflects_flag(monkeypatch): + """_in_mcp_server_mode() mirrors the module flag.""" + monkeypatch.setattr(server, "_MCP_SERVER_MODE", True, raising=False) + assert server._in_mcp_server_mode() is True + monkeypatch.setattr(server, "_MCP_SERVER_MODE", False, raising=False) + assert server._in_mcp_server_mode() is False