Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions simdrive/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions simdrive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ session_start(bundle_id="com.example.app", udid="<device-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,
Expand Down
2 changes: 1 addition & 1 deletion simdrive/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
48 changes: 47 additions & 1 deletion simdrive/src/simdrive/ax.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]
Expand Down
70 changes: 64 additions & 6 deletions simdrive/src/simdrive/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions simdrive/tests/test_ax_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────


Expand Down
Loading
Loading