From 9d577aa8f2927d6946fe64541285bd874b748c30 Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Thu, 9 Jul 2026 08:54:19 +0100 Subject: [PATCH 1/2] feat: add mobile Hermes CDP support and hermes_targets tool Dispatch the cdp tool through the platform driver so it targets the Chrome CDP session on browser and the React Native Hermes runtime via Metro on mobile, with each driver owning its own destructive-method blocklist. Add a mobile-only hermes_targets tool to list and diagnose debuggable Hermes targets. Extract platform gating into a shared checkPlatformGate helper used by both the HTTP route handler and the run_steps batch executor, and add isMobileOnlyTool alongside the existing browser-only gating so the two paths cannot drift on error code or message. --- README.md | 233 +++++++------ SKILL.md | 120 ++++--- src/cli/mm.ts | 26 +- src/index.ts | 2 +- .../mobile-platform-driver.hermes.test.ts | 284 ++++++++++++++++ src/platform/mobile-platform-driver.test.ts | 8 + src/platform/mobile-platform-driver.ts | 209 +++++++++++- src/platform/playwright-driver.test.ts | 141 ++++++++ src/platform/playwright-driver.ts | 76 +++++ src/platform/types.ts | 12 + src/server/create-server.test.ts | 94 ++++++ src/server/create-server.ts | 21 +- src/tools/batch.test.ts | 147 ++++++++ src/tools/batch.ts | 39 +-- src/tools/cdp.test.ts | 314 +++++------------- src/tools/cdp.ts | 80 ++--- src/tools/hermes.test.ts | 117 +++++++ src/tools/hermes.ts | 52 +++ src/tools/index.ts | 1 + src/tools/platform-gating.test.ts | 73 +++- src/tools/registry.test.ts | 28 +- src/tools/registry.ts | 56 +++- src/tools/types/errors.ts | 5 + src/tools/types/tool-inputs.ts | 16 + src/tools/types/tool-outputs.ts | 25 ++ src/validation/schemas.ts | 41 ++- 26 files changed, 1736 insertions(+), 484 deletions(-) create mode 100644 src/platform/mobile-platform-driver.hermes.test.ts create mode 100644 src/tools/hermes.test.ts create mode 100644 src/tools/hermes.ts diff --git a/README.md b/README.md index c71b1ce..55ed21d 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ The design is **consumer-agnostic**: the core handles protocol, tooling, and kno │ ┌──────────┐ ┌───────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ Routes │ │ RequestQueue │ │ Tool │ │ Knowledge │ │ │ │ /health │ │ (async mutex) │ │ Registry │ │ Store │ │ - │ │ /status │ │ │ │ 30 tools │ │ │ │ + │ │ /status │ │ │ │ 31 tools │ │ │ │ │ │ /launch │ └───────────────┘ └─────┬──────┘ └────────────┘ │ │ │ /cleanup │ │ │ │ │ /tool/:n │ ▼ │ @@ -363,48 +363,50 @@ The daemon routes `POST /tool/:name` requests through the registry, applies Zod **Registered tools:** -| Tool | Description | -| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Lifecycle** | | -| `build` | Triggers an extension build using the configured `BuildCapability`. Accepts build type and force options. | -| `launch` | Launches a new browser session with the configured extension. Supports state modes (`default`, `onboarding`, `custom`), fixture presets, goal/tag metadata, and optional contract seeding on start. | -| `cleanup` | Tears down the active browser session and cleans up all resources (browser, services, fixtures). | -| **Interaction** | | -| `click` | Clicks an element identified by a11y ref, test ID, or CSS selector. Waits for the element to be visible before clicking. Supports `within` to scope the target inside a parent element. | -| `type` | Types text into an input element identified by a11y ref, test ID, or CSS selector. Clears the field first, then sets the new value (uses Playwright's `fill()`). Supports `within` scoping. | -| `wait_for` | Waits for an element to become visible on the page within a configurable timeout. Supports `within` to scope the target inside a parent element. | -| `get_text` | Reads the text content of an element identified by a11y ref, test ID, or CSS selector. Returns the text, target descriptor, and character length. Supports `within` scoping. Categorized as read-only (no observations in response). | -| `clipboard` | Reads from or writes to the system clipboard via Chrome DevTools Protocol. Useful for pasting seed phrases or copying addresses. | -| **Navigation** | | -| `navigate` | Navigates the browser to a named screen (`home`, `settings`, `notification`) or an arbitrary URL. | -| `switch_to_tab` | Switches the active page to a tab matching a given role (e.g., `extension`, `dapp`) or URL prefix. | -| `close_tab` | Closes a browser tab matching a given role or URL. Falls back to the extension tab if the active tab is closed. | -| `wait_for_notification` | Waits for the extension notification popup to appear within a timeout. Returns the notification page URL. | -| **Discovery** | | -| `describe_screen` | Captures a comprehensive screen snapshot: extension state, visible test IDs, trimmed a11y tree with refs, optional screenshot, and prior knowledge from historical sessions. | -| `accessibility_snapshot` | Captures a trimmed accessibility tree of the current page with deterministic refs (`e1`, `e2`, ...). Supports scoping to a root CSS selector. | -| `list_testids` | Collects all visible `data-testid` attributes from the current page with text previews and visibility status. | -| **State** | | -| `get_state` | Retrieves the current extension state (URL, screen, network, balance, account) and tracked tab information. | -| `get_context` | Returns the current environment context (`e2e` or `prod`), session status, available capabilities, and whether context switching is allowed. | -| `set_context` | Switches the session environment between `e2e` and `prod` modes. Blocked while a session is active. | -| **Screenshots** | | -| `screenshot` | Captures a screenshot of the current page. Supports naming, full-page capture, scoping to a CSS selector, and optional base64 output. | -| **Knowledge** | | -| `knowledge_last` | Retrieves the N most recent step records from the knowledge store, with optional scope and filter parameters. | -| `knowledge_search` | Searches step records by query string with token-based matching and synonym expansion. Scores results by relevance to screen, URL, test IDs, and a11y nodes. | -| `knowledge_summarize` | Generates a recipe-style summary of a session's tool invocations, showing the step sequence with targets and outcomes. | -| `knowledge_sessions` | Lists available knowledge sessions with metadata (goal, flow tags, timestamps), with optional filtering. | -| **Contracts** | | -| `seed_contract` | Deploys a single smart contract to the local Anvil chain by name. Requires `ContractSeedingCapability`. | -| `seed_contracts` | Deploys multiple smart contracts in sequence. Returns both successful deployments and individual failures. | -| `get_contract_address` | Looks up the deployed address of a contract by name from the session's deployment registry. | -| `list_contracts` | Lists all contracts deployed in the current session with addresses and deployment timestamps. | -| **Batching** | | -| `run_steps` | Executes a batch of tool invocations sequentially. Supports `stopOnError` to halt on first failure, `includeObservations` (`'all'`, `'none'`, `'failures'`) to control observations, and `batchTimeoutMs` to set an overall deadline (remaining steps are skipped on timeout). Accepts tool aliases like `navigate_home` / `navigate-home`. Returns per-step results with timing. | -| **Advanced** | | -| `mock_network` | Adds, clears, lists, and inspects targeted Playwright network mocks on the active browser context. Unmatched same-origin requests are continued unchanged. | -| `cdp` | Sends a raw Chrome DevTools Protocol command against the active page. Escape hatch for cases where structured tools are insufficient (e.g., `Runtime.evaluate`, `Network.enable`). A small set of destructive methods (`Browser.close`, `Target.closeTarget`, etc.) are blocked to protect session state. Categorized as mutating — run `describe_screen` afterward to re-sync. | +| Tool | Description | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Lifecycle** | | +| `build` | Triggers an extension build using the configured `BuildCapability`. Accepts build type and force options. | +| `launch` | Launches a new browser session with the configured extension. Supports state modes (`default`, `onboarding`, `custom`), fixture presets, goal/tag metadata, and optional contract seeding on start. | +| `cleanup` | Tears down the active browser session and cleans up all resources (browser, services, fixtures). | +| **Interaction** | | +| `click` | Clicks an element identified by a11y ref, test ID, or CSS selector. Waits for the element to be visible before clicking. Supports `within` to scope the target inside a parent element. | +| `type` | Types text into an input element identified by a11y ref, test ID, or CSS selector. Clears the field first, then sets the new value (uses Playwright's `fill()`). Supports `within` scoping. | +| `wait_for` | Waits for an element to become visible on the page within a configurable timeout. Supports `within` to scope the target inside a parent element. | +| `get_text` | Reads the text content of an element identified by a11y ref, test ID, or CSS selector. Returns the text, target descriptor, and character length. Supports `within` scoping. Categorized as read-only (no observations in response). | +| `clipboard` | Reads from or writes to the system clipboard via Chrome DevTools Protocol. Useful for pasting seed phrases or copying addresses. | +| **Navigation** | | +| `navigate` | Navigates the browser to a named screen (`home`, `settings`, `notification`) or an arbitrary URL. | +| `switch_to_tab` | Switches the active page to a tab matching a given role (e.g., `extension`, `dapp`) or URL prefix. | +| `close_tab` | Closes a browser tab matching a given role or URL. Falls back to the extension tab if the active tab is closed. | +| `wait_for_notification` | Waits for the extension notification popup to appear within a timeout. Returns the notification page URL. | +| **Discovery** | | +| `describe_screen` | Captures a comprehensive screen snapshot: extension state, visible test IDs, trimmed a11y tree with refs, optional screenshot, and prior knowledge from historical sessions. | +| `accessibility_snapshot` | Captures a trimmed accessibility tree of the current page with deterministic refs (`e1`, `e2`, ...). Supports scoping to a root CSS selector. | +| `list_testids` | Collects all visible `data-testid` attributes from the current page with text previews and visibility status. | +| **State** | | +| `get_state` | Retrieves the current extension state (URL, screen, network, balance, account) and tracked tab information. | +| `get_context` | Returns the current environment context (`e2e` or `prod`), session status, available capabilities, and whether context switching is allowed. | +| `set_context` | Switches the session environment between `e2e` and `prod` modes. Blocked while a session is active. | +| **Screenshots** | | +| `screenshot` | Captures a screenshot of the current page. Supports naming, full-page capture, scoping to a CSS selector, and optional base64 output. | +| **Knowledge** | | +| `knowledge_last` | Retrieves the N most recent step records from the knowledge store, with optional scope and filter parameters. | +| `knowledge_search` | Searches step records by query string with token-based matching and synonym expansion. Scores results by relevance to screen, URL, test IDs, and a11y nodes. | +| `knowledge_summarize` | Generates a recipe-style summary of a session's tool invocations, showing the step sequence with targets and outcomes. | +| `knowledge_sessions` | Lists available knowledge sessions with metadata (goal, flow tags, timestamps), with optional filtering. | +| **Contracts** | | +| `seed_contract` | Deploys a single smart contract to the local Anvil chain by name. Requires `ContractSeedingCapability`. | +| `seed_contracts` | Deploys multiple smart contracts in sequence. Returns both successful deployments and individual failures. | +| `get_contract_address` | Looks up the deployed address of a contract by name from the session's deployment registry. | +| `list_contracts` | Lists all contracts deployed in the current session with addresses and deployment timestamps. | +| **Batching** | | +| `run_steps` | Executes a batch of tool invocations sequentially. Supports `stopOnError` to halt on first failure, `includeObservations` (`'all'`, `'none'`, `'failures'`) to control observations, and `batchTimeoutMs` to set an overall deadline (remaining steps are skipped on timeout). Accepts tool aliases like `navigate_home` / `navigate-home`. Returns per-step results with timing. | +| **Advanced** | | +| `mock_network` | Adds, clears, lists, and inspects targeted Playwright network mocks on the active browser context. Unmatched same-origin requests are continued unchanged. | +| `cdp` | Sends a raw Chrome DevTools Protocol command, dispatched through the active platform driver. **Browser:** targets the page's Chrome CDP session (full Runtime/DOM/Network/Page surface); destructive methods (`Browser.close`, `Target.closeTarget`, etc.) are blocked. **Mobile (iOS/Android):** targets the app's React Native Hermes JS runtime via Metro's inspector proxy (delegates to `@metamask/device-mcp`, needs a DEBUG build with Metro running); only the JS-engine subset exists (`Runtime`, `Debugger`, `Log`, `HeapProfiler` — no DOM/Page/Network) and `Runtime.terminateExecution` / `Inspector.detached` are blocked. Optional `metroPort` / `appId` are mobile-only (ignored on browser). Categorized as mutating — run `describe_screen` afterward to re-sync. See [Browser vs Mobile CDP](#browser-vs-mobile-hermes-cdp). | +| **Hermes (mobile only)** | | +| `hermes_targets` | Lists and diagnoses the debuggable Hermes targets Metro exposes (iOS and Android), reporting which target would be chosen or why selection is ambiguous. Use to confirm Metro is running and the app is registered. Pass `all` to bypass the appId filter and discover the real appId. Mobile only — there is no browser equivalent. | ### Accessibility References @@ -606,13 +608,13 @@ mm describe-screen ### Advanced -| Command | Description | -| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mm mock-network add ''` | Adds targeted route mocks during an active session. Pass either a single rule, an array of rules, or an object with a `routes` array. | -| `mm mock-network clear` | Clears route mocks and recorded requests. | -| `mm mock-network list` | Lists active route mocks. | -| `mm mock-network requests [--limit ]` | Shows recorded matched and missed requests. | -| `mm cdp [params-json] [--timeout ]` | Sends a raw Chrome DevTools Protocol command against the active page. Escape hatch for when structured tools are insufficient. Destructive methods (`Browser.close`, etc.) are blocked. | +| Command | Description | +| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mm mock-network add ''` | Adds targeted route mocks during an active session. Pass either a single rule, an array of rules, or an object with a `routes` array. | +| `mm mock-network clear` | Clears route mocks and recorded requests. | +| `mm mock-network list` | Lists active route mocks. | +| `mm mock-network requests [--limit ]` | Shows recorded matched and missed requests. | +| `mm cdp [params-json] [--timeout ] [--metro-port

] [--app-id ]` | Sends a raw Chrome DevTools Protocol command against the active session. Escape hatch for when structured tools are insufficient. Works on both browser and mobile (Hermes) — the target runtime and blocked methods differ by platform (see below). | ```bash mm cdp Runtime.evaluate '{"expression":"document.title"}' @@ -622,7 +624,36 @@ mm cdp DOM.getDocument '{"depth":2}' --timeout 60000 The `params-json` argument must be a valid JSON object. The `--timeout` flag sets a per-command timeout (default: 30 000 ms, max: 30 000 ms). The tool is categorized as **mutating** — run `mm describe-screen` afterward to re-sync session state. -**Blocked methods** (would destroy the browser session): `Browser.close`, `Target.closeTarget`, `Target.disposeBrowserContext`, `Browser.crashGpuProcess`. +#### Browser vs Mobile (Hermes) CDP + +`cdp` dispatches through the active platform driver, so the same command works on a browser or a mobile session — but the two targets are not interchangeable: + +| Aspect | Browser (Playwright) | Mobile (React Native Hermes) | +| ------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | +| Target runtime | The page's Chrome DevTools target | The app's Hermes JS engine, via Metro's inspector proxy | +| Available domains | Full Chrome surface — `Runtime`, `DOM`, `Network`, `Page`, `Accessibility`, … | JS-engine subset only — `Runtime`, `Debugger`, `Log`, `HeapProfiler` (no `DOM` / `Page` / `Network`) | +| Blocked methods | `Browser.close`, `Target.closeTarget`, `Target.disposeBrowserContext`, `Browser.crashGpuProcess` | `Runtime.terminateExecution`, `Inspector.detached` | +| `metroPort`/`appId` | Ignored | Select the Metro inspector port (default `8081`) and target app (`io.metamask.MetaMask` iOS / `io.metamask` Android) | +| Prerequisite | An active browser session | An active mobile session with a DEBUG build and Metro running | +| Errors | `MM_CDP_BLOCKED` / `MM_CDP_FAILED` | `MM_CDP_BLOCKED` / `MM_CDP_FAILED` (the underlying `HERMES_*` code is preserved in the message) | + +On mobile, `Runtime.evaluate` nests its value at `result.result.value`. Metro defaults can also be set globally via the `HERMES_METRO_PORT` / `HERMES_APP_ID` env vars on the `@metamask/device-mcp` backend. + +```bash +# Mobile (Hermes) — evaluate JS in the running app +mm cdp Runtime.evaluate '{"expression":"1+1","returnByValue":true}' --app-id io.metamask --metro-port 8081 +``` + +#### Hermes targets (mobile only) + +| Command | Description | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mm hermes-targets [--all] [--metro-port

] [--app-id ]` | Lists and diagnoses the debuggable Hermes targets Metro exposes, reporting which target would be chosen or why selection is ambiguous. Pass `--all` to bypass the appId filter and discover the real appId. Mobile only — there is no browser equivalent. | + +```bash +mm hermes-targets +mm hermes-targets --all +``` For the full agent-facing reference and workflow guidelines, see [SKILL.md](./SKILL.md). @@ -630,55 +661,57 @@ For the full agent-facing reference and workflow guidelines, see [SKILL.md](./SK Tool errors are classified into specific error codes for structured handling: -| Code | Meaning | -| -------------------------------- | -------------------------------------------------- | -| **Session & Lifecycle** | | -| `MM_NO_ACTIVE_SESSION` | No browser session running | -| `MM_SESSION_ALREADY_RUNNING` | Session already exists | -| `MM_LAUNCH_FAILED` | Browser session launch failed | -| `MM_PAGE_CLOSED` | Browser page was closed unexpectedly | -| **Build** | | -| `MM_BUILD_FAILED` | Extension build failed | -| `MM_DEPENDENCIES_MISSING` | Required build dependencies not installed | -| **Interaction** | | -| `MM_TARGET_NOT_FOUND` | Element not found by ref, testId, or selector | -| `MM_WAIT_TIMEOUT` | Timeout waiting for element visibility | -| `MM_CLICK_FAILED` | Click operation failed | -| `MM_CLICK_TIMEOUT` | Click action timed out (element found, click hung) | -| `MM_TYPE_FAILED` | Type operation failed | -| `MM_TYPE_TIMEOUT` | Fill action timed out | -| `MM_GETTEXT_FAILED` | getText operation failed | -| `MM_GETTEXT_TIMEOUT` | textContent action timed out | -| **Clipboard** | | -| `MM_CLIPBOARD_PERMISSION_DENIED` | Clipboard permission denied by browser | -| `MM_CLIPBOARD_LAVAMOAT_BLOCKED` | Clipboard blocked by LavaMoat policy | -| `MM_CLIPBOARD_FAILED` | Clipboard operation failed | -| **Navigation & Tabs** | | -| `MM_NAVIGATION_FAILED` | Navigation error or network failure | -| `MM_NOTIFICATION_TIMEOUT` | Notification popup did not appear | -| `MM_TAB_NOT_FOUND` | Tab not found by role or URL | -| **Discovery & State** | | -| `MM_DISCOVERY_FAILED` | Discovery tool failure | -| `MM_SCREENSHOT_FAILED` | Screenshot capture failure | -| `MM_STATE_FAILED` | State retrieval failed | -| **Knowledge** | | -| `MM_KNOWLEDGE_ERROR` | Knowledge store operation failed | -| **Contracts** | | -| `MM_CONTRACT_NOT_FOUND` | Unknown contract name | -| `MM_SEED_FAILED` | Contract deployment failure | -| **Context & Config** | | -| `MM_CONTEXT_SWITCH_BLOCKED` | Context switch while session is active | -| `MM_SET_CONTEXT_FAILED` | Context switch operation failed | -| `MM_CAPABILITY_NOT_AVAILABLE` | Feature requires a capability not configured | -| `MM_INVALID_INPUT` | Bad parameters | -| `MM_INVALID_CONFIG` | Invalid configuration | -| `MM_PORT_IN_USE` | Port already in use | -| **System** | | -| `MM_UNKNOWN_TOOL` | Unknown tool name | -| `MM_INTERNAL_ERROR` | Internal server error | -| `MM_BATCH_TIMEOUT` | `batchTimeoutMs` deadline exceeded in run_steps | -| `MM_CDP_BLOCKED` | CDP method is blocked (destructive to session) | -| `MM_CDP_FAILED` | CDP command execution failed or timed out | +| Code | Meaning | +| -------------------------------- | ------------------------------------------------------------------------------------------------------- | +| **Session & Lifecycle** | | +| `MM_NO_ACTIVE_SESSION` | No browser session running | +| `MM_SESSION_ALREADY_RUNNING` | Session already exists | +| `MM_LAUNCH_FAILED` | Browser session launch failed | +| `MM_PAGE_CLOSED` | Browser page was closed unexpectedly | +| **Build** | | +| `MM_BUILD_FAILED` | Extension build failed | +| `MM_DEPENDENCIES_MISSING` | Required build dependencies not installed | +| **Interaction** | | +| `MM_TARGET_NOT_FOUND` | Element not found by ref, testId, or selector | +| `MM_WAIT_TIMEOUT` | Timeout waiting for element visibility | +| `MM_CLICK_FAILED` | Click operation failed | +| `MM_CLICK_TIMEOUT` | Click action timed out (element found, click hung) | +| `MM_TYPE_FAILED` | Type operation failed | +| `MM_TYPE_TIMEOUT` | Fill action timed out | +| `MM_GETTEXT_FAILED` | getText operation failed | +| `MM_GETTEXT_TIMEOUT` | textContent action timed out | +| **Clipboard** | | +| `MM_CLIPBOARD_PERMISSION_DENIED` | Clipboard permission denied by browser | +| `MM_CLIPBOARD_LAVAMOAT_BLOCKED` | Clipboard blocked by LavaMoat policy | +| `MM_CLIPBOARD_FAILED` | Clipboard operation failed | +| **Navigation & Tabs** | | +| `MM_NAVIGATION_FAILED` | Navigation error or network failure | +| `MM_NOTIFICATION_TIMEOUT` | Notification popup did not appear | +| `MM_TAB_NOT_FOUND` | Tab not found by role or URL | +| **Discovery & State** | | +| `MM_DISCOVERY_FAILED` | Discovery tool failure | +| `MM_SCREENSHOT_FAILED` | Screenshot capture failure | +| `MM_STATE_FAILED` | State retrieval failed | +| **Knowledge** | | +| `MM_KNOWLEDGE_ERROR` | Knowledge store operation failed | +| **Contracts** | | +| `MM_CONTRACT_NOT_FOUND` | Unknown contract name | +| `MM_SEED_FAILED` | Contract deployment failure | +| **Context & Config** | | +| `MM_CONTEXT_SWITCH_BLOCKED` | Context switch while session is active | +| `MM_SET_CONTEXT_FAILED` | Context switch operation failed | +| `MM_CAPABILITY_NOT_AVAILABLE` | Feature requires a capability not configured | +| `MM_INVALID_INPUT` | Bad parameters | +| `MM_INVALID_CONFIG` | Invalid configuration | +| `MM_PORT_IN_USE` | Port already in use | +| **System** | | +| `MM_UNKNOWN_TOOL` | Unknown tool name | +| `MM_INTERNAL_ERROR` | Internal server error | +| `MM_BATCH_TIMEOUT` | `batchTimeoutMs` deadline exceeded in run_steps | +| `MM_CDP_BLOCKED` | CDP method is blocked (destructive to session); on mobile the Hermes blocklist maps here too | +| `MM_CDP_FAILED` | CDP command execution failed or timed out (on mobile, the underlying `HERMES_*` code is in the message) | +| `MM_HERMES_FAILED` | `hermes_targets` discovery failed (underlying `HERMES_*` code is in the message) | +| `MM_HERMES_NOT_AVAILABLE` | `hermes_targets` used outside a mobile (iOS/Android) session | ## Development diff --git a/SKILL.md b/SKILL.md index 11456a2..6ff384f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -549,25 +549,47 @@ mm mock-network requests [--limit ] | ------------- | ------------------------------------------ | | `--limit ` | Maximum number of recent records to return | -#### `mm cdp [params-json] [--timeout ]` +#### `mm cdp [params-json] [--timeout ] [--metro-port

] [--app-id ]` -Sends a raw Chrome DevTools Protocol command against the active page. This is an escape hatch for cases where structured tools are insufficient — e.g., evaluating JavaScript, enabling network tracking, or inspecting the DOM tree directly. +Sends a raw Chrome DevTools Protocol command against the active session. This is an escape hatch for cases where structured tools are insufficient — e.g., evaluating JavaScript, enabling network tracking, or inspecting the DOM tree. It dispatches through the active platform driver, so it works on **both** browser and mobile sessions — but the target runtime and available methods differ (see the table below). ```bash +# Browser mm cdp Runtime.evaluate '{"expression":"document.title"}' mm cdp Network.enable mm cdp DOM.getDocument '{"depth":2}' --timeout 60000 + +# Mobile (Hermes) — evaluate JS in the running app +mm cdp Runtime.evaluate '{"expression":"1+1","returnByValue":true}' --app-id io.metamask --metro-port 8081 ``` -| Argument | Description | -| --------------- | ------------------------------------------------------------- | -| `` | CDP method name (e.g., `Runtime.evaluate`, `DOM.getDocument`) | -| `[params-json]` | Optional JSON object with method-specific parameters | -| `--timeout` | Per-command timeout in ms (default: 30 000, max: 30 000) | +| Argument | Description | +| --------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `` | CDP method name (e.g., `Runtime.evaluate`, `DOM.getDocument` on browser; `Runtime.evaluate`, `Debugger.enable` on mobile) | +| `[params-json]` | Optional JSON object with method-specific parameters | +| `--timeout` | Per-command timeout in ms (default: 30 000, max: 30 000) | +| `--metro-port` | **Mobile only** — override the Metro inspector proxy port (default: 8081). Ignored on browser. | +| `--app-id` | **Mobile only** — override the expected app bundle identifier. Ignored on browser. | + +**Browser vs Mobile (Hermes):** the same command targets different runtimes. + +| Aspect | Browser (Playwright) | Mobile (React Native Hermes) | +| ----------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| Target | The page's Chrome DevTools session | The app's Hermes JS engine, via Metro's inspector proxy (needs a DEBUG build with Metro running) | +| Available domains | Full Chrome surface (`Runtime`, `DOM`, `Network`, `Page`, …) | JS-engine subset only (`Runtime`, `Debugger`, `Log`, `HeapProfiler`) — no `DOM`/`Page`/`Network` | +| Blocked methods | `Browser.close`, `Target.closeTarget`, `Target.disposeBrowserContext`, `Browser.crashGpuProcess` | `Runtime.terminateExecution`, `Inspector.detached` | +| Result shape | Standard CDP response | `Runtime.evaluate` nests the value at `result.result.value` | + +Blocked methods return `MM_CDP_BLOCKED` on either platform; other failures return `MM_CDP_FAILED` (on mobile the underlying `HERMES_*` code is preserved in the message). The tool is categorized as **mutating** — run `describe-screen` afterward to re-sync if the call changed runtime/page state. -**Blocked methods** (would destroy the browser session): `Browser.close`, `Target.closeTarget`, `Target.disposeBrowserContext`, `Browser.crashGpuProcess`. Attempting a blocked method returns `MM_CDP_BLOCKED`. +#### `mm hermes-targets [--all] [--metro-port

] [--app-id ]` -The tool is categorized as **mutating** — run `describe-screen` afterward to re-sync if the CDP call changed page state. +**Mobile only.** Lists and diagnoses the debuggable Hermes targets Metro currently exposes, reporting which target would be chosen or why selection is ambiguous. Use it to confirm Metro is running and the app is registered. Pass `--all` to bypass the appId filter and discover the real appId. + +```bash +mm hermes-targets +mm hermes-targets --all +``` ## Element Targeting @@ -595,45 +617,47 @@ Use prior knowledge to guide your actions, but always verify against the current When a command fails, the response includes `error.code`. Use this to decide what to do: -| Code | Meaning | Recovery | -| -------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `MM_NO_ACTIVE_SESSION` | No browser session running | Run `mm launch` first | -| `MM_SESSION_ALREADY_RUNNING` | Session already exists | Run `mm cleanup` first, or use `--force` | -| `MM_LAUNCH_FAILED` | Browser session launch failed | Check extension path and config; retry | -| `MM_PAGE_CLOSED` | Page was closed unexpectedly | Normal after some confirmations; run describe-screen | -| `MM_BUILD_FAILED` | Extension build failed | Check build logs; fix build errors and retry | -| `MM_DEPENDENCIES_MISSING` | Required build dependencies not installed | Run dependency install (npm/yarn) and retry build | -| `MM_TARGET_NOT_FOUND` | Element ref/testId/selector not found | Run `mm describe-screen` to get fresh refs | -| `MM_WAIT_TIMEOUT` | Element didn't appear in time | Increase timeout or verify you're on the right screen | -| `MM_CLICK_FAILED` | Click failed after finding element | Element may be obscured; try waiting or scrolling | -| `MM_CLICK_TIMEOUT` | Click action timed out (element found, click hung) | Run `mm describe-screen` to verify if click completed; retry with `--timeout` or different approach | -| `MM_TYPE_FAILED` | Type failed after finding element | Element may not be an input; verify with describe-screen | -| `MM_TYPE_TIMEOUT` | Fill action timed out | Run `mm describe-screen` to verify state; retry with `--timeout` | -| `MM_GETTEXT_FAILED` | getText operational failure (non-timeout) | Element may be detached; run `mm describe-screen` and re-target | -| `MM_GETTEXT_TIMEOUT` | textContent action timed out | Retry with `--timeout` | -| `MM_CLIPBOARD_PERMISSION_DENIED` | Clipboard permission denied by browser | Check browser permissions; try CDP approach | -| `MM_CLIPBOARD_LAVAMOAT_BLOCKED` | Clipboard blocked by LavaMoat policy | Extension security policy blocks clipboard; use alternative input method | -| `MM_CLIPBOARD_FAILED` | Clipboard operation failed | Retry; check if page is still active | -| `MM_NAVIGATION_FAILED` | Navigation error or network failure | Check URL validity; retry once | -| `MM_NOTIFICATION_TIMEOUT` | Extension notification popup didn't appear | Action may not have triggered a notification; check state | -| `MM_TAB_NOT_FOUND` | Tab role/URL not found | Run `mm get-state` to see available tabs | -| `MM_DISCOVERY_FAILED` | Discovery tool failure | Page may be loading; wait and retry | -| `MM_SCREENSHOT_FAILED` | Screenshot capture failure | Page may be in unstable state; retry after describe-screen | -| `MM_STATE_FAILED` | State retrieval failed | Session may be unstable; run `mm describe-screen` | -| `MM_KNOWLEDGE_ERROR` | Knowledge store operation failed | Retry; check that session exists | -| `MM_CONTRACT_NOT_FOUND` | Unknown contract name for seeding | See available contracts below | -| `MM_SEED_FAILED` | Contract deployment failure | Check Anvil chain is running; verify contract name | -| `MM_CAPABILITY_NOT_AVAILABLE` | Feature requires a capability not configured | Check environment mode (e2e vs prod) | -| `MM_CONTEXT_SWITCH_BLOCKED` | Can't switch context with active session | Run `mm cleanup` first | -| `MM_SET_CONTEXT_FAILED` | Context switch operation failed | Retry; check session state | -| `MM_INVALID_INPUT` | Bad parameters | Fix input and retry | -| `MM_INVALID_CONFIG` | Invalid configuration | Check config file format and required fields | -| `MM_PORT_IN_USE` | Port already in use | Stop conflicting process or let the daemon auto-allocate | -| `MM_UNKNOWN_TOOL` | Unknown tool name | Check tool name spelling | -| `MM_INTERNAL_ERROR` | Internal server error | Retry; if persistent, restart daemon with `mm stop && mm serve` | -| `MM_BATCH_TIMEOUT` | `batchTimeoutMs` deadline exceeded | Remaining steps were skipped; check partial results | -| `MM_CDP_BLOCKED` | CDP method is blocked (destructive) | Use a different CDP method; see blocked list | -| `MM_CDP_FAILED` | CDP command failed or timed out | Check method name/params; retry or increase timeout | +| Code | Meaning | Recovery | +| -------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `MM_NO_ACTIVE_SESSION` | No browser session running | Run `mm launch` first | +| `MM_SESSION_ALREADY_RUNNING` | Session already exists | Run `mm cleanup` first, or use `--force` | +| `MM_LAUNCH_FAILED` | Browser session launch failed | Check extension path and config; retry | +| `MM_PAGE_CLOSED` | Page was closed unexpectedly | Normal after some confirmations; run describe-screen | +| `MM_BUILD_FAILED` | Extension build failed | Check build logs; fix build errors and retry | +| `MM_DEPENDENCIES_MISSING` | Required build dependencies not installed | Run dependency install (npm/yarn) and retry build | +| `MM_TARGET_NOT_FOUND` | Element ref/testId/selector not found | Run `mm describe-screen` to get fresh refs | +| `MM_WAIT_TIMEOUT` | Element didn't appear in time | Increase timeout or verify you're on the right screen | +| `MM_CLICK_FAILED` | Click failed after finding element | Element may be obscured; try waiting or scrolling | +| `MM_CLICK_TIMEOUT` | Click action timed out (element found, click hung) | Run `mm describe-screen` to verify if click completed; retry with `--timeout` or different approach | +| `MM_TYPE_FAILED` | Type failed after finding element | Element may not be an input; verify with describe-screen | +| `MM_TYPE_TIMEOUT` | Fill action timed out | Run `mm describe-screen` to verify state; retry with `--timeout` | +| `MM_GETTEXT_FAILED` | getText operational failure (non-timeout) | Element may be detached; run `mm describe-screen` and re-target | +| `MM_GETTEXT_TIMEOUT` | textContent action timed out | Retry with `--timeout` | +| `MM_CLIPBOARD_PERMISSION_DENIED` | Clipboard permission denied by browser | Check browser permissions; try CDP approach | +| `MM_CLIPBOARD_LAVAMOAT_BLOCKED` | Clipboard blocked by LavaMoat policy | Extension security policy blocks clipboard; use alternative input method | +| `MM_CLIPBOARD_FAILED` | Clipboard operation failed | Retry; check if page is still active | +| `MM_NAVIGATION_FAILED` | Navigation error or network failure | Check URL validity; retry once | +| `MM_NOTIFICATION_TIMEOUT` | Extension notification popup didn't appear | Action may not have triggered a notification; check state | +| `MM_TAB_NOT_FOUND` | Tab role/URL not found | Run `mm get-state` to see available tabs | +| `MM_DISCOVERY_FAILED` | Discovery tool failure | Page may be loading; wait and retry | +| `MM_SCREENSHOT_FAILED` | Screenshot capture failure | Page may be in unstable state; retry after describe-screen | +| `MM_STATE_FAILED` | State retrieval failed | Session may be unstable; run `mm describe-screen` | +| `MM_KNOWLEDGE_ERROR` | Knowledge store operation failed | Retry; check that session exists | +| `MM_CONTRACT_NOT_FOUND` | Unknown contract name for seeding | See available contracts below | +| `MM_SEED_FAILED` | Contract deployment failure | Check Anvil chain is running; verify contract name | +| `MM_CAPABILITY_NOT_AVAILABLE` | Feature requires a capability not configured | Check environment mode (e2e vs prod) | +| `MM_CONTEXT_SWITCH_BLOCKED` | Can't switch context with active session | Run `mm cleanup` first | +| `MM_SET_CONTEXT_FAILED` | Context switch operation failed | Retry; check session state | +| `MM_INVALID_INPUT` | Bad parameters | Fix input and retry | +| `MM_INVALID_CONFIG` | Invalid configuration | Check config file format and required fields | +| `MM_PORT_IN_USE` | Port already in use | Stop conflicting process or let the daemon auto-allocate | +| `MM_UNKNOWN_TOOL` | Unknown tool name | Check tool name spelling | +| `MM_INTERNAL_ERROR` | Internal server error | Retry; if persistent, restart daemon with `mm stop && mm serve` | +| `MM_BATCH_TIMEOUT` | `batchTimeoutMs` deadline exceeded | Remaining steps were skipped; check partial results | +| `MM_CDP_BLOCKED` | CDP method is blocked (destructive) | Use a different CDP method; blocked list differs by platform (see `cdp`) | +| `MM_CDP_FAILED` | CDP command failed or timed out (mobile: `HERMES_*` code in message) | Check method/params; on mobile ensure a DEBUG build with Metro is running, then `mm hermes-targets --all` | +| `MM_HERMES_FAILED` | `hermes_targets` discovery failed (`HERMES_*` code in message) | Ensure a DEBUG build with Metro is running; run `mm hermes-targets --all` to diagnose | +| `MM_HERMES_NOT_AVAILABLE` | `hermes_targets` used outside a mobile session | Launch a mobile (iOS/Android) session first | ## Available Contracts (E2E only) diff --git a/src/cli/mm.ts b/src/cli/mm.ts index 99333b6..b42a131 100644 --- a/src/cli/mm.ts +++ b/src/cli/mm.ts @@ -645,14 +645,18 @@ export async function routeCommand( const cdpMethod = args[0]; if (!cdpMethod) { process.stderr.write( - 'Usage: mm cdp [params-json] [--timeout ]\n' + + 'Usage: mm cdp [params-json] [--timeout ] [--metro-port ] [--app-id ]\n' + ' mm cdp Runtime.evaluate \'{"expression":"document.title"}\'\n' + ' mm cdp Network.enable\n' + - ' mm cdp DOM.getDocument \'{"depth":2}\' --timeout 60000\n', + ' mm cdp DOM.getDocument \'{"depth":2}\' --timeout 60000\n' + + ' # mobile (Hermes): --metro-port / --app-id override the Metro target\n' + + ' mm cdp Runtime.evaluate \'{"expression":"1+1","returnByValue":true}\' --app-id io.metamask\n', ); process.exit(1); } const cdpTimeout = parseIntFlag(args, '--timeout'); + const cdpMetroPort = parseIntFlag(args, '--metro-port'); + const cdpAppId = parseStringFlag(args, '--app-id'); const cdpParamsRaw = args[1] !== undefined && !args[1].startsWith('--') ? args[1] @@ -680,6 +684,21 @@ export async function routeCommand( method: cdpMethod, ...(cdpParams ? { params: cdpParams } : {}), ...(cdpTimeout === undefined ? {} : { timeoutMs: cdpTimeout }), + ...(cdpMetroPort === undefined ? {} : { metroPort: cdpMetroPort }), + ...(cdpAppId ? { appId: cdpAppId } : {}), + }); + break; + } + case 'hermes-targets': { + const targetsMetroPort = parseIntFlag(args, '--metro-port'); + const targetsAppId = parseStringFlag(args, '--app-id'); + const targetsAll = args.includes('--all'); + await sendRequest(port, 'POST', '/tool/hermes_targets', { + ...(targetsMetroPort === undefined + ? {} + : { metroPort: targetsMetroPort }), + ...(targetsAppId ? { appId: targetsAppId } : {}), + ...(targetsAll ? { all: true } : {}), }); break; } @@ -1436,7 +1455,8 @@ Advanced: mm mock-network clear mm mock-network list mm mock-network requests [--limit ] - mm cdp [params-json] [--timeout ] + mm cdp [params-json] [--timeout ] [--metro-port

] [--app-id ] + mm hermes-targets [--all] [--metro-port

] [--app-id ] (mobile only) Examples: mm launch (from inside project) diff --git a/src/index.ts b/src/index.ts index ba19096..a46bb05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ export type { GetTextActionResult, PlatformScreenshotOptions, } from './platform'; -export { PlaywrightPlatformDriver } from './platform'; +export { PlaywrightPlatformDriver, MobilePlatformDriver } from './platform'; // Session Manager Interface (transport-agnostic) export type { diff --git a/src/platform/mobile-platform-driver.hermes.test.ts b/src/platform/mobile-platform-driver.hermes.test.ts new file mode 100644 index 0000000..f62add0 --- /dev/null +++ b/src/platform/mobile-platform-driver.hermes.test.ts @@ -0,0 +1,284 @@ +import type { DeviceBackend } from '@metamask/device-mcp'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + runHermesCdp: vi.fn(), + fetchDiscoveryTargets: vi.fn(), + selectHermesTarget: vi.fn(), + hasAmbiguousTarget: vi.fn(), + resolve: vi.fn(), + getPinnedHermesDeviceId: vi.fn(), + setPinnedHermesDeviceId: vi.fn(), + HermesSession: vi.fn(), +})); + +vi.mock('@metamask/device-mcp', () => ({ + runHermesCdp: mocks.runHermesCdp, + fetchDiscoveryTargets: mocks.fetchDiscoveryTargets, + selectHermesTarget: mocks.selectHermesTarget, + hasAmbiguousTarget: mocks.hasAmbiguousTarget, + LEGACY_SYNTHETIC_TITLE: 'React Native Experimental (Improved Chrome Reloads)', + HermesSession: mocks.HermesSession, +})); + +// Imported AFTER vi.mock so the driver binds to the mocked device-mcp runtime. +const { MobilePlatformDriver } = await import('./mobile-platform-driver.js'); + +function createBackend(platform: 'ios' | 'android' = 'android'): DeviceBackend { + return { platform } as unknown as DeviceBackend; +} + +describe('MobilePlatformDriver hermes delegation', () => { + beforeEach(() => { + mocks.HermesSession.mockImplementation(() => ({ + resolve: mocks.resolve, + getPinnedHermesDeviceId: mocks.getPinnedHermesDeviceId, + setPinnedHermesDeviceId: mocks.setPinnedHermesDeviceId, + })); + mocks.resolve.mockReturnValue({ + metroPort: 8081, + appId: 'io.metamask', + pinnedDeviceId: undefined, + }); + mocks.getPinnedHermesDeviceId.mockReturnValue(undefined); + }); + + describe('session scoping', () => { + it('owns an instance-scoped Hermes session instead of the process singleton', () => { + const iosDriver = new MobilePlatformDriver(createBackend('ios')); + const androidDriver = new MobilePlatformDriver(createBackend('android')); + + expect(iosDriver.getPlatform()).toBe('ios'); + expect(androidDriver.getPlatform()).toBe('android'); + expect(mocks.HermesSession).toHaveBeenCalledTimes(2); + expect(mocks.HermesSession).toHaveBeenNthCalledWith(1, { + platform: 'ios', + }); + expect(mocks.HermesSession).toHaveBeenNthCalledWith(2, { + platform: 'android', + }); + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('cdp', () => { + it('resolves with the backend platform and delegates to runHermesCdp', async () => { + mocks.runHermesCdp.mockResolvedValue({ ok: true, result: { value: 2 } }); + const driver = new MobilePlatformDriver(createBackend('android')); + + const outcome = await driver.cdp({ + method: 'Runtime.evaluate', + params: { expression: '1+1' }, + timeoutMs: 30_000, + }); + + expect(mocks.resolve).toHaveBeenCalledWith({ + metroPort: undefined, + appId: undefined, + platform: 'android', + }); + expect(mocks.runHermesCdp).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'Runtime.evaluate', + params: { expression: '1+1' }, + timeoutMs: 30_000, + metroPort: 8081, + appId: 'io.metamask', + pinnedDeviceId: undefined, + }), + ); + expect(outcome).toStrictEqual({ ok: true, result: { value: 2 } }); + }); + + it('persists the device pin via onPin when none is set', async () => { + mocks.runHermesCdp.mockImplementation(async (input) => { + input.onPin?.('device-42'); + return { ok: true, result: null }; + }); + const driver = new MobilePlatformDriver(createBackend()); + + await driver.cdp({ method: 'Runtime.evaluate', timeoutMs: 30_000 }); + + expect(mocks.setPinnedHermesDeviceId).toHaveBeenCalledWith('device-42'); + }); + + it('does not overwrite an existing device pin', async () => { + mocks.getPinnedHermesDeviceId.mockReturnValue('device-existing'); + mocks.runHermesCdp.mockImplementation(async (input) => { + input.onPin?.('device-new'); + return { ok: true, result: null }; + }); + const driver = new MobilePlatformDriver(createBackend()); + + await driver.cdp({ method: 'Runtime.evaluate', timeoutMs: 30_000 }); + + expect(mocks.setPinnedHermesDeviceId).not.toHaveBeenCalled(); + }); + + it('maps the Hermes blocked-method code to MM_CDP_BLOCKED', async () => { + mocks.runHermesCdp.mockResolvedValue({ + ok: false, + code: 'HERMES_BLOCKED_METHOD', + message: + 'CDP method "Runtime.terminateExecution" is blocked for safety.', + }); + const driver = new MobilePlatformDriver(createBackend()); + + const outcome = await driver.cdp({ + method: 'Runtime.terminateExecution', + timeoutMs: 30_000, + }); + + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.code).toBe('MM_CDP_BLOCKED'); + expect(outcome.message).toContain('HERMES_BLOCKED_METHOD'); + } + }); + + it('maps other Hermes failures to MM_CDP_FAILED and preserves the HERMES_* code', async () => { + mocks.runHermesCdp.mockResolvedValue({ + ok: false, + code: 'HERMES_TARGET_NOT_FOUND', + message: 'No Hermes debug target found for appId io.metamask', + }); + const driver = new MobilePlatformDriver(createBackend()); + + const outcome = await driver.cdp({ + method: 'Runtime.evaluate', + timeoutMs: 30_000, + }); + + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.code).toBe('MM_CDP_FAILED'); + expect(outcome.message).toContain('HERMES_TARGET_NOT_FOUND'); + } + }); + }); + + describe('hermesTargets', () => { + it('reports the chosen target on successful selection', async () => { + const target = { + id: 'page-1', + title: 'MetaMask', + appId: 'io.metamask', + webSocketDebuggerUrl: 'ws://localhost:8081/x', + reactNative: { + logicalDeviceId: 'dev-1', + capabilities: { nativePageReloads: true }, + }, + }; + mocks.fetchDiscoveryTargets.mockResolvedValue([target]); + mocks.selectHermesTarget.mockReturnValue({ ok: true, target }); + const driver = new MobilePlatformDriver(createBackend()); + + const result = await driver.hermesTargets({}); + + expect(result.metroDown).toBe(false); + expect(result.targetsDiscovered).toBe(1); + expect(result.candidates).toStrictEqual([ + { + id: 'page-1', + title: 'MetaMask', + appId: 'io.metamask', + logicalDeviceId: 'dev-1', + nativePageReloads: true, + webSocketDebuggerUrl: 'ws://localhost:8081/x', + }, + ]); + expect(result.chosen).toStrictEqual({ + id: 'page-1', + logicalDeviceId: 'dev-1', + }); + }); + + it('returns metroDown when discovery fails with a connection error', async () => { + mocks.fetchDiscoveryTargets.mockRejectedValue( + new Error('fetch failed: ECONNREFUSED'), + ); + const driver = new MobilePlatformDriver(createBackend()); + + const result = await driver.hermesTargets({}); + + expect(result.metroDown).toBe(true); + expect(result.targetsDiscovered).toBe(0); + expect(result.candidates).toStrictEqual([]); + }); + + it('rethrows non-connection discovery errors', async () => { + mocks.fetchDiscoveryTargets.mockRejectedValue(new Error('boom')); + const driver = new MobilePlatformDriver(createBackend()); + + await expect(driver.hermesTargets({})).rejects.toThrowError('boom'); + }); + + it('reports ambiguity when selection fails with HERMES_MULTIPLE_DEVICES', async () => { + const targets = [ + { + appId: 'io.metamask', + webSocketDebuggerUrl: 'ws://localhost:8081/a', + reactNative: { logicalDeviceId: 'dev-1' }, + }, + { + appId: 'io.metamask', + webSocketDebuggerUrl: 'ws://localhost:8081/b', + reactNative: { logicalDeviceId: 'dev-2' }, + }, + ]; + mocks.fetchDiscoveryTargets.mockResolvedValue(targets); + mocks.selectHermesTarget.mockReturnValue({ + ok: false, + code: 'HERMES_MULTIPLE_DEVICES', + message: 'Ambiguous Hermes target', + }); + mocks.hasAmbiguousTarget.mockReturnValue(true); + const driver = new MobilePlatformDriver(createBackend()); + + const result = await driver.hermesTargets({}); + + expect(result.ambiguous).toBe('Ambiguous Hermes target'); + expect(result.chosen).toBeUndefined(); + }); + + it('reports noTargetReason for other selection failures', async () => { + mocks.fetchDiscoveryTargets.mockResolvedValue([]); + mocks.selectHermesTarget.mockReturnValue({ + ok: false, + code: 'HERMES_TARGET_NOT_FOUND', + message: 'No Hermes debug target found', + }); + mocks.hasAmbiguousTarget.mockReturnValue(false); + const driver = new MobilePlatformDriver(createBackend()); + + const result = await driver.hermesTargets({}); + + expect(result.noTargetReason).toStrictEqual({ + code: 'HERMES_TARGET_NOT_FOUND', + message: 'No Hermes debug target found', + }); + }); + + it('bypasses the appId filter when all is set', async () => { + const targets = [ + { appId: 'other.app', webSocketDebuggerUrl: 'ws://localhost:8081/a' }, + ]; + mocks.fetchDiscoveryTargets.mockResolvedValue(targets); + mocks.selectHermesTarget.mockReturnValue({ + ok: false, + code: 'HERMES_TARGET_NOT_FOUND', + message: 'No Hermes debug target found', + }); + mocks.hasAmbiguousTarget.mockReturnValue(false); + const driver = new MobilePlatformDriver(createBackend()); + + const result = await driver.hermesTargets({ all: true }); + + expect(result.filterBypassed).toBe(true); + expect(result.candidates).toHaveLength(1); + }); + }); +}); diff --git a/src/platform/mobile-platform-driver.test.ts b/src/platform/mobile-platform-driver.test.ts index ce489ca..9eb46f4 100644 --- a/src/platform/mobile-platform-driver.test.ts +++ b/src/platform/mobile-platform-driver.test.ts @@ -137,6 +137,14 @@ describe('MobilePlatformDriver', () => { ).rejects.toThrowError('Unknown a11yRef: e99'); }); + it('throws for an unknown target type', async () => { + const driver = new MobilePlatformDriver(createMockBackend()); + + await expect( + driver.click('bogus' as unknown as 'testId', 'x', new Map(), 5000), + ).rejects.toThrowError('Unknown target type: bogus'); + }); + it('resolves a11yRef with no-colon stable id as identifier', async () => { const backend = createMockBackend(); const driver = new MobilePlatformDriver(backend); diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts index 551dabd..033d06d 100644 --- a/src/platform/mobile-platform-driver.ts +++ b/src/platform/mobile-platform-driver.ts @@ -1,7 +1,16 @@ +import { + runHermesCdp, + HermesSession, + fetchDiscoveryTargets, + selectHermesTarget, + hasAmbiguousTarget, + LEGACY_SYNTHETIC_TITLE, +} from '@metamask/device-mcp'; import type { DeviceBackend, DeviceButton, ElementQuery, + HermesTarget, UIElement, } from '@metamask/device-mcp'; @@ -20,10 +29,26 @@ import type { ExtensionState, } from '../capabilities/types.js'; import type { TestIdItem, A11yNodeTrimmed } from '../tools/types/discovery.js'; +import { ErrorCodes } from '../tools/types/errors.js'; +import type { + CdpInput, + HermesTargetsInput, +} from '../tools/types/tool-inputs.js'; +import type { + CdpOutcome, + HermesTargetInfo, + HermesTargetsResult, +} from '../tools/types/tool-outputs.js'; import { OBSERVATION_TESTID_LIMIT } from '../tools/utils/constants.js'; const DEFAULT_BUNDLE_ID = 'io.metamask'; const APPIUM_STATE_RUNNING_IN_FOREGROUND = '4'; +const HERMES_DISCOVERY_TIMEOUT_MS = 10_000; +const HERMES_MULTIPLE_DEVICES_CODE = 'HERMES_MULTIPLE_DEVICES'; +// device-mcp returns this code for its destructive-method blocklist +// (Runtime.terminateExecution, Inspector.detached); mirror it to MM_CDP_BLOCKED +// so the agent-facing contract matches the browser driver. +const HERMES_BLOCKED_METHOD_CODE = 'HERMES_BLOCKED_METHOD'; /** * Platform driver for mobile devices backed by @metamask/device-mcp. @@ -36,6 +61,11 @@ export class MobilePlatformDriver implements IPlatformDriver { readonly #bundleId: string; + // Scoped to this driver instance rather than the process-wide + // getHermesSession() singleton so the pinned Hermes deviceId cannot leak + // across mobile sessions (e.g. a relaunch against a different simulator). + readonly #hermesSession: HermesSession; + /** * @param backend - The device-mcp backend to delegate to. * @param bundleId - The app bundle ID for getAppState calls. @@ -43,6 +73,7 @@ export class MobilePlatformDriver implements IPlatformDriver { constructor(backend: DeviceBackend, bundleId: string = DEFAULT_BUNDLE_ID) { this.#backend = backend; this.#bundleId = bundleId; + this.#hermesSession = new HermesSession({ platform: backend.platform }); } /** @@ -390,6 +421,161 @@ export class MobilePlatformDriver implements IPlatformDriver { }> { return this.#backend.getLogs(durationSeconds, filter); } + + /** + * Send a raw Chrome DevTools Protocol command to the app's React Native + * Hermes JS runtime via Metro's inspector proxy. Hermes exposes only the + * JS-engine CDP subset (Runtime, Debugger, Log, HeapProfiler) — there is no + * DOM/Page/Network domain. Works on both iOS and Android; only the default + * appId differs by platform. + * + * @param input - The CDP method, params, timeout, and optional Metro port / + * appId overrides. + * @returns A discriminated outcome carrying the raw CDP result or an error, + * with the underlying device-mcp `HERMES_*` code preserved in the message. + */ + async cdp(input: CdpInput): Promise { + const session = this.#hermesSession; + const resolved = session.resolve({ + metroPort: input.metroPort, + appId: input.appId, + platform: this.#backend.platform, + }); + + const outcome = await runHermesCdp({ + method: input.method, + params: input.params, + timeoutMs: input.timeoutMs, + metroPort: resolved.metroPort, + appId: resolved.appId, + pinnedDeviceId: resolved.pinnedDeviceId, + onPin: (logicalDeviceId) => { + if (session.getPinnedHermesDeviceId() === undefined) { + session.setPinnedHermesDeviceId(logicalDeviceId); + } + }, + }); + + if (outcome.ok) { + return { ok: true, result: outcome.result }; + } + return { + ok: false, + code: + outcome.code === HERMES_BLOCKED_METHOD_CODE + ? ErrorCodes.MM_CDP_BLOCKED + : ErrorCodes.MM_CDP_FAILED, + message: `[${outcome.code}] ${outcome.message}`, + }; + } + + /** + * Lists and diagnoses the debuggable Hermes targets Metro currently exposes, + * reporting which target would be chosen (or why selection is ambiguous). + * + * @param input - Optional Metro port / appId overrides and an `all` flag to + * bypass the appId filter for discovery. + * @returns A structured report of discovered targets and the selection result. + */ + async hermesTargets(input: HermesTargetsInput): Promise { + const session = this.#hermesSession; + const resolved = session.resolve({ + metroPort: input.metroPort, + appId: input.appId, + platform: this.#backend.platform, + }); + const filterBypassed = Boolean(input.all); + + let targets: HermesTarget[]; + try { + targets = await fetchDiscoveryTargets( + resolved.metroPort, + HERMES_DISCOVERY_TIMEOUT_MS, + ); + } catch (error) { + if (isMetroDownError(error)) { + return { + metroPort: resolved.metroPort, + expectedAppId: resolved.appId, + filterBypassed, + metroDown: true, + targetsDiscovered: 0, + candidates: [], + }; + } + throw error; + } + + const candidates = input.all + ? targets + : targets + .filter((target) => Boolean(target.webSocketDebuggerUrl)) + .filter((target) => target.title !== LEGACY_SYNTHETIC_TITLE) + .filter((target) => target.appId === resolved.appId); + + const result: HermesTargetsResult = { + metroPort: resolved.metroPort, + expectedAppId: resolved.appId, + filterBypassed, + metroDown: false, + targetsDiscovered: targets.length, + candidates: candidates.map(toHermesTargetInfo), + }; + + const selection = selectHermesTarget( + targets, + resolved.appId, + resolved.pinnedDeviceId, + ); + if (selection.ok) { + result.chosen = { + id: selection.target.id, + logicalDeviceId: selection.target.reactNative?.logicalDeviceId, + }; + } else if ( + selection.code === HERMES_MULTIPLE_DEVICES_CODE && + hasAmbiguousTarget(candidates) + ) { + result.ambiguous = selection.message; + } else { + result.noTargetReason = { + code: selection.code, + message: selection.message, + }; + } + + return result; + } +} + +/** + * Projects a raw Metro Hermes target into the trimmed shape returned to agents. + * + * @param target - A discovered Hermes target from Metro. + * @returns The trimmed target info for the tool response. + */ +function toHermesTargetInfo(target: HermesTarget): HermesTargetInfo { + return { + id: target.id, + title: target.title, + appId: target.appId, + logicalDeviceId: target.reactNative?.logicalDeviceId, + nativePageReloads: target.reactNative?.capabilities?.nativePageReloads, + webSocketDebuggerUrl: target.webSocketDebuggerUrl, + }; +} + +/** + * Detects the discovery failure that means Metro is not running (or has no + * registered app), which callers surface as a soft `metroDown` result rather + * than a hard error. + * + * @param error - The error thrown by Metro target discovery. + * @returns True when the failure indicates Metro is unreachable. + */ +function isMetroDownError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes('ECONNREFUSED') || message.includes('fetch failed'); } /** @@ -590,13 +776,18 @@ async function withTimeout( if (remainingMs <= 0) { throw new Error(`Timeout exceeded before ${operationName}`); } - return Promise.race([ - promise, - new Promise((_resolve, reject) => - setTimeout( - () => reject(new Error(`Timeout exceeded during ${operationName}`)), - remainingMs, - ), - ), - ]); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`Timeout exceeded during ${operationName}`)), + remainingMs, + ); + }), + ]); + } finally { + clearTimeout(timer); + } } diff --git a/src/platform/playwright-driver.test.ts b/src/platform/playwright-driver.test.ts index c35c7f6..477105d 100644 --- a/src/platform/playwright-driver.test.ts +++ b/src/platform/playwright-driver.test.ts @@ -384,4 +384,145 @@ describe('PlaywrightPlatformDriver', () => { expect(state).toStrictEqual(mockState); }); }); + + describe('cdp', () => { + function createCdpPage(cdpSession: { + send: ReturnType; + detach: ReturnType; + }) { + return { + context: vi.fn().mockReturnValue({ + newCDPSession: vi.fn().mockResolvedValue(cdpSession), + }), + }; + } + + const TIMEOUT_MS = 30_000; + + it('sends a CDP command and returns the raw result', async () => { + const cdpResult = { result: { value: 'My Page Title' } }; + const cdpSession = { + send: vi.fn().mockResolvedValue(cdpResult), + detach: vi.fn().mockResolvedValue(undefined), + }; + const driver = createDriver(createCdpPage(cdpSession)); + + const outcome = await driver.cdp({ + method: 'Runtime.evaluate', + params: { expression: 'document.title', returnByValue: true }, + timeoutMs: TIMEOUT_MS, + }); + + expect(outcome).toStrictEqual({ ok: true, result: cdpResult }); + expect(cdpSession.send).toHaveBeenCalledWith('Runtime.evaluate', { + expression: 'document.title', + returnByValue: true, + }); + expect(cdpSession.detach).toHaveBeenCalled(); + }); + + it('sends a CDP command with no params', async () => { + const cdpSession = { + send: vi.fn().mockResolvedValue({}), + detach: vi.fn().mockResolvedValue(undefined), + }; + const driver = createDriver(createCdpPage(cdpSession)); + + const outcome = await driver.cdp({ + method: 'Network.enable', + timeoutMs: TIMEOUT_MS, + }); + + expect(outcome.ok).toBe(true); + expect(cdpSession.send).toHaveBeenCalledWith('Network.enable', {}); + }); + + it.each([ + 'Browser.close', + 'Target.closeTarget', + 'Target.disposeBrowserContext', + 'Browser.crashGpuProcess', + ])('blocks %s with MM_CDP_BLOCKED', async (method) => { + const cdpSession = { send: vi.fn(), detach: vi.fn() }; + const driver = createDriver(createCdpPage(cdpSession)); + + const outcome = await driver.cdp({ method, timeoutMs: TIMEOUT_MS }); + + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.code).toBe('MM_CDP_BLOCKED'); + expect(outcome.message).toContain(method); + } + expect(cdpSession.send).not.toHaveBeenCalled(); + }); + + it('returns MM_CDP_FAILED on CDP error and still detaches', async () => { + const cdpSession = { + send: vi + .fn() + .mockRejectedValue(new Error('Protocol error: method not found')), + detach: vi.fn().mockResolvedValue(undefined), + }; + const driver = createDriver(createCdpPage(cdpSession)); + + const outcome = await driver.cdp({ + method: 'Nonexistent.method', + timeoutMs: TIMEOUT_MS, + }); + + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.code).toBe('MM_CDP_FAILED'); + expect(outcome.message).toContain('Nonexistent.method'); + expect(outcome.message).toContain('Protocol error'); + } + expect(cdpSession.detach).toHaveBeenCalled(); + }); + + it('returns MM_CDP_FAILED for non-Error throwables', async () => { + const cdpSession = { + send: vi.fn().mockRejectedValue('string error'), + detach: vi.fn().mockResolvedValue(undefined), + }; + const driver = createDriver(createCdpPage(cdpSession)); + + const outcome = await driver.cdp({ + method: 'Runtime.evaluate', + timeoutMs: TIMEOUT_MS, + }); + + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.code).toBe('MM_CDP_FAILED'); + expect(outcome.message).toContain('string error'); + } + }); + + it('times out long-running CDP calls', async () => { + vi.useFakeTimers(); + try { + const cdpSession = { + send: vi.fn().mockReturnValue(new Promise(() => {})), + detach: vi.fn().mockResolvedValue(undefined), + }; + const driver = createDriver(createCdpPage(cdpSession)); + + const outcomePromise = driver.cdp({ + method: 'Runtime.evaluate', + timeoutMs: 5_000, + }); + + await vi.advanceTimersByTimeAsync(5_000); + const outcome = await outcomePromise; + + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.code).toBe('MM_CDP_FAILED'); + expect(outcome.message).toContain('timed out'); + } + } finally { + vi.useRealTimers(); + } + }); + }); }); diff --git a/src/platform/playwright-driver.ts b/src/platform/playwright-driver.ts index d2d8db5..5f37ff7 100644 --- a/src/platform/playwright-driver.ts +++ b/src/platform/playwright-driver.ts @@ -17,12 +17,28 @@ import type { import type { ISessionManager } from '../server/session-manager.js'; import { isPageClosedError } from '../tools/error-classification.js'; import type { TestIdItem, A11yNodeTrimmed } from '../tools/types/discovery.js'; +import { ErrorCodes } from '../tools/types/errors.js'; +import type { CdpInput } from '../tools/types/tool-inputs.js'; +import type { CdpOutcome } from '../tools/types/tool-outputs.js'; +import { withCdpSession } from '../tools/utils/cdp.js'; import { collectTestIds, collectTrimmedA11ySnapshot, waitForTarget, } from '../tools/utils/discovery.js'; +/** + * Browser CDP methods that would destroy the page/session and break every + * other tool. Kept intentionally small — `cdp` is an escape hatch, not a + * sandbox. + */ +const CDP_BLOCKED_METHODS = new Set([ + 'Browser.close', + 'Target.closeTarget', + 'Target.disposeBrowserContext', + 'Browser.crashGpuProcess', +]); + /** * Platform driver implementation for Playwright-based browser automation. * Wraps existing Playwright interaction and discovery functions behind @@ -282,4 +298,64 @@ export class PlaywrightPlatformDriver implements IPlatformDriver { getPlatform(): PlatformType { return 'browser'; } + + /** + * Send a raw Chrome DevTools Protocol command against the active page over a + * short-lived CDP session. The browser exposes the full Chrome CDP surface + * (Runtime, DOM, Network, Page, ...). Destructive methods are blocked. + * + * @param input - The CDP method, params, and per-command timeout. The + * `metroPort` / `appId` fields are mobile-only and ignored here. + * @returns A discriminated outcome carrying the raw CDP result or an error. + */ + async cdp(input: CdpInput): Promise { + if (CDP_BLOCKED_METHODS.has(input.method)) { + return { + ok: false, + code: ErrorCodes.MM_CDP_BLOCKED, + message: + `CDP method "${input.method}" is blocked because it would destroy the browser session. ` + + `Blocked methods: ${[...CDP_BLOCKED_METHODS].join(', ')}`, + }; + } + + const page = this.#getPage(); + try { + const result = await withCdpSession(page, async (cdpSession) => { + const send = cdpSession.send.bind(cdpSession) as ( + method: string, + params?: Record, + ) => Promise; + + let timer: ReturnType | undefined; + try { + return await Promise.race([ + send(input.method, input.params ?? {}), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `CDP call "${input.method}" timed out after ${input.timeoutMs}ms`, + ), + ), + input.timeoutMs, + ); + }), + ]); + } finally { + clearTimeout(timer); + } + }); + + return { ok: true, result }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + code: ErrorCodes.MM_CDP_FAILED, + message: `CDP "${input.method}" failed: ${message}`, + }; + } + } } diff --git a/src/platform/types.ts b/src/platform/types.ts index c400d32..bdf9f04 100644 --- a/src/platform/types.ts +++ b/src/platform/types.ts @@ -3,6 +3,14 @@ import type { ExtensionState, } from '../capabilities/types.js'; import type { TestIdItem, A11yNodeTrimmed } from '../tools/types/discovery.js'; +import type { + CdpInput, + HermesTargetsInput, +} from '../tools/types/tool-inputs.js'; +import type { + CdpOutcome, + HermesTargetsResult, +} from '../tools/types/tool-outputs.js'; export type PlatformType = 'browser' | 'ios' | 'android'; @@ -86,6 +94,8 @@ export type IPlatformDriver = { getPlatform(): PlatformType; + cdp(input: CdpInput): Promise; + // ---- Mobile-specific (optional) ---- swipe?( @@ -145,4 +155,6 @@ export type IPlatformDriver = { entries: { timestamp: string; level: string; message: string }[]; source: string; }>; + + hermesTargets?(input: HermesTargetsInput): Promise; }; diff --git a/src/server/create-server.test.ts b/src/server/create-server.test.ts index 2242503..549f9a3 100644 --- a/src/server/create-server.test.ts +++ b/src/server/create-server.test.ts @@ -1873,4 +1873,98 @@ describe('createServer observation driver freshness after launch', () => { expect(mobileDriver.getAccessibilityTree).toHaveBeenCalled(); expect(body.observations).toBeDefined(); }); + + it('blocks browser-only tools on a mobile platform', async () => { + await httpRequest(`http://127.0.0.1:${state.port}/launch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ platform: 'ios' }), + }); + + const res = await httpRequest( + `http://127.0.0.1:${state.port}/tool/navigate`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ screen: 'home' }), + }, + ); + const body = (await res.json()) as { + ok: boolean; + error: { code: string }; + }; + + expect(body.ok).toBe(false); + expect(body.error.code).toBe('MM_TOOL_NOT_SUPPORTED_ON_PLATFORM'); + }); + + it('lets mobile-only tools pass platform gating on a mobile platform', async () => { + await httpRequest(`http://127.0.0.1:${state.port}/launch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ platform: 'ios' }), + }); + + const res = await httpRequest( + `http://127.0.0.1:${state.port}/tool/hermes_targets`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ); + const body = (await res.json()) as { + ok: boolean; + error: { code: string }; + }; + + // Gating passes (platform is mobile); the mock driver has no hermesTargets + // method, so the tool itself reports MM_HERMES_NOT_AVAILABLE rather than the + // platform-gating error. + expect(body.ok).toBe(false); + expect(body.error.code).toBe('MM_HERMES_NOT_AVAILABLE'); + }); + + it('blocks mobile-only tools when no platform driver is present', async () => { + mockSM.hasActiveSession.mockReturnValue(true); + + const res = await httpRequest( + `http://127.0.0.1:${state.port}/tool/hermes_targets`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ); + const body = (await res.json()) as { + ok: boolean; + error: { code: string }; + }; + + expect(body.ok).toBe(false); + expect(body.error.code).toBe('MM_TOOL_NOT_SUPPORTED_ON_PLATFORM'); + }); + + it('blocks mobile-only tools on a browser platform', async () => { + mockSM.hasActiveSession.mockReturnValue(true); + ( + mockSM.getPlatformDriver as unknown as ReturnType + ).mockReturnValue({ getPlatform: () => 'browser' }); + + const res = await httpRequest( + `http://127.0.0.1:${state.port}/tool/hermes_targets`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + ); + const body = (await res.json()) as { + ok: boolean; + error: { code: string }; + }; + + expect(body.ok).toBe(false); + expect(body.error.code).toBe('MM_TOOL_NOT_SUPPORTED_ON_PLATFORM'); + }); }); diff --git a/src/server/create-server.ts b/src/server/create-server.ts index 6196dcd..ea87827 100644 --- a/src/server/create-server.ts +++ b/src/server/create-server.ts @@ -18,7 +18,7 @@ import { PlaywrightPlatformDriver } from '../platform/playwright-driver.js'; import { toolRegistry, getToolCategory, - isBrowserOnlyTool, + checkPlatformGate, } from '../tools/registry.js'; import type { ToolCategory } from '../tools/registry.js'; import type { @@ -448,18 +448,13 @@ export function createServer(config: ServerConfig): ServerInstance { const category = getToolCategory(toolName); - if (isBrowserOnlyTool(toolName)) { - const platformDriver = config.sessionManager.getPlatformDriver?.(); - if (platformDriver && platformDriver.getPlatform() !== 'browser') { - res.json({ - ok: false, - error: { - code: 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', - message: `Tool "${toolName}" is not supported on ${platformDriver.getPlatform()} platform`, - }, - }); - return; - } + const gateError = checkPlatformGate( + toolName, + config.sessionManager.getPlatformDriver?.()?.getPlatform(), + ); + if (gateError) { + res.json({ ok: false, error: gateError }); + return; } const inputRecord = validatedInput as Record; diff --git a/src/tools/batch.test.ts b/src/tools/batch.test.ts index 61d59fd..d8b255a 100644 --- a/src/tools/batch.test.ts +++ b/src/tools/batch.test.ts @@ -98,6 +98,24 @@ describe('runStepsTool', () => { } }); + it('normalises a bare ref arg to a11yRef before dispatch', async () => { + const clickHandler = vi.fn().mockResolvedValue({ ok: true, result: 'ok' }); + const context = createMockContext({ + toolRegistry: new Map([['click', clickHandler]]), + }); + + const result = await runStepsTool( + { steps: [{ tool: 'click', args: { ref: 'e1' } }] }, + context, + ); + + expect(clickHandler).toHaveBeenCalledWith( + { a11yRef: 'e1', timeoutMs: 15000 }, + context, + ); + expect(result.ok).toBe(true); + }); + it('returns unknown tool error in the step result', async () => { const context = createMockContext({ toolRegistry: new Map() }); @@ -665,6 +683,39 @@ describe('runStepsTool', () => { } }); + it('halts on a browser-only platform mismatch when stopOnError is set', async () => { + const navigateHandler = vi.fn(); + const clickHandler = vi.fn().mockResolvedValue({ ok: true, result: {} }); + const context = createMockContext({ + toolRegistry: new Map([ + ['navigate', navigateHandler], + ['click', clickHandler], + ]), + driverPlatform: 'android', + }); + + const result = await runStepsTool( + { + steps: [ + { tool: 'navigate', args: { screen: 'home' } }, + { tool: 'click', args: { testId: 'btn' } }, + ], + stopOnError: true, + }, + context, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.steps).toHaveLength(1); + expect(result.result.steps[0].error?.code).toBe( + 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', + ); + expect(navigateHandler).not.toHaveBeenCalled(); + expect(clickHandler).not.toHaveBeenCalled(); + } + }); + it('allows browser-only tools on browser platform', async () => { const navigateHandler = vi.fn().mockResolvedValue({ ok: true, result: {} }); const context = createMockContext({ @@ -683,4 +734,100 @@ describe('runStepsTool', () => { expect(navigateHandler).toHaveBeenCalled(); } }); + + it('rejects mobile-only tools on browser platform', async () => { + const hermesTargetsHandler = vi.fn(); + const context = createMockContext({ + toolRegistry: new Map([['hermes_targets', hermesTargetsHandler]]), + driverPlatform: 'browser', + }); + + const result = await runStepsTool( + { steps: [{ tool: 'hermes_targets', args: {} }] }, + context, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.steps[0].ok).toBe(false); + expect(result.result.steps[0].error?.code).toBe( + 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', + ); + expect(hermesTargetsHandler).not.toHaveBeenCalled(); + } + }); + + it('rejects mobile-only tools when no driver is present', async () => { + const hermesTargetsHandler = vi.fn(); + const context = createMockContext({ + toolRegistry: new Map([['hermes_targets', hermesTargetsHandler]]), + }); + + const result = await runStepsTool( + { steps: [{ tool: 'hermes_targets', args: {} }] }, + context, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.steps[0].ok).toBe(false); + expect(result.result.steps[0].error?.code).toBe( + 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', + ); + expect(hermesTargetsHandler).not.toHaveBeenCalled(); + } + }); + + it('halts on a mobile-only platform mismatch when stopOnError is set', async () => { + const hermesTargetsHandler = vi.fn(); + const clickHandler = vi.fn().mockResolvedValue({ ok: true, result: {} }); + const context = createMockContext({ + toolRegistry: new Map([ + ['hermes_targets', hermesTargetsHandler], + ['click', clickHandler], + ]), + driverPlatform: 'browser', + }); + + const result = await runStepsTool( + { + steps: [ + { tool: 'hermes_targets', args: {} }, + { tool: 'click', args: { testId: 'btn' } }, + ], + stopOnError: true, + }, + context, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.steps).toHaveLength(1); + expect(result.result.steps[0].error?.code).toBe( + 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', + ); + expect(clickHandler).not.toHaveBeenCalled(); + } + }); + + it('allows mobile-only tools on a mobile platform', async () => { + const hermesTargetsHandler = vi + .fn() + .mockResolvedValue({ ok: true, result: {} }); + const context = createMockContext({ + toolRegistry: new Map([['hermes_targets', hermesTargetsHandler]]), + driverPlatform: 'ios', + }); + + const result = await runStepsTool( + { steps: [{ tool: 'hermes_targets', args: {} }] }, + context, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.steps[0].ok).toBe(true); + expect(hermesTargetsHandler).toHaveBeenCalled(); + } + }); }); diff --git a/src/tools/batch.ts b/src/tools/batch.ts index aff5f2a..cc8bfe1 100644 --- a/src/tools/batch.ts +++ b/src/tools/batch.ts @@ -1,4 +1,4 @@ -import { isBrowserOnlyTool } from './registry.js'; +import { checkPlatformGate } from './registry.js'; import type { RunStepsInput, RunStepsResult, StepResult } from './types'; import { ErrorCodes } from './types'; import { createToolError, createToolSuccess } from './utils.js'; @@ -160,29 +160,24 @@ export async function runStepsTool( continue; } - if (isBrowserOnlyTool(tool)) { - const driverPlatform = context.driver?.getPlatform(); - if (driverPlatform && driverPlatform !== 'browser') { - stepResults.push({ - tool, - ok: false, - error: { - code: 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', - message: `Tool "${tool}" is not supported on ${driverPlatform} platform`, - }, - meta: { - durationMs: Date.now() - stepStartTime, - timestamp: new Date().toISOString(), - }, - }); - failed += 1; - - if (stopOnError) { - break; - } + const gateError = checkPlatformGate(tool, context.driver?.getPlatform()); + if (gateError) { + stepResults.push({ + tool, + ok: false, + error: gateError, + meta: { + durationMs: Date.now() - stepStartTime, + timestamp: new Date().toISOString(), + }, + }); + failed += 1; - continue; + if (stopOnError) { + break; } + + continue; } const schema = diff --git a/src/tools/cdp.test.ts b/src/tools/cdp.test.ts index a04da8d..6fd81d7 100644 --- a/src/tools/cdp.test.ts +++ b/src/tools/cdp.test.ts @@ -1,270 +1,140 @@ -import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { cdpTool } from './cdp.js'; import { createMockSessionManager } from './test-utils/mock-factories.js'; +import type { CdpInput } from './types'; import { ErrorCodes } from './types/errors.js'; +import type { IPlatformDriver } from '../platform/types.js'; import type { ToolContext } from '../types/http.js'; -function createMockContext( - options: { - hasActive?: boolean; - cdpSession?: { - send: ReturnType; - detach: ReturnType; - }; - } = {}, -): ToolContext { - const { hasActive = true, cdpSession } = options; - - const mockCdpSession = cdpSession ?? { - send: vi.fn().mockResolvedValue(undefined), - detach: vi.fn().mockResolvedValue(undefined), - }; - - const mockPage = { - context: vi.fn().mockReturnValue({ - newCDPSession: vi.fn().mockResolvedValue(mockCdpSession), - }), - }; +const DEFAULT_TIMEOUT_MS = 30_000; +function createContext(options: { + hasActive?: boolean; + driver?: Partial | undefined; +}): ToolContext { + const { hasActive = true, driver } = options; return { sessionManager: createMockSessionManager({ hasActive }), - page: mockPage, refMap: new Map(), workflowContext: {}, knowledgeStore: {}, + toolRegistry: new Map(), + driver: driver as IPlatformDriver | undefined, } as unknown as ToolContext; } -const DEFAULT_TIMEOUT_MS = 30_000; +const cdpInput: CdpInput = { + method: 'Runtime.evaluate', + params: { expression: '1+1', returnByValue: true }, + timeoutMs: DEFAULT_TIMEOUT_MS, +}; describe('cdpTool', () => { - describe('successful execution', () => { - it('sends a CDP command and returns the raw result', async () => { - const cdpResult = { result: { value: 'My Page Title' } }; - const cdpSession = { - send: vi.fn().mockResolvedValue(cdpResult), - detach: vi.fn().mockResolvedValue(undefined), - }; - const context = createMockContext({ cdpSession }); - - const result = await cdpTool( - { - method: 'Runtime.evaluate', - params: { expression: 'document.title', returnByValue: true }, - timeoutMs: DEFAULT_TIMEOUT_MS, - }, - context, - ); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.method).toBe('Runtime.evaluate'); - expect(result.result.result).toStrictEqual(cdpResult); - } - expect(cdpSession.send).toHaveBeenCalledWith('Runtime.evaluate', { - expression: 'document.title', - returnByValue: true, - }); - expect(cdpSession.detach).toHaveBeenCalled(); - }); - - it('sends a CDP command with no params', async () => { - const cdpResult = {}; - const cdpSession = { - send: vi.fn().mockResolvedValue(cdpResult), - detach: vi.fn().mockResolvedValue(undefined), - }; - const context = createMockContext({ cdpSession }); - - const result = await cdpTool( - { method: 'Network.enable', timeoutMs: DEFAULT_TIMEOUT_MS }, - context, - ); + afterEach(() => { + vi.restoreAllMocks(); + }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.result.method).toBe('Network.enable'); - expect(result.result.result).toStrictEqual(cdpResult); - } - expect(cdpSession.send).toHaveBeenCalledWith('Network.enable', {}); + it('returns MM_NO_ACTIVE_SESSION when no session is active', async () => { + const context = createContext({ + hasActive: false, + driver: { cdp: vi.fn() }, }); - it('detaches CDP session after successful call', async () => { - const cdpSession = { - send: vi.fn().mockResolvedValue({ root: { nodeId: 1 } }), - detach: vi.fn().mockResolvedValue(undefined), - }; - const context = createMockContext({ cdpSession }); - - await cdpTool( - { - method: 'DOM.getDocument', - params: { depth: 2 }, - timeoutMs: DEFAULT_TIMEOUT_MS, - }, - context, - ); + const result = await cdpTool(cdpInput, context); - expect(cdpSession.detach).toHaveBeenCalled(); - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_NO_ACTIVE_SESSION); + } }); - describe('blocked methods', () => { - const blockedMethods = [ - 'Browser.close', - 'Target.closeTarget', - 'Target.disposeBrowserContext', - 'Browser.crashGpuProcess', - ]; + it('returns MM_CDP_FAILED when no driver is available', async () => { + const context = createContext({ driver: undefined }); - for (const method of blockedMethods) { - it(`blocks ${method}`, async () => { - const cdpSession = { - send: vi.fn(), - detach: vi.fn(), - }; - const context = createMockContext({ cdpSession }); + const result = await cdpTool(cdpInput, context); - const result = await cdpTool( - { method, timeoutMs: DEFAULT_TIMEOUT_MS }, - context, - ); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_CDP_BLOCKED); - expect(result.error.message).toContain(method); - expect(result.error.message).toContain('blocked'); - } - expect(cdpSession.send).not.toHaveBeenCalled(); - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_CDP_FAILED); + expect(result.error.message).toContain('No platform driver'); } - - it('allows non-blocked methods', async () => { - const cdpSession = { - send: vi.fn().mockResolvedValue({}), - detach: vi.fn().mockResolvedValue(undefined), - }; - const context = createMockContext({ cdpSession }); - - const result = await cdpTool( - { - method: 'Runtime.evaluate', - params: { expression: '1+1' }, - timeoutMs: DEFAULT_TIMEOUT_MS, - }, - context, - ); - - expect(result.ok).toBe(true); - }); }); - describe('error handling', () => { - it('returns MM_CDP_FAILED on CDP error', async () => { - const cdpSession = { - send: vi - .fn() - .mockRejectedValue(new Error('Protocol error: method not found')), - detach: vi.fn().mockResolvedValue(undefined), - }; - const context = createMockContext({ cdpSession }); - - const result = await cdpTool( - { method: 'Nonexistent.method', timeoutMs: DEFAULT_TIMEOUT_MS }, - context, - ); + it('dispatches to driver.cdp and returns the raw result', async () => { + const cdp = vi.fn().mockResolvedValue({ ok: true, result: { value: 2 } }); + const context = createContext({ driver: { cdp } }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_CDP_FAILED); - expect(result.error.message).toContain('Nonexistent.method'); - expect(result.error.message).toContain('Protocol error'); - } - }); + const result = await cdpTool(cdpInput, context); - it('detaches CDP session even on failure', async () => { - const cdpSession = { - send: vi.fn().mockRejectedValue(new Error('boom')), - detach: vi.fn().mockResolvedValue(undefined), - }; - const context = createMockContext({ cdpSession }); - - await cdpTool( - { method: 'Runtime.evaluate', timeoutMs: DEFAULT_TIMEOUT_MS }, - context, - ); + expect(cdp).toHaveBeenCalledWith(cdpInput); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.method).toBe('Runtime.evaluate'); + expect(result.result.result).toStrictEqual({ value: 2 }); + } + }); - expect(cdpSession.detach).toHaveBeenCalled(); + it('passes through the error code from a blocked outcome', async () => { + const cdp = vi.fn().mockResolvedValue({ + ok: false, + code: ErrorCodes.MM_CDP_BLOCKED, + message: 'CDP method "Browser.close" is blocked', }); + const context = createContext({ driver: { cdp } }); - it('handles non-Error throwables', async () => { - const cdpSession = { - send: vi.fn().mockRejectedValue('string error'), - detach: vi.fn().mockResolvedValue(undefined), - }; - const context = createMockContext({ cdpSession }); - - const result = await cdpTool( - { method: 'Runtime.evaluate', timeoutMs: DEFAULT_TIMEOUT_MS }, - context, - ); + const result = await cdpTool( + { method: 'Browser.close', timeoutMs: DEFAULT_TIMEOUT_MS }, + context, + ); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_CDP_FAILED); - expect(result.error.message).toContain('string error'); - } - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_CDP_BLOCKED); + expect(result.error.message).toContain('Browser.close'); + } }); - describe('timeout', () => { - beforeEach(() => { - vi.useFakeTimers(); + it('passes through a failed outcome with its preserved message', async () => { + const cdp = vi.fn().mockResolvedValue({ + ok: false, + code: ErrorCodes.MM_CDP_FAILED, + message: '[HERMES_TARGET_NOT_FOUND] No Hermes debug target found', }); + const context = createContext({ driver: { cdp } }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('times out long-running CDP calls', async () => { - const cdpSession = { - send: vi.fn().mockReturnValue(new Promise(() => {})), - detach: vi.fn().mockResolvedValue(undefined), - }; - const context = createMockContext({ cdpSession }); + const result = await cdpTool(cdpInput, context); - const resultPromise = cdpTool( - { method: 'Runtime.evaluate', timeoutMs: 5_000 }, - context, - ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_CDP_FAILED); + expect(result.error.message).toContain('HERMES_TARGET_NOT_FOUND'); + } + }); - await vi.advanceTimersByTimeAsync(5_000); + it('returns MM_CDP_FAILED when the driver throws', async () => { + const cdp = vi.fn().mockRejectedValue(new Error('socket boom')); + const context = createContext({ driver: { cdp } }); - const result = await resultPromise; + const result = await cdpTool(cdpInput, context); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_CDP_FAILED); - expect(result.error.message).toContain('timed out'); - } - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_CDP_FAILED); + expect(result.error.message).toContain('socket boom'); + } }); - describe('session validation', () => { - it('returns error when no active session', async () => { - const context = createMockContext({ hasActive: false }); + it('handles non-Error throwables', async () => { + const cdp = vi.fn().mockRejectedValue('string error'); + const context = createContext({ driver: { cdp } }); - const result = await cdpTool( - { method: 'Runtime.evaluate', timeoutMs: DEFAULT_TIMEOUT_MS }, - context, - ); + const result = await cdpTool(cdpInput, context); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.code).toBe(ErrorCodes.MM_NO_ACTIVE_SESSION); - } - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_CDP_FAILED); + expect(result.error.message).toContain('string error'); + } }); }); diff --git a/src/tools/cdp.ts b/src/tools/cdp.ts index 5946a74..0c35de0 100644 --- a/src/tools/cdp.ts +++ b/src/tools/cdp.ts @@ -1,6 +1,6 @@ -import type { CdpInput, CdpResult } from './types'; +import type { CdpInput } from './types'; import { ErrorCodes } from './types/errors.js'; -import { withCdpSession } from './utils/cdp.js'; +import type { CdpResult } from './types/tool-outputs.js'; import { createToolError, createToolSuccess, @@ -9,25 +9,18 @@ import { import type { ToolContext, ToolResponse } from '../types/http.js'; /** - * CDP methods that would destroy session state and break all other tools. - * Kept intentionally small — this is an escape hatch, not a sandbox. - */ -const CDP_BLOCKED_METHODS = new Set([ - 'Browser.close', - 'Target.closeTarget', - 'Target.disposeBrowserContext', - 'Browser.crashGpuProcess', -]); - -/** - * Sends a raw CDP command against the active page. + * Sends a raw CDP command against the active session, dispatched through the + * platform driver. On the browser this targets the page's Chrome CDP session + * (full Runtime/DOM/Network/Page surface); on mobile it targets the React + * Native Hermes JS runtime via Metro (Runtime/Debugger subset only). Each + * driver owns its own destructive-method blocklist. * - * Escape hatch for cases where structured tools are insufficient. - * State-changing methods (e.g. Page.navigate) bypass session tracking — - * run describe_screen afterward to re-sync. + * Escape hatch for cases where structured tools are insufficient. State-changing + * methods bypass session tracking — run describe_screen afterward to re-sync. * - * @param input - The CDP command input (method, params, timeout). - * @param context - The tool execution context with session and page access. + * @param input - The CDP command input (method, params, timeout, and optional + * mobile-only metroPort/appId overrides). + * @param context - The tool execution context with session and driver access. * @returns The CDP command result or an error response. */ export async function cdpTool( @@ -39,52 +32,25 @@ export async function cdpTool( return missingSession; } - if (CDP_BLOCKED_METHODS.has(input.method)) { + const { driver } = context; + if (!driver) { return createToolError( - ErrorCodes.MM_CDP_BLOCKED, - `CDP method "${input.method}" is blocked because it would destroy the browser session. ` + - `Blocked methods: ${[...CDP_BLOCKED_METHODS].join(', ')}`, + ErrorCodes.MM_CDP_FAILED, + 'No platform driver available', ); } try { - return await withCdpSession(context.page, async (cdpSession) => { - const { timeoutMs } = input; - - // Playwright types send() for known CDP methods only; widen for arbitrary passthrough. - const send = cdpSession.send.bind(cdpSession) as ( - method: string, - params?: Record, - ) => Promise; - - let timer: ReturnType | undefined; - try { - const result = await Promise.race([ - send(input.method, input.params ?? {}), - new Promise((_resolve, reject) => { - timer = setTimeout( - () => - reject( - new Error( - `CDP call "${input.method}" timed out after ${timeoutMs}ms`, - ), - ), - timeoutMs, - ); - }), - ]); - - return createToolSuccess({ - method: input.method, - result, - }); - } finally { - clearTimeout(timer); - } + const outcome = await driver.cdp(input); + if (!outcome.ok) { + return createToolError(outcome.code, outcome.message); + } + return createToolSuccess({ + method: input.method, + result: outcome.result, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - return createToolError( ErrorCodes.MM_CDP_FAILED, `CDP "${input.method}" failed: ${message}`, diff --git a/src/tools/hermes.test.ts b/src/tools/hermes.test.ts new file mode 100644 index 0000000..ba95aca --- /dev/null +++ b/src/tools/hermes.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { hermesTargetsTool } from './hermes.js'; +import { createMockSessionManager } from './test-utils/mock-factories.js'; +import type { HermesTargetsInput } from './types'; +import { ErrorCodes } from './types/errors.js'; +import type { IPlatformDriver } from '../platform/types.js'; +import type { ToolContext } from '../types/http.js'; + +function createContext(options: { + hasActive?: boolean; + driver?: Partial | undefined; +}): ToolContext { + const { hasActive = true, driver } = options; + return { + sessionManager: createMockSessionManager({ hasActive }), + refMap: new Map(), + workflowContext: {}, + knowledgeStore: {}, + toolRegistry: new Map(), + driver: driver as IPlatformDriver | undefined, + } as unknown as ToolContext; +} + +const targetsInput: HermesTargetsInput = {}; + +describe('hermesTargetsTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns MM_NO_ACTIVE_SESSION when no session is active', async () => { + const context = createContext({ + hasActive: false, + driver: { hermesTargets: vi.fn() }, + }); + + const result = await hermesTargetsTool(targetsInput, context); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_NO_ACTIVE_SESSION); + } + }); + + it('returns MM_HERMES_NOT_AVAILABLE when driver lacks hermesTargets', async () => { + const context = createContext({ driver: {} }); + + const result = await hermesTargetsTool(targetsInput, context); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_HERMES_NOT_AVAILABLE); + } + }); + + it('returns MM_HERMES_NOT_AVAILABLE when no driver is present', async () => { + const context = createContext({ driver: undefined }); + + const result = await hermesTargetsTool(targetsInput, context); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_HERMES_NOT_AVAILABLE); + } + }); + + it('delegates to driver.hermesTargets and returns the report', async () => { + const report = { + metroPort: 8081, + expectedAppId: 'io.metamask', + filterBypassed: false, + metroDown: false, + targetsDiscovered: 1, + candidates: [], + chosen: { id: 'page-1', logicalDeviceId: 'dev-1' }, + }; + const hermesTargets = vi.fn().mockResolvedValue(report); + const context = createContext({ driver: { hermesTargets } }); + + const result = await hermesTargetsTool(targetsInput, context); + + expect(hermesTargets).toHaveBeenCalledWith(targetsInput); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result).toStrictEqual(report); + } + }); + + it('returns MM_HERMES_FAILED when the driver throws', async () => { + const hermesTargets = vi + .fn() + .mockRejectedValue(new Error('discovery boom')); + const context = createContext({ driver: { hermesTargets } }); + + const result = await hermesTargetsTool(targetsInput, context); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_HERMES_FAILED); + expect(result.error.message).toContain('discovery boom'); + } + }); + + it('returns MM_HERMES_FAILED for non-Error throwables', async () => { + const hermesTargets = vi.fn().mockRejectedValue('discovery string error'); + const context = createContext({ driver: { hermesTargets } }); + + const result = await hermesTargetsTool(targetsInput, context); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe(ErrorCodes.MM_HERMES_FAILED); + expect(result.error.message).toContain('discovery string error'); + } + }); +}); diff --git a/src/tools/hermes.ts b/src/tools/hermes.ts new file mode 100644 index 0000000..21749b6 --- /dev/null +++ b/src/tools/hermes.ts @@ -0,0 +1,52 @@ +import type { HermesTargetsInput } from './types'; +import { ErrorCodes } from './types/errors.js'; +import type { HermesTargetsResult } from './types/tool-outputs.js'; +import { + createToolError, + createToolSuccess, + requireActiveSession, +} from './utils.js'; +import type { ToolContext, ToolResponse } from '../types/http.js'; + +/** + * Lists and diagnoses the debuggable React Native Hermes targets Metro exposes, + * reporting which target would be chosen or why selection is ambiguous. Mobile + * only (iOS/Android) — the browser platform has no Hermes runtime. Useful to + * confirm Metro is running and the app is registered, and to discover the real + * appId via `all`. + * + * Raw CDP execution lives in the unified `cdp` tool; this tool only inspects + * the Metro target list, which has no browser equivalent. + * + * @param input - Optional Metro port / appId overrides and an `all` flag. + * @param context - The tool execution context with session and driver access. + * @returns The structured targets report or an error response. + */ +export async function hermesTargetsTool( + input: HermesTargetsInput, + context: ToolContext, +): Promise> { + const missingSession = requireActiveSession(context); + if (missingSession) { + return missingSession; + } + + const { driver } = context; + if (!driver?.hermesTargets) { + return createToolError( + ErrorCodes.MM_HERMES_NOT_AVAILABLE, + 'Hermes targets is only available on mobile (iOS/Android) sessions.', + ); + } + + try { + const result = await driver.hermesTargets(input); + return createToolSuccess(result); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return createToolError( + ErrorCodes.MM_HERMES_FAILED, + `Hermes targets discovery failed: ${message}`, + ); + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 5c2a4c2..d451904 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -4,6 +4,7 @@ export * from './cleanup.js'; export * from './clipboard.js'; export * from './context.js'; export * from './discovery-tools.js'; +export * from './hermes.js'; export * from './interaction.js'; export * from './knowledge.js'; export * from './launch.js'; diff --git a/src/tools/platform-gating.test.ts b/src/tools/platform-gating.test.ts index 3f13606..0c372e4 100644 --- a/src/tools/platform-gating.test.ts +++ b/src/tools/platform-gating.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { isBrowserOnlyTool } from './registry.js'; +import { + checkPlatformGate, + isBrowserOnlyTool, + isMobileOnlyTool, +} from './registry.js'; describe('isBrowserOnlyTool', () => { it('returns true for browser-only tools', () => { @@ -8,7 +12,6 @@ describe('isBrowserOnlyTool', () => { expect(isBrowserOnlyTool('switch_to_tab')).toBe(true); expect(isBrowserOnlyTool('close_tab')).toBe(true); expect(isBrowserOnlyTool('wait_for_notification')).toBe(true); - expect(isBrowserOnlyTool('cdp')).toBe(true); expect(isBrowserOnlyTool('clipboard')).toBe(true); expect(isBrowserOnlyTool('mock_network')).toBe(true); expect(isBrowserOnlyTool('build')).toBe(true); @@ -26,9 +29,75 @@ describe('isBrowserOnlyTool', () => { expect(isBrowserOnlyTool('list_testids')).toBe(false); expect(isBrowserOnlyTool('accessibility_snapshot')).toBe(false); expect(isBrowserOnlyTool('get_text')).toBe(false); + expect(isBrowserOnlyTool('cdp')).toBe(false); + }); + + it('returns false for the mobile-only hermes tools', () => { + expect(isBrowserOnlyTool('hermes_targets')).toBe(false); }); it('returns false for unknown tools', () => { expect(isBrowserOnlyTool('nonexistent_tool')).toBe(false); }); }); + +describe('isMobileOnlyTool', () => { + it('returns true for mobile-only hermes tools', () => { + expect(isMobileOnlyTool('hermes_targets')).toBe(true); + }); + + it('returns false for browser-only and cross-platform tools', () => { + expect(isMobileOnlyTool('cdp')).toBe(false); + expect(isMobileOnlyTool('click')).toBe(false); + expect(isMobileOnlyTool('navigate')).toBe(false); + expect(isMobileOnlyTool('screenshot')).toBe(false); + expect(isMobileOnlyTool('build')).toBe(false); + }); + + it('returns false for unknown tools', () => { + expect(isMobileOnlyTool('nonexistent_tool')).toBe(false); + }); +}); + +describe('checkPlatformGate', () => { + it('allows browser-only tools on the browser platform', () => { + expect(checkPlatformGate('navigate', 'browser')).toBeUndefined(); + }); + + it('allows browser-only tools when no platform driver is present', () => { + expect(checkPlatformGate('navigate', undefined)).toBeUndefined(); + }); + + it('blocks browser-only tools on a mobile platform', () => { + expect(checkPlatformGate('navigate', 'ios')).toStrictEqual({ + code: 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', + message: 'Tool "navigate" is not supported on ios platform', + }); + }); + + it('allows mobile-only tools on a mobile platform', () => { + expect(checkPlatformGate('hermes_targets', 'android')).toBeUndefined(); + }); + + it('blocks mobile-only tools on the browser platform', () => { + expect(checkPlatformGate('hermes_targets', 'browser')).toStrictEqual({ + code: 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', + message: + 'Tool "hermes_targets" is only supported on mobile (iOS/Android) platforms', + }); + }); + + it('blocks mobile-only tools when no platform driver is present', () => { + expect(checkPlatformGate('hermes_targets', undefined)).toStrictEqual({ + code: 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', + message: + 'Tool "hermes_targets" is only supported on mobile (iOS/Android) platforms', + }); + }); + + it('allows cross-platform tools on any platform', () => { + expect(checkPlatformGate('cdp', 'ios')).toBeUndefined(); + expect(checkPlatformGate('cdp', 'browser')).toBeUndefined(); + expect(checkPlatformGate('cdp', undefined)).toBeUndefined(); + }); +}); diff --git a/src/tools/registry.test.ts b/src/tools/registry.test.ts index 9bd1316..c52c956 100644 --- a/src/tools/registry.test.ts +++ b/src/tools/registry.test.ts @@ -5,6 +5,7 @@ import { TOOL_CATEGORIES, getToolCategory, isBrowserOnlyTool, + isMobileOnlyTool, } from './registry.js'; describe('toolRegistry', () => { @@ -21,6 +22,8 @@ describe('toolRegistry', () => { 'clipboard', 'mock_network', 'run_steps', + 'cdp', + 'hermes_targets', ]; for (const toolName of expectedTools) { @@ -37,7 +40,7 @@ describe('toolRegistry', () => { }); it('has the expected number of entries', () => { - expect(toolRegistry.size).toBe(30); + expect(toolRegistry.size).toBe(31); }); it('stores only functions as values', () => { @@ -93,7 +96,6 @@ describe('isBrowserOnlyTool', () => { 'switch_to_tab', 'close_tab', 'wait_for_notification', - 'cdp', 'clipboard', 'mock_network', 'build', @@ -101,10 +103,28 @@ describe('isBrowserOnlyTool', () => { expect(isBrowserOnlyTool(toolName)).toBe(true); }); - it.each(['click', 'type', 'launch', 'screenshot', 'get_text'])( + it.each([ + 'click', + 'type', + 'launch', + 'screenshot', + 'get_text', + 'cdp', + 'hermes_targets', + ])('returns false for %s', (toolName) => { + expect(isBrowserOnlyTool(toolName)).toBe(false); + }); +}); + +describe('isMobileOnlyTool', () => { + it.each(['hermes_targets'])('returns true for %s', (toolName) => { + expect(isMobileOnlyTool(toolName)).toBe(true); + }); + + it.each(['click', 'type', 'cdp', 'navigate', 'screenshot', 'nonexistent'])( 'returns false for %s', (toolName) => { - expect(isBrowserOnlyTool(toolName)).toBe(false); + expect(isMobileOnlyTool(toolName)).toBe(false); }, ); }); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index f03444f..205c54f 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -9,6 +9,7 @@ import { describeScreenTool, listTestIdsTool, } from './discovery-tools.js'; +import { hermesTargetsTool } from './hermes.js'; import { clickTool, getTextTool, @@ -37,6 +38,8 @@ import { seedContractsTool, } from './seeding.js'; import { getStateTool } from './state.js'; +import { ErrorCodes } from './types/errors.js'; +import type { PlatformType } from '../platform/types.js'; import type { ToolFunction } from '../types/http.js'; // holds tools with heterogeneous parameter types. TypeScript's contravariant @@ -73,6 +76,7 @@ export const toolRegistry = new Map>([ ['get_context', getContextTool], ['clipboard', clipboardTool], ['cdp', cdpTool], + ['hermes_targets', hermesTargetsTool], ['mock_network', mockNetworkTool], ]); @@ -95,7 +99,8 @@ export const TOOL_CATEGORIES: Record = { seed_contracts: 'mutating', cdp: 'mutating', mock_network: 'mutating', - // READONLY (10) + // READONLY (11) + hermes_targets: 'readonly', knowledge_last: 'readonly', knowledge_search: 'readonly', knowledge_summarize: 'readonly', @@ -135,7 +140,6 @@ const BROWSER_ONLY_TOOLS = new Set([ 'switch_to_tab', 'close_tab', 'wait_for_notification', - 'cdp', 'clipboard', 'mock_network', 'build', @@ -150,3 +154,51 @@ const BROWSER_ONLY_TOOLS = new Set([ export function isBrowserOnlyTool(toolName: string): boolean { return BROWSER_ONLY_TOOLS.has(toolName); } + +const MOBILE_ONLY_TOOLS = new Set(['hermes_targets']); + +/** + * Checks if a tool is only available on mobile (iOS/Android) platforms. + * + * @param toolName - The registered tool name to check. + * @returns True if the tool is mobile-only, false otherwise. + */ +export function isMobileOnlyTool(toolName: string): boolean { + return MOBILE_ONLY_TOOLS.has(toolName); +} + +export type PlatformGateError = { + code: typeof ErrorCodes.MM_TOOL_NOT_SUPPORTED_ON_PLATFORM; + message: string; +}; + +/** + * Single source of truth for browser-only / mobile-only platform gating, + * shared by the HTTP route handler and the run_steps batch executor so the two + * paths cannot drift on error code or message. An undefined platform (no active + * platform driver) is treated as "not mobile", so mobile-only tools are gated. + * + * @param toolName - The registered tool name being invoked. + * @param platform - The active platform driver's platform, or undefined when no + * platform driver is available. + * @returns A gating error when the tool may not run on the platform, otherwise + * undefined. + */ +export function checkPlatformGate( + toolName: string, + platform: PlatformType | undefined, +): PlatformGateError | undefined { + if (isBrowserOnlyTool(toolName) && platform && platform !== 'browser') { + return { + code: ErrorCodes.MM_TOOL_NOT_SUPPORTED_ON_PLATFORM, + message: `Tool "${toolName}" is not supported on ${platform} platform`, + }; + } + if (isMobileOnlyTool(toolName) && (!platform || platform === 'browser')) { + return { + code: ErrorCodes.MM_TOOL_NOT_SUPPORTED_ON_PLATFORM, + message: `Tool "${toolName}" is only supported on mobile (iOS/Android) platforms`, + }; + } + return undefined; +} diff --git a/src/tools/types/errors.ts b/src/tools/types/errors.ts index e1d4d0e..1c4446f 100644 --- a/src/tools/types/errors.ts +++ b/src/tools/types/errors.ts @@ -43,6 +43,11 @@ export const ErrorCodes = { MM_CDP_BLOCKED: 'MM_CDP_BLOCKED', MM_CDP_FAILED: 'MM_CDP_FAILED', + MM_HERMES_FAILED: 'MM_HERMES_FAILED', // underlying device-mcp HERMES_* code is preserved in the message + MM_HERMES_NOT_AVAILABLE: 'MM_HERMES_NOT_AVAILABLE', // hermes tools require a mobile (iOS/Android) driver + + MM_TOOL_NOT_SUPPORTED_ON_PLATFORM: 'MM_TOOL_NOT_SUPPORTED_ON_PLATFORM', // tool gated off the active platform (browser-only / mobile-only) + MM_UNKNOWN_TOOL: 'MM_UNKNOWN_TOOL', MM_INTERNAL_ERROR: 'MM_INTERNAL_ERROR', } as const; diff --git a/src/tools/types/tool-inputs.ts b/src/tools/types/tool-inputs.ts index cd04775..04db81f 100644 --- a/src/tools/types/tool-inputs.ts +++ b/src/tools/types/tool-inputs.ts @@ -164,6 +164,22 @@ export type CdpInput = { params?: Record; /** Always populated after Zod validation (schema default: 30 000). */ timeoutMs: number; + /** + * Mobile only: override the Metro inspector proxy port for the Hermes + * runtime. Ignored on the browser platform. + */ + metroPort?: number; + /** + * Mobile only: override the expected app bundle identifier when selecting + * the Hermes debug target. Ignored on the browser platform. + */ + appId?: string; +}; + +export type HermesTargetsInput = { + metroPort?: number; + appId?: string; + all?: boolean; }; export type NetworkMockResponse = { diff --git a/src/tools/types/tool-outputs.ts b/src/tools/types/tool-outputs.ts index 3c831b2..ad5ae33 100644 --- a/src/tools/types/tool-outputs.ts +++ b/src/tools/types/tool-outputs.ts @@ -159,6 +159,31 @@ export type CdpResult = { result: unknown; }; +export type CdpOutcome = + | { ok: true; result: unknown } + | { ok: false; code: string; message: string }; + +export type HermesTargetInfo = { + id?: string; + title?: string; + appId?: string; + logicalDeviceId?: string; + nativePageReloads?: boolean; + webSocketDebuggerUrl?: string; +}; + +export type HermesTargetsResult = { + metroPort: number; + expectedAppId: string; + filterBypassed: boolean; + metroDown: boolean; + targetsDiscovered: number; + candidates: HermesTargetInfo[]; + chosen?: { id?: string; logicalDeviceId?: string }; + ambiguous?: string; + noTargetReason?: { code: string; message: string }; +}; + export type MockNetworkResult = | { action: 'add'; diff --git a/src/validation/schemas.ts b/src/validation/schemas.ts index f28446d..0dfa4dd 100644 --- a/src/validation/schemas.ts +++ b/src/validation/schemas.ts @@ -640,7 +640,9 @@ export const cdpInputSchema = z.object({ .string() .min(1) .describe( - 'CDP method name (e.g., "Runtime.evaluate", "DOM.getDocument", "Network.enable")', + 'CDP method name. Browser: full Chrome surface (e.g., "Runtime.evaluate", ' + + '"DOM.getDocument", "Network.enable"). Mobile (Hermes): JS-engine subset ' + + 'only (e.g., "Runtime.evaluate", "Debugger.enable") — no DOM/Page/Network.', ), params: z .record(z.string(), z.unknown()) @@ -656,12 +658,48 @@ export const cdpInputSchema = z.object({ 'Per-command timeout in milliseconds. ' + 'Capped at 30 000 to stay within the server request-queue ceiling.', ), + metroPort: z + .number() + .int() + .min(1) + .max(65535) + .describe( + 'Mobile only: override the Metro inspector proxy port. Ignored on browser.', + ) + .optional(), + appId: z + .string() + .min(1) + .describe( + 'Mobile only: override the expected app bundle identifier. Ignored on browser.', + ) + .optional(), +}); + +export const hermesTargetsInputSchema = z.object({ + metroPort: z + .number() + .int() + .min(1) + .max(65535) + .describe('Override the Metro inspector proxy port for this call') + .optional(), + appId: z + .string() + .min(1) + .describe('Override the expected app bundle identifier for this call') + .optional(), + all: z + .boolean() + .default(false) + .describe('Bypass the appId filter and list ALL candidates (diagnostics)'), }); export type SetContextInputZ = z.infer; export type GetContextInputZ = z.infer; export type ClipboardInputZ = z.infer; export type CdpInputZ = z.infer; +export type HermesTargetsInputZ = z.infer; export type MockNetworkInputZ = z.infer; export const toolSchemas = { @@ -694,6 +732,7 @@ export const toolSchemas = { get_context: getContextInputSchema, clipboard: clipboardInputSchema, cdp: cdpInputSchema, + hermes_targets: hermesTargetsInputSchema, mock_network: mockNetworkInputSchema, } as const; From ae706a8843fa23fe4068a4ff521454cee62571a0 Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Wed, 15 Jul 2026 11:20:35 +0100 Subject: [PATCH 2/2] feat: support base64 screenshots in mobile platform driver Wire up the device-mcp encode option so mobile screenshots can return base64 data when includeBase64 is requested, instead of always empty. --- src/platform/mobile-platform-driver.test.ts | 35 ++++++++++++++++----- src/platform/mobile-platform-driver.ts | 7 +++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/platform/mobile-platform-driver.test.ts b/src/platform/mobile-platform-driver.test.ts index 9eb46f4..ec8a589 100644 --- a/src/platform/mobile-platform-driver.test.ts +++ b/src/platform/mobile-platform-driver.test.ts @@ -38,11 +38,13 @@ function createMockBackend( bundleId: 'io.metamask', state: 'Running', }), - screenshot: vi.fn().mockResolvedValue({ - data: 'base64data', - format: 'png' as const, - path: '/tmp/screenshot.png', - }), + screenshot: vi.fn().mockImplementation((_outputPath, options) => + Promise.resolve({ + data: options?.encode === false ? undefined : 'base64data', + format: 'png' as const, + path: '/tmp/screenshot.png', + }), + ), openApp: vi.fn(), closeApp: vi.fn(), pressButton: vi.fn(), @@ -574,11 +576,15 @@ describe('MobilePlatformDriver', () => { }); describe('screenshot', () => { - it('returns mapped result with empty base64 and zero dimensions', async () => { - const driver = new MobilePlatformDriver(createMockBackend()); + it('skips base64 encoding by default and returns zero dimensions', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); const result = await driver.screenshot({ name: 'test-shot' }); + expect(backend.screenshot).toHaveBeenCalledWith(undefined, { + encode: false, + }); expect(result).toStrictEqual({ path: '/tmp/screenshot.png', base64: '', @@ -587,6 +593,21 @@ describe('MobilePlatformDriver', () => { }); }); + it('includes base64 when requested', async () => { + const backend = createMockBackend(); + const driver = new MobilePlatformDriver(backend); + + const result = await driver.screenshot({ + name: 'test-shot', + includeBase64: true, + }); + + expect(backend.screenshot).toHaveBeenCalledWith(undefined, { + encode: true, + }); + expect(result.base64).toBe('base64data'); + }); + it('uses name as fallback path when backend returns no path', async () => { const backend = createMockBackend({ screenshot: vi.fn().mockResolvedValue({ diff --git a/src/platform/mobile-platform-driver.ts b/src/platform/mobile-platform-driver.ts index 033d06d..2253f46 100644 --- a/src/platform/mobile-platform-driver.ts +++ b/src/platform/mobile-platform-driver.ts @@ -213,10 +213,13 @@ export class MobilePlatformDriver implements IPlatformDriver { async screenshot( options: PlatformScreenshotOptions, ): Promise { - const result = await this.#backend.screenshot(); + const includeBase64 = options.includeBase64 === true; + const result = await this.#backend.screenshot(undefined, { + encode: includeBase64, + }); return { path: result.path ?? `${options.name}.${result.format}`, - base64: '', + base64: result.data ?? '', width: 0, height: 0, };