From 47f1889c99f47884610ce93563295eb2a4a9489a Mon Sep 17 00:00:00 2001 From: cryptotavares Date: Wed, 1 Jul 2026 14:08:31 +0100 Subject: [PATCH] feat: add Hermes CDP tools for React Native runtime access Add hermes_cdp and hermes_targets MCP tools that speak Chrome DevTools Protocol to the React Native Hermes JS runtime via Metro's inspector proxy (iOS and Android). Includes standalone HermesSession state for Metro port, app bundle ID, and device pinning, plus target discovery, selection, and ambiguity handling. --- CHANGELOG.md | 4 + README.md | 49 +- src/hermes/hermes-cdp.test.ts | 1111 +++++++++++++++++++++++++++++++++ src/hermes/hermes-cdp.ts | 900 ++++++++++++++++++++++++++ src/hermes/session.test.ts | 215 +++++++ src/hermes/session.ts | 261 ++++++++ src/index.ts | 30 + src/server.test.ts | 6 +- src/server.ts | 5 + src/tools/hermes-cdp.test.ts | 384 ++++++++++++ src/tools/hermes-cdp.ts | 118 ++++ src/tools/hermes-targets.ts | 166 +++++ src/tools/index.ts | 2 + 13 files changed, 3247 insertions(+), 4 deletions(-) create mode 100644 src/hermes/hermes-cdp.test.ts create mode 100644 src/hermes/hermes-cdp.ts create mode 100644 src/hermes/session.test.ts create mode 100644 src/hermes/session.ts create mode 100644 src/tools/hermes-cdp.test.ts create mode 100644 src/tools/hermes-cdp.ts create mode 100644 src/tools/hermes-targets.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba7a1a..38365c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add Hermes CDP tools (`hermes_cdp`, `hermes_targets`) for inspecting the React Native Hermes JS runtime via Metro + ## [0.2.0] ### Added diff --git a/README.md b/README.md index 505b146..e0c1608 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Provides device interaction tools for LLM agents to inspect UI state, interact w - **iOS local**: Xcode Command Line Tools (for `xcrun simctl`) + [IDB](https://fbidb.io/) for UI interaction (`brew tap facebook/fb && brew install idb-companion && pip3 install fb-idb`) - **Android local**: ADB (Android SDK platform-tools) — auto-discovered from `$ANDROID_HOME`, `$ANDROID_SDK_ROOT`, or `~/Library/Android/sdk` - **Remote/BrowserStack**: No local tools needed — connects via Appium W3C WebDriver HTTP +- **Hermes CDP** (debugging the React Native JS runtime): uses the global `WebSocket` API. Node 22+ works out of the box; **Node 20 requires launching with `NODE_OPTIONS="--experimental-websocket"`**. ## Installation @@ -157,6 +158,13 @@ The `.device-session` file is typically written by the test runner when it creat | `device_dismiss_keyboard` | Hide the on-screen keyboard after typing. | | `device_dismiss_alert` | Accept or dismiss a system alert or permission dialog. | +### Hermes CDP + +| Tool | Description | +| ---------------- | ------------------------------------------------------------------------- | +| `hermes_cdp` | Speak raw Chrome DevTools Protocol to the React Native Hermes JS runtime. | +| `hermes_targets` | List and diagnose the debuggable Hermes targets exposed by Metro. | + ### Element Identification Elements are identified by accessibility attributes — not internal refs. Matching is **fuzzy**: partial text and case-insensitive matches work. For example, querying `{ label: "Confirm" }` matches an element with label `"Confirm Transaction"`. @@ -187,6 +195,43 @@ Elements are identified by accessibility attributes — not internal refs. Match | `device_dismiss_alert` | find button + tap | find button + tap | `mobile: accept/dismissAlert` | | `device_logs` | `idb log` | `logcat` | `mobile: getLog` | +## Hermes CDP + +Beyond native UI automation (idb/adb), the server can speak [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) (CDP) directly to the **React Native Hermes** JS runtime of a running app via Metro's inspector proxy. This lets an agent evaluate JavaScript, inspect runtime state, and diagnose the app's JS layer — complementing the native tools. Works on **both iOS and Android** (the transport is identical HTTP/WebSocket to Metro; only the default `appId` differs). + +This is _React Native Hermes CDP via Metro's inspector proxy_ — not WebView/browser CDP, and not the iOS WebKit Inspector Protocol. The app's Hermes runtime connects out to Metro; Metro exposes debuggable targets over `http://localhost:/json` and a per-target `webSocketDebuggerUrl`, and the server speaks real CDP over that WebSocket. + +### Prerequisites + +- A **DEBUG build with Metro running** on the inspector port. Release builds expose no inspector (`/json` returns `[]`). +- The global `WebSocket` API: **Node 22+ works out of the box**; **Node 20 requires launching with `NODE_OPTIONS="--experimental-websocket"`** (see [Requirements](#requirements)). + +### Tools + +| Tool | Description | +| ---------------- | ------------------------------------------------------------------------- | +| `hermes_cdp` | Speak raw Chrome DevTools Protocol to the React Native Hermes JS runtime. | +| `hermes_targets` | List and diagnose the debuggable Hermes targets exposed by Metro. | + +Example `hermes_cdp` call: method `Runtime.evaluate` with params `{"expression":"1+1","returnByValue":true}` — the raw response nests the value at `result.result.value`. + +### Safety model + +A single Metro can have multiple apps/simulators registered, so executing CDP against the wrong target could run code in the wrong workspace. The server applies five fail-closed checks before executing: + +1. **Strict `appId` match** — only targets whose `appId` equals the expected id (no substring, no `targets[0]` fallback). +2. **`HermesInternal` identity probe** — evaluates `HermesInternal.getRuntimeProperties()` before the user's method; non-Hermes targets fail closed. +3. **Device pin** — pins `reactNative.logicalDeviceId` on first success; later calls are filtered to the pin. +4. **Multi-device fail-closed** — multiple distinct logical devices (or any missing the field) fail closed rather than guess. +5. **WebSocket URL validation** — protocol must be `ws:`, hostname must be loopback, and port must equal the resolved Metro port. + +The synthetic legacy page (`React Native Experimental (Improved Chrome Reloads)`) is filtered out, and destructive methods `Runtime.terminateExecution` and `Inspector.detached` are blocked. + +### Configuration + +- **appId** defaults to `io.metamask.MetaMask` (iOS) / `io.metamask` (Android). Override globally via the `HERMES_APP_ID` env var or per-call via the tool's `appId` param. Android users not on the default must pass `appId` or set `HERMES_APP_ID`; `hermes_targets` with `all: true` aids discovery of the real appId. +- **Metro port** defaults to `8081`. Override via the `HERMES_METRO_PORT` env var or per-call via the tool's `metroPort` param. + ## MCP Client Configuration ### opencode @@ -260,7 +305,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @metamask/device-mcp ├── src/ │ ├── index.ts # Entry point — lazy backend, stdio MCP server -│ ├── server.ts # MCP server — registers 25 tools +│ ├── server.ts # MCP server — registers 28 tools │ ├── backends/ │ │ ├── types.ts # DeviceBackend interface │ │ ├── idb-backend.ts # iOS local — IDB commands + simctl fallback @@ -269,7 +314,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: │ │ ├── webdriver-client.ts # Minimal W3C WebDriver HTTP client (fetch) │ │ ├── session-file.ts # .device-session file reader │ │ └── index.ts # createBackend() + createLazyBackend() factory -│ ├── tools/ # One file per MCP tool (25 tools) +│ ├── tools/ # One file per MCP tool (28 tools) │ │ ├── list-devices.ts # device_list_devices — enumerate connected devices │ │ ├── select-device.ts # device_select_device — choose device for session │ │ └── ... # snapshot, tap, type, swipe, etc. diff --git a/src/hermes/hermes-cdp.test.ts b/src/hermes/hermes-cdp.test.ts new file mode 100644 index 0000000..19ee8ef --- /dev/null +++ b/src/hermes/hermes-cdp.test.ts @@ -0,0 +1,1111 @@ +/* eslint-disable vitest/prefer-lowercase-title, vitest/expect-expect */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + executeVerifiedCdpCommand, + fetchDiscoveryTargets, + hasAmbiguousTarget, + HERMES_BLOCKED_METHOD, + HERMES_CDP_FAILED, + HERMES_CONNECTION_FAILED, + HERMES_DEVICE_PIN_MISMATCH, + HERMES_INVALID_WS_URL, + HERMES_MULTIPLE_DEVICES, + HERMES_NOT_VERIFIED, + HERMES_TARGET_NOT_FOUND, + HERMES_TIMEOUT, + HERMES_WEBSOCKET_UNAVAILABLE, + IDENTITY_PROBE_EXPR, + runHermesCdp, + selectHermesTarget, + validateWebSocketUrl, +} from './hermes-cdp.js'; +import type { HermesTarget, RunHermesCdpInput } from './hermes-cdp.js'; + +const APP_ID = 'io.metamask.MetaMask'; +const METRO_PORT = 8081; + +type CdpRequest = { id: number; method: string; params?: unknown }; +type MockResponse = Record | string | undefined; + +class MockWebSocket extends EventTarget { + static readonly connecting = 0; + + static readonly openState = 1; + + static readonly closing = 2; + + static readonly closed = 3; + + static instances: MockWebSocket[] = []; + + static autoOpen = true; + + static openSynchronously = false; + + static responseFactory: ((request: CdpRequest) => MockResponse) | undefined; + + readonly url: string; + + readyState = MockWebSocket.connecting; + + sentMessages: string[] = []; + + constructor(url: string) { + super(); + this.url = url; + MockWebSocket.instances.push(this); + + if (MockWebSocket.openSynchronously) { + this.open(); + } else if (MockWebSocket.autoOpen) { + queueMicrotask(() => this.open()); + } + } + + send(message: string): void { + this.sentMessages.push(message); + const request = JSON.parse(message) as CdpRequest; + const response = MockWebSocket.responseFactory?.(request); + if (response !== undefined) { + const payload = + typeof response === 'string' ? response : JSON.stringify(response); + queueMicrotask(() => this.message(payload)); + } + } + + close(): void { + if (this.readyState === MockWebSocket.closed) { + return; + } + this.readyState = MockWebSocket.closed; + this.dispatchEvent(new Event('close')); + } + + open(): void { + this.readyState = MockWebSocket.openState; + this.dispatchEvent(new Event('open')); + } + + message(data: string): void { + this.dispatchEvent(new MessageEvent('message', { data })); + } +} + +Object.defineProperties(MockWebSocket, { + CONNECTING: { value: MockWebSocket.connecting }, + OPEN: { value: MockWebSocket.openState }, + CLOSING: { value: MockWebSocket.closing }, + CLOSED: { value: MockWebSocket.closed }, +}); + +let fetchMock: ReturnType; + +function target(overrides: Record = {}): HermesTarget { + return { + id: 'device-page-1', + title: 'io.metamask.MetaMask (iPhone 15)', + description: 'MetaMask Hermes VM', + appId: APP_ID, + webSocketDebuggerUrl: + 'ws://localhost:8081/inspector/debug?device=device-1&page=1', + reactNative: { + logicalDeviceId: 'logical-device-1', + capabilities: { nativePageReloads: true }, + }, + ...overrides, + }; +} + +function mockDiscovery(targets: unknown[]): void { + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(targets), + }); + vi.stubGlobal('fetch', fetchMock); +} + +function defaultResponseFactory(request: CdpRequest): Record { + if (request.id === 1) { + return { + id: request.id, + result: { + result: { + type: 'string', + value: + '{"isHermes":true,"ossVersion":"0.19.0","debuggerEnabled":true}', + }, + }, + }; + } + return { id: request.id, result: { result: { type: 'number', value: 2 } } }; +} + +function socketAt(index: number): MockWebSocket { + const socket = MockWebSocket.instances[index]; + if (!socket) { + throw new Error(`Missing MockWebSocket instance ${index}`); + } + return socket; +} + +function sentMessage(socket: MockWebSocket, index: number): CdpRequest { + const raw = socket.sentMessages[index]; + if (!raw) { + throw new Error(`Missing sent message ${index}`); + } + return JSON.parse(raw) as CdpRequest; +} + +function baseInput( + overrides: Partial = {}, +): RunHermesCdpInput { + return { + method: 'Runtime.evaluate', + params: { expression: '1+1', returnByValue: true }, + timeoutMs: 30_000, + metroPort: METRO_PORT, + appId: APP_ID, + pinnedDeviceId: undefined, + ...overrides, + }; +} + +function expectError( + result: Awaited>, + code: string, +): asserts result is { ok: false; code: string; message: string } { + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(code); + } +} + +describe('runHermesCdp', () => { + beforeEach(() => { + MockWebSocket.instances = []; + MockWebSocket.autoOpen = true; + MockWebSocket.openSynchronously = false; + MockWebSocket.responseFactory = defaultResponseFactory; + vi.stubGlobal('WebSocket', MockWebSocket); + mockDiscovery([target()]); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('happy path: identity probe + Runtime.evaluate returns the value', async () => { + const result = await runHermesCdp(baseInput()); + + expect(result).toStrictEqual({ + ok: true, + result: { result: { type: 'number', value: 2 } }, + }); + expect(sentMessage(socketAt(0), 0)).toMatchObject({ + id: 1, + method: 'Runtime.evaluate', + params: { expression: IDENTITY_PROBE_EXPR, returnByValue: true }, + }); + expect(sentMessage(socketAt(0), 1)).toMatchObject({ + id: 2, + method: 'Runtime.evaluate', + }); + }); + + it('selects only the strictly-matching appId target', async () => { + mockDiscovery([ + target({ + id: 'evil', + appId: 'io.other.app', + webSocketDebuggerUrl: 'ws://localhost:8081/inspector/debug?evil', + }), + target({ + id: 'good', + webSocketDebuggerUrl: 'ws://localhost:8081/inspector/debug?good', + }), + ]); + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + expect(socketAt(0).url).toBe('ws://localhost:8081/inspector/debug?good'); + }); + + it('appId mismatch → HERMES_TARGET_NOT_FOUND', async () => { + mockDiscovery([target({ appId: 'io.evil.app' })]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_TARGET_NOT_FOUND); + expect(result.message).toContain('Saw appIds: ["io.evil.app"]'); + }); + + it('empty target list → HERMES_TARGET_NOT_FOUND', async () => { + mockDiscovery([]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_TARGET_NOT_FOUND); + expect(result.message).toContain('Saw appIds: []'); + }); + + it('synthetic-title target is filtered out → HERMES_TARGET_NOT_FOUND', async () => { + mockDiscovery([ + target({ title: 'React Native Experimental (Improved Chrome Reloads)' }), + ]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_TARGET_NOT_FOUND); + }); + + it('multi-device without pin → HERMES_MULTIPLE_DEVICES', async () => { + mockDiscovery([ + target({ + id: 'first', + webSocketDebuggerUrl: 'ws://localhost:8081/inspector/debug?first', + reactNative: { + logicalDeviceId: 'logical-device-1', + capabilities: { nativePageReloads: true }, + }, + }), + target({ + id: 'second', + webSocketDebuggerUrl: 'ws://localhost:8081/inspector/debug?second', + reactNative: { + logicalDeviceId: 'logical-device-2', + capabilities: { nativePageReloads: true }, + }, + }), + ]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_MULTIPLE_DEVICES); + expect(result.message).toContain('logical-device-1'); + expect(result.message).toContain('logical-device-2'); + expect(result.message).toContain('first'); + expect(result.message).toContain('second'); + }); + + it('candidate missing logicalDeviceId among many → HERMES_MULTIPLE_DEVICES', async () => { + mockDiscovery([ + target({ + id: 'has-device', + webSocketDebuggerUrl: 'ws://localhost:8081/has-device', + reactNative: { + logicalDeviceId: 'logical-device-1', + capabilities: { nativePageReloads: true }, + }, + }), + target({ + id: 'no-device', + webSocketDebuggerUrl: 'ws://localhost:8081/no-device', + reactNative: { capabilities: { nativePageReloads: true } }, + }), + ]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_MULTIPLE_DEVICES); + expect(result.message).toContain(''); + }); + + it('pins device on first successful call via onPin', async () => { + const onPin = vi.fn(); + + const result = await runHermesCdp(baseInput({ onPin })); + + expect(result.ok).toBe(true); + expect(onPin).toHaveBeenCalledTimes(1); + expect(onPin).toHaveBeenCalledWith('logical-device-1'); + }); + + it('does not call onPin when a pin already exists', async () => { + const onPin = vi.fn(); + + const result = await runHermesCdp( + baseInput({ pinnedDeviceId: 'logical-device-1', onPin }), + ); + + expect(result.ok).toBe(true); + expect(onPin).not.toHaveBeenCalled(); + }); + + it('pin filter selects the same-device target on later calls', async () => { + mockDiscovery([ + target({ + id: 'different-pin', + webSocketDebuggerUrl: 'ws://localhost:8081/inspector/debug?different', + reactNative: { + logicalDeviceId: 'logical-device-2', + capabilities: { nativePageReloads: true }, + }, + }), + target({ + id: 'same-pin', + webSocketDebuggerUrl: 'ws://localhost:8081/inspector/debug?same', + }), + ]); + + const result = await runHermesCdp( + baseInput({ pinnedDeviceId: 'logical-device-1' }), + ); + + expect(result.ok).toBe(true); + expect(socketAt(0).url).toBe('ws://localhost:8081/inspector/debug?same'); + }); + + it('pin filtering away a mismatched-device target → HERMES_TARGET_NOT_FOUND', async () => { + mockDiscovery([ + target({ + reactNative: { logicalDeviceId: 'logical-device-2', capabilities: {} }, + }), + ]); + + const result = await runHermesCdp( + baseInput({ pinnedDeviceId: 'logical-device-1' }), + ); + + expectError(result, HERMES_TARGET_NOT_FOUND); + }); + + it('nativePageReloads tiebreak selects the fresh same-device page', async () => { + mockDiscovery([ + target({ + id: 'stale-page', + webSocketDebuggerUrl: 'ws://localhost:8081/stale', + reactNative: { + logicalDeviceId: 'logical-device-1', + capabilities: { nativePageReloads: false }, + }, + }), + target({ + id: 'fresh-page', + webSocketDebuggerUrl: 'ws://localhost:8081/fresh', + reactNative: { + logicalDeviceId: 'logical-device-1', + capabilities: { nativePageReloads: true }, + }, + }), + ]); + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + expect(socketAt(0).url).toBe('ws://localhost:8081/fresh'); + }); + + it('same-device duplicates resolve via last-in-array tiebreak', async () => { + mockDiscovery([ + target({ id: 'page-1' }), + target({ + id: 'page-2', + webSocketDebuggerUrl: 'ws://localhost:8081/inspector/debug?page=2', + }), + ]); + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + expect(socketAt(0).url).toBe('ws://localhost:8081/inspector/debug?page=2'); + }); + + it('blocked method Runtime.terminateExecution → HERMES_BLOCKED_METHOD without a socket', async () => { + const result = await runHermesCdp( + baseInput({ method: 'Runtime.terminateExecution' }), + ); + + expectError(result, HERMES_BLOCKED_METHOD); + expect(fetchMock).not.toHaveBeenCalled(); + expect(MockWebSocket.instances).toHaveLength(0); + }); + + it('blocked method Inspector.detached → HERMES_BLOCKED_METHOD without a socket', async () => { + const result = await runHermesCdp( + baseInput({ method: 'Inspector.detached' }), + ); + + expectError(result, HERMES_BLOCKED_METHOD); + expect(fetchMock).not.toHaveBeenCalled(); + expect(MockWebSocket.instances).toHaveLength(0); + }); + + it('bad WS protocol (wss:) → HERMES_INVALID_WS_URL', async () => { + mockDiscovery([ + target({ webSocketDebuggerUrl: 'wss://localhost:8081/inspector/debug' }), + ]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_INVALID_WS_URL); + expect(result.message).toContain("Unexpected protocol 'wss:'"); + }); + + it('bad WS protocol (http:) → HERMES_INVALID_WS_URL', async () => { + mockDiscovery([ + target({ webSocketDebuggerUrl: 'http://localhost:8081/inspector/debug' }), + ]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_INVALID_WS_URL); + expect(result.message).toContain("Unexpected protocol 'http:'"); + }); + + it('bad WS host → HERMES_INVALID_WS_URL', async () => { + mockDiscovery([ + target({ webSocketDebuggerUrl: 'ws://evil.com:8081/inspector' }), + ]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_INVALID_WS_URL); + expect(result.message).toContain("Unexpected hostname 'evil.com'"); + }); + + it('wrong WS port → HERMES_INVALID_WS_URL', async () => { + mockDiscovery([ + target({ webSocketDebuggerUrl: 'ws://localhost:9999/inspector' }), + ]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_INVALID_WS_URL); + expect(result.message).toContain( + 'Port mismatch: target=9999 expected=8081', + ); + }); + + it('malformed WS URL → HERMES_INVALID_WS_URL', async () => { + mockDiscovery([target({ webSocketDebuggerUrl: 'not a valid url' })]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_INVALID_WS_URL); + expect(result.message).toContain('not a valid URL'); + }); + + it('discovery /json failure falls back to /json/list', async () => { + fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue([target()]), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'http://localhost:8081/json', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + 'http://localhost:8081/json/list', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('discovery /json throwing falls back to /json/list', async () => { + fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValue([target()]), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('Metro down (ECONNREFUSED) → HERMES_CONNECTION_FAILED', async () => { + fetchMock = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + vi.stubGlobal('fetch', fetchMock); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_CONNECTION_FAILED); + expect(result.message).toContain('ECONNREFUSED'); + }); + + it('non-array discovery payload → HERMES_CONNECTION_FAILED', async () => { + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ not: 'an array' }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_CONNECTION_FAILED); + expect(result.message).toContain('non-array'); + }); + + it('identity probe returns isHermes:false → HERMES_NOT_VERIFIED', async () => { + MockWebSocket.responseFactory = (request) => + request.id === 1 + ? { + id: request.id, + result: { result: { value: '{"isHermes":false}' } }, + } + : defaultResponseFactory(request); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_NOT_VERIFIED); + }); + + it('identity probe CDP-level error → HERMES_NOT_VERIFIED', async () => { + MockWebSocket.responseFactory = (request) => + request.id === 1 + ? { + id: request.id, + error: { message: 'Method not found', code: -32601 }, + } + : defaultResponseFactory(request); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_NOT_VERIFIED); + expect(result.message).toContain('Method not found'); + }); + + it('identity probe subtype error → HERMES_NOT_VERIFIED', async () => { + MockWebSocket.responseFactory = (request) => + request.id === 1 + ? { + id: request.id, + result: { result: { subtype: 'error', value: 'EvalError' } }, + } + : defaultResponseFactory(request); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_NOT_VERIFIED); + expect(result.message).toContain('error subtype'); + }); + + it('identity probe non-JSON value → HERMES_NOT_VERIFIED', async () => { + MockWebSocket.responseFactory = (request) => + request.id === 1 + ? { id: request.id, result: { result: { value: '[object Object]' } } } + : defaultResponseFactory(request); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_NOT_VERIFIED); + expect(result.message).toContain('non-JSON'); + }); + + it('identity probe missing result.value → HERMES_NOT_VERIFIED', async () => { + MockWebSocket.responseFactory = (request) => + request.id === 1 + ? { id: request.id, result: { result: {} } } + : defaultResponseFactory(request); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_NOT_VERIFIED); + expect(result.message).toContain('missing result.value'); + }); + + it('identity probe null outer result → HERMES_NOT_VERIFIED', async () => { + MockWebSocket.responseFactory = (request) => + request.id === 1 + ? { id: request.id, result: null } + : defaultResponseFactory(request); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_NOT_VERIFIED); + expect(result.message).toContain('missing result.value'); + }); + + it('accepts an object-valued probe payload that verifies Hermes', async () => { + MockWebSocket.responseFactory = (request) => + request.id === 1 + ? { id: request.id, result: { result: { value: { isHermes: true } } } } + : defaultResponseFactory(request); + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + }); + + it('target missing logicalDeviceId after probe → HERMES_NOT_VERIFIED', async () => { + mockDiscovery([ + target({ reactNative: { capabilities: { nativePageReloads: true } } }), + ]); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_NOT_VERIFIED); + expect(result.message).toContain('logicalDeviceId'); + }); + + it('user method returns a CDP error → HERMES_CDP_FAILED', async () => { + MockWebSocket.responseFactory = (request) => + request.id === 2 + ? { + id: request.id, + error: { + message: 'Bad params', + code: -32602, + data: { reason: 'invalid' }, + }, + } + : defaultResponseFactory(request); + + const result = await runHermesCdp(baseInput({ method: 'Missing.method' })); + + expectError(result, HERMES_CDP_FAILED); + expect(result.message).toContain('Bad params'); + expect(result.message).toContain('{"reason":"invalid"}'); + }); + + it('identity probe timeout → HERMES_TIMEOUT', async () => { + vi.useFakeTimers(); + MockWebSocket.responseFactory = (request) => + request.id === 1 ? undefined : defaultResponseFactory(request); + + const resultPromise = runHermesCdp(baseInput({ timeoutMs: 1_000 })); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1_000); + const result = await resultPromise; + + expectError(result, HERMES_TIMEOUT); + expect(result.message).toContain('timed out'); + }); + + it('user-method timeout → HERMES_CONNECTION_FAILED via finally close', async () => { + vi.useFakeTimers(); + MockWebSocket.responseFactory = (request) => + request.id === 1 ? defaultResponseFactory(request) : undefined; + + const resultPromise = runHermesCdp(baseInput({ timeoutMs: 1_000 })); + + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(1_000); + const result = await resultPromise; + + expect(result.ok).toBe(false); + expect(socketAt(0).readyState).toBe(MockWebSocket.closed); + }); + + it('WebSocket open timeout → HERMES_CONNECTION_FAILED', async () => { + vi.useFakeTimers(); + MockWebSocket.autoOpen = false; + + const resultPromise = runHermesCdp(baseInput({ timeoutMs: 1_000 })); + + await vi.advanceTimersByTimeAsync(1_000); + const result = await resultPromise; + + expectError(result, HERMES_CONNECTION_FAILED); + expect(result.message).toContain('connection timed out'); + }); + + it('WebSocket error during open → HERMES_CONNECTION_FAILED', async () => { + MockWebSocket.autoOpen = false; + + const resultPromise = runHermesCdp(baseInput()); + + // Flush microtasks until discovery completes and the WebSocket is created. + while (MockWebSocket.instances.length === 0) { + await Promise.resolve(); + } + socketAt(0).dispatchEvent(new Event('error')); + const result = await resultPromise; + + expectError(result, HERMES_CONNECTION_FAILED); + expect(result.message).toContain('connection error'); + }); + + it('socket closes before user method response → HERMES_CONNECTION_FAILED', async () => { + MockWebSocket.responseFactory = (request) => { + if (request.id === 2) { + queueMicrotask(() => socketAt(0).close()); + return undefined; + } + return defaultResponseFactory(request); + }; + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_CONNECTION_FAILED); + expect(socketAt(0).readyState).toBe(MockWebSocket.closed); + }); + + it('WebSocket error while awaiting CDP response → HERMES_CONNECTION_FAILED', async () => { + MockWebSocket.responseFactory = (request) => { + if (request.id === 2) { + queueMicrotask(() => socketAt(0).dispatchEvent(new Event('error'))); + return undefined; + } + return defaultResponseFactory(request); + }; + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_CONNECTION_FAILED); + expect(result.message).toContain('message error'); + }); + + it('non-text WebSocket frame → HERMES_CONNECTION_FAILED', async () => { + MockWebSocket.responseFactory = (request) => { + if (request.id === 2) { + queueMicrotask(() => + socketAt(0).dispatchEvent( + new MessageEvent('message', { data: { id: 2 } }), + ), + ); + return undefined; + } + return defaultResponseFactory(request); + }; + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_CONNECTION_FAILED); + expect(result.message).toContain('non-text'); + }); + + it('WebSocket open path resolves immediately when already open', async () => { + MockWebSocket.openSynchronously = true; + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + }); + + it('ignores unrelated response ids while awaiting the user method', async () => { + MockWebSocket.responseFactory = (request) => { + if (request.id === 2) { + queueMicrotask(() => + socketAt(0).message(JSON.stringify({ id: 999, result: {} })), + ); + } + return defaultResponseFactory(request); + }; + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + }); + + it('ignores frames without numeric ids while awaiting the user method', async () => { + MockWebSocket.responseFactory = (request) => { + if (request.id === 2) { + queueMicrotask(() => + socketAt(0).message(JSON.stringify({ result: { ignored: true } })), + ); + } + return defaultResponseFactory(request); + }; + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + }); + + it('ignores non-object JSON frames while awaiting the user method', async () => { + MockWebSocket.responseFactory = (request) => { + if (request.id === 2) { + queueMicrotask(() => socketAt(0).message('42')); + } + return defaultResponseFactory(request); + }; + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + }); + + it('filters malformed discovery entries while keeping valid targets', async () => { + mockDiscovery([ + null, + 'not an object', + { appId: 123, webSocketDebuggerUrl: 'ws://localhost:8081/bad' }, + target(), + ]); + + const result = await runHermesCdp(baseInput()); + + expect(result.ok).toBe(true); + expect(socketAt(0).url).toBe( + 'ws://localhost:8081/inspector/debug?device=device-1&page=1', + ); + }); + + it('global WebSocket unavailable → HERMES_WEBSOCKET_UNAVAILABLE without fetch', async () => { + vi.stubGlobal('WebSocket', undefined); + + const result = await runHermesCdp(baseInput()); + + expectError(result, HERMES_WEBSOCKET_UNAVAILABLE); + expect(result.message).toContain('Global WebSocket is unavailable'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('selectHermesTarget', () => { + it('returns HERMES_TARGET_NOT_FOUND when no appId matches', () => { + const selection = selectHermesTarget( + [target({ appId: 'io.other' })], + APP_ID, + undefined, + ); + expect(selection).toMatchObject({ + ok: false, + code: HERMES_TARGET_NOT_FOUND, + }); + }); + + it('drops targets without a webSocketDebuggerUrl', () => { + const selection = selectHermesTarget( + [target({ webSocketDebuggerUrl: undefined })], + APP_ID, + undefined, + ); + expect(selection.ok).toBe(false); + }); + + it('returns the matching target on success', () => { + const selection = selectHermesTarget([target()], APP_ID, undefined); + expect(selection).toMatchObject({ + ok: true, + target: { id: 'device-page-1' }, + }); + }); + + it('returns HERMES_MULTIPLE_DEVICES on distinct device ids', () => { + const selection = selectHermesTarget( + [ + target({ + id: 'a', + reactNative: { logicalDeviceId: 'd1', capabilities: {} }, + }), + target({ + id: 'b', + webSocketDebuggerUrl: 'ws://localhost:8081/b', + reactNative: { logicalDeviceId: 'd2', capabilities: {} }, + }), + ], + APP_ID, + undefined, + ); + expect(selection).toMatchObject({ + ok: false, + code: HERMES_MULTIPLE_DEVICES, + }); + }); +}); + +describe('hasAmbiguousTarget', () => { + it('is false for zero or one candidate', () => { + expect(hasAmbiguousTarget([])).toBe(false); + expect(hasAmbiguousTarget([target()])).toBe(false); + }); + + it('is true when a candidate is missing logicalDeviceId', () => { + expect( + hasAmbiguousTarget([ + target(), + target({ reactNative: { capabilities: {} } }), + ]), + ).toBe(true); + }); + + it('is true for multiple distinct logical device ids', () => { + expect( + hasAmbiguousTarget([ + target({ reactNative: { logicalDeviceId: 'd1', capabilities: {} } }), + target({ reactNative: { logicalDeviceId: 'd2', capabilities: {} } }), + ]), + ).toBe(true); + }); + + it('is false when all candidates share one logical device id', () => { + expect( + hasAmbiguousTarget([ + target({ reactNative: { logicalDeviceId: 'd1', capabilities: {} } }), + target({ reactNative: { logicalDeviceId: 'd1', capabilities: {} } }), + ]), + ).toBe(false); + }); +}); + +describe('validateWebSocketUrl', () => { + it('accepts a valid loopback ws URL on the expected port', () => { + expect( + validateWebSocketUrl('ws://localhost:8081/inspector', 8081), + ).toStrictEqual({ ok: true }); + expect( + validateWebSocketUrl('ws://127.0.0.1:8081/inspector', 8081), + ).toStrictEqual({ ok: true }); + }); + + it('rejects a missing URL', () => { + const result = validateWebSocketUrl(undefined, 8081); + expect(result).toMatchObject({ + ok: false, + message: expect.stringContaining('missing'), + }); + }); + + it('rejects an unparseable URL', () => { + const result = validateWebSocketUrl('::::', 8081); + expect(result.ok).toBe(false); + }); + + it('rejects a non-ws protocol', () => { + const result = validateWebSocketUrl('wss://localhost:8081/x', 8081); + expect(result).toMatchObject({ + ok: false, + message: expect.stringContaining("Unexpected protocol 'wss:'"), + }); + }); + + it('rejects a non-loopback hostname', () => { + const result = validateWebSocketUrl('ws://evil.com:8081/x', 8081); + expect(result).toMatchObject({ + ok: false, + message: expect.stringContaining("Unexpected hostname 'evil.com'"), + }); + }); + + it('rejects a port mismatch', () => { + const result = validateWebSocketUrl('ws://localhost:9999/x', 8081); + expect(result).toMatchObject({ + ok: false, + message: expect.stringContaining('Port mismatch'), + }); + }); +}); + +describe('fetchDiscoveryTargets', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('returns objects from the /json payload, filtering non-objects', async () => { + const fetchFn = vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue([null, 'x', target()]), + }); + vi.stubGlobal('fetch', fetchFn); + + const targets = await fetchDiscoveryTargets(8081, 1_000); + + expect(targets).toHaveLength(1); + expect(fetchFn).toHaveBeenCalledWith( + 'http://localhost:8081/json', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('throws the last error when all discovery paths fail', async () => { + const fetchFn = vi.fn().mockRejectedValue(new Error('ECONNREFUSED')); + vi.stubGlobal('fetch', fetchFn); + + await expect(fetchDiscoveryTargets(8081, 1_000)).rejects.toThrow( + 'ECONNREFUSED', + ); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it('throws on an HTTP error status across both paths', async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 500 }); + vi.stubGlobal('fetch', fetchFn); + + await expect(fetchDiscoveryTargets(8081, 1_000)).rejects.toThrow( + 'HTTP 500', + ); + }); +}); + +describe('executeVerifiedCdpCommand', () => { + beforeEach(() => { + MockWebSocket.instances = []; + MockWebSocket.autoOpen = true; + MockWebSocket.openSynchronously = false; + MockWebSocket.responseFactory = defaultResponseFactory; + vi.stubGlobal('WebSocket', MockWebSocket); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('fails with HERMES_INVALID_WS_URL when the target lacks a ws URL', async () => { + const result = await executeVerifiedCdpCommand( + target({ webSocketDebuggerUrl: undefined }), + baseInput(), + undefined, + undefined, + ); + + expect(result).toMatchObject({ ok: false, code: HERMES_INVALID_WS_URL }); + }); + + it('fails with HERMES_WEBSOCKET_UNAVAILABLE when WebSocket is missing', async () => { + vi.stubGlobal('WebSocket', undefined); + + const result = await executeVerifiedCdpCommand( + target(), + baseInput(), + undefined, + undefined, + ); + + expect(result).toMatchObject({ + ok: false, + code: HERMES_WEBSOCKET_UNAVAILABLE, + }); + }); + + it('returns the CDP result and pins via onPin on success', async () => { + const onPin = vi.fn(); + + const result = await executeVerifiedCdpCommand( + target(), + baseInput(), + undefined, + onPin, + ); + + expect(result.ok).toBe(true); + expect(onPin).toHaveBeenCalledWith('logical-device-1'); + }); + + it('fails closed with HERMES_DEVICE_PIN_MISMATCH', async () => { + const result = await executeVerifiedCdpCommand( + target(), + baseInput({ pinnedDeviceId: 'other-device' }), + 'other-device', + undefined, + ); + + expect(result).toMatchObject({ + ok: false, + code: HERMES_DEVICE_PIN_MISMATCH, + message: expect.stringContaining('does not match session pin'), + }); + }); +}); diff --git a/src/hermes/hermes-cdp.ts b/src/hermes/hermes-cdp.ts new file mode 100644 index 0000000..3eb9180 --- /dev/null +++ b/src/hermes/hermes-cdp.ts @@ -0,0 +1,900 @@ +/** + * Pure Hermes Chrome DevTools Protocol (CDP) core for `@metamask/device-mcp`. + * + * Ported from `@metamask/client-mcp-core`'s `src/tools/hermes-cdp.ts`, decoupled + * from that repo's `ToolContext`/`ErrorCodes`/`createToolError|Success`. Every + * function here takes EXPLICIT parameters (port, appId, pin) and returns a + * discriminated {@link HermesCdpResult} so this module is MCP-free and + * unit-testable. The tools layer is responsible for resolving session state and + * for PERSISTING the device pin — this file never imports `HermesSession`. + * + * Mechanism: the React Native Hermes runtime connects out to Metro's inspector + * proxy; Metro exposes debuggable targets over `http://localhost:/json` + * and a per-target `webSocketDebuggerUrl`. We speak real CDP over that WebSocket + * using the GLOBAL `WebSocket` (no library) and the global `fetch` for discovery. + * + * Five fail-closed safety checks are preserved VERBATIM from the reference: + * 1. Strict `appId` match (`===`, no substring, no `targets[0]` fallback). + * 2. `HermesInternal.getRuntimeProperties()` identity probe before the user method. + * 3. Device pin on `reactNative.logicalDeviceId` (set on first success). + * 4. Multi-device fail-closed (missing or >1 distinct `logicalDeviceId`). + * 5. WebSocket URL validation (protocol/hostname/port). + */ + +/** + * Reported capability flags on a Metro Hermes target. + */ +type HermesCapabilities = { + /** Whether the target supports native page reloads (newer RN registrations). */ + nativePageReloads?: boolean; + /** Whether the target supports native source-code fetching. */ + nativeSourceCodeFetching?: boolean; + /** Whether the target supports multiple concurrent debuggers. */ + supportsMultipleDebuggers?: boolean; +}; + +/** + * React Native-specific metadata attached to a Metro Hermes target. + */ +type HermesReactNative = { + /** Stable logical device identifier used for session pinning. */ + logicalDeviceId?: string; + /** Reported capability flags for this target. */ + capabilities?: HermesCapabilities; +}; + +/** + * A debuggable Hermes target as reported by Metro's `/json` (or `/json/list`) + * discovery endpoint. Shape matches the reference implementation exactly. + */ +export type HermesTarget = { + /** Metro's per-target identifier. */ + id?: string; + /** Human-readable target title (used to filter the legacy synthetic page). */ + title?: string; + /** Human-readable target description. */ + description?: string; + /** The app bundle identifier this target belongs to (strict-matched). */ + appId?: string; + /** The `ws://` URL used to speak CDP with this target. */ + webSocketDebuggerUrl?: string; + /** React Native-specific metadata (logical device id + capabilities). */ + reactNative?: HermesReactNative; +}; + +/** + * Discriminated result of a Hermes CDP operation. + * + * On success `result` carries the raw CDP `result` payload. On failure `code` + * is a STABLE string error code (see the `HERMES_*` constants) and `message` + * is a human-readable diagnostic preserving the reference's wording. + */ +export type HermesCdpResult = + | { ok: true; result: unknown } + | { ok: false; code: string; message: string }; + +/** + * Parameters for {@link runHermesCdp}. + */ +export type RunHermesCdpInput = { + /** The CDP method to invoke (e.g. `Runtime.evaluate`). */ + method: string; + /** Optional CDP method parameters. */ + params?: Record; + /** Per-call timeout in milliseconds for discovery and each CDP round-trip. */ + timeoutMs: number; + /** The resolved Metro inspector proxy port. */ + metroPort: number; + /** The expected app bundle identifier (strict-matched against targets). */ + appId: string; + /** The pinned `logicalDeviceId` for this session, if one has been set. */ + pinnedDeviceId?: string; + /** + * Callback invoked with the resolved `logicalDeviceId` on the FIRST + * successful identity probe so the caller can persist the pin. This file + * stays pure: it never imports or mutates session state itself. + */ + onPin?: (logicalDeviceId: string) => void; +}; + +type CdpSuccessResponse = { + id: number; + result?: unknown; +}; + +type CdpErrorResponse = { + id: number; + error: { + message?: string; + code?: number; + data?: unknown; + }; +}; + +type TargetSelection = + | { ok: true; target: HermesTarget } + | { ok: false; code: string; message: string }; + +type ProbeResult = { ok: true } | { ok: false; code: string; message: string }; + +/** + * No Hermes debug target matched the expected appId (after synthetic-title and + * pin filtering). Often means Metro is up but the app is not registered. + */ +export const HERMES_TARGET_NOT_FOUND = 'HERMES_TARGET_NOT_FOUND'; + +/** + * A target was found but could not be verified as the expected Hermes runtime + * (identity probe failed, missing `logicalDeviceId`, or non-JSON probe payload). + */ +export const HERMES_NOT_VERIFIED = 'HERMES_NOT_VERIFIED'; + +/** + * Multiple distinct logical devices are registered without a session pin (or a + * candidate is missing its `logicalDeviceId`); routing would be a guess, so we + * fail closed. + */ +export const HERMES_MULTIPLE_DEVICES = 'HERMES_MULTIPLE_DEVICES'; + +/** + * The resolved target's `logicalDeviceId` does not match the session pin. + */ +export const HERMES_DEVICE_PIN_MISMATCH = 'HERMES_DEVICE_PIN_MISMATCH'; + +/** + * The requested CDP method is in the destructive blocked set. + */ +export const HERMES_BLOCKED_METHOD = 'HERMES_BLOCKED_METHOD'; + +/** + * The target's `webSocketDebuggerUrl` failed protocol/hostname/port validation. + */ +export const HERMES_INVALID_WS_URL = 'HERMES_INVALID_WS_URL'; + +/** + * The underlying connection (discovery fetch or WebSocket) failed. + */ +export const HERMES_CONNECTION_FAILED = 'HERMES_CONNECTION_FAILED'; + +/** + * A discovery, socket-open, or CDP round-trip exceeded the configured timeout. + */ +export const HERMES_TIMEOUT = 'HERMES_TIMEOUT'; + +/** + * The user's CDP method returned a CDP-level error. + */ +export const HERMES_CDP_FAILED = 'HERMES_CDP_FAILED'; + +/** + * The global `WebSocket` constructor is unavailable in this Node runtime. + */ +export const HERMES_WEBSOCKET_UNAVAILABLE = 'HERMES_WEBSOCKET_UNAVAILABLE'; + +/** + * CDP methods blocked for safety. Ported VERBATIM from the reference — do not + * weaken. + */ +const HERMES_BLOCKED_METHODS = new Set([ + 'Runtime.terminateExecution', + 'Inspector.detached', +]); + +const DISCOVERY_PATHS = ['/json', '/json/list']; +const ALLOWED_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * Title of the synthetic legacy "Improved Chrome Reloads" page that Metro + * registers alongside the real Hermes target. Filtered out during selection. + * Ported VERBATIM from the reference. + */ +export const LEGACY_SYNTHETIC_TITLE = + 'React Native Experimental (Improved Chrome Reloads)'; + +/** + * JavaScript expression evaluated against a candidate target BEFORE the user's + * method, to verify it is a genuine Hermes runtime. Copied VERBATIM from the + * reference — do not alter the verification semantics. + */ +export const IDENTITY_PROBE_EXPR = `(function () { + try { + var hi = typeof HermesInternal !== 'undefined' ? HermesInternal : null; + var p = + hi && typeof hi.getRuntimeProperties === 'function' + ? hi.getRuntimeProperties() + : null; + return JSON.stringify({ + isHermes: !!hi, + ossVersion: p ? p['OSS Release Version'] : null, + debuggerEnabled: p ? p['Debugger Enabled'] : null, + }); + } catch (e) { + return JSON.stringify({ error: String(e) }); + } +})();`; + +/** + * Orchestrates a single verified Hermes CDP command end to end. + * + * Resolves a target from Metro discovery, enforces all five fail-closed safety + * checks, runs the identity probe, persists the device pin via `onPin` on first + * success, sends the user's method, and returns the raw CDP `result`. The caller + * supplies the resolved port/appId/pin and persists the pin — this function is + * pure with respect to session state. + * + * @param input - Method, params, timeout, resolved port/appId/pin, and the pin + * persistence callback. See {@link RunHermesCdpInput}. + * @returns A discriminated {@link HermesCdpResult}. + */ +export async function runHermesCdp( + input: RunHermesCdpInput, +): Promise { + if (HERMES_BLOCKED_METHODS.has(input.method)) { + return { + ok: false, + code: HERMES_BLOCKED_METHOD, + message: + `CDP method "${input.method}" is blocked for safety. ` + + `Blocked methods: ${[...HERMES_BLOCKED_METHODS].join(', ')}`, + }; + } + + if (typeof WebSocket !== 'function') { + return { + ok: false, + code: HERMES_WEBSOCKET_UNAVAILABLE, + message: + 'Global WebSocket is unavailable. On Node 20 launch device-mcp with ' + + 'NODE_OPTIONS="--experimental-websocket" (or use Node 22+).', + }; + } + + try { + const targets = await fetchDiscoveryTargets( + input.metroPort, + input.timeoutMs, + ); + const selection = selectHermesTarget( + targets, + input.appId, + input.pinnedDeviceId, + ); + if (!selection.ok) { + return { ok: false, code: selection.code, message: selection.message }; + } + + const validation = validateWebSocketUrl( + selection.target.webSocketDebuggerUrl, + input.metroPort, + ); + if (!validation.ok) { + return { + ok: false, + code: HERMES_INVALID_WS_URL, + message: validation.message, + }; + } + + return await executeVerifiedCdpCommand( + selection.target, + input, + input.pinnedDeviceId, + input.onPin, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + code: HERMES_CONNECTION_FAILED, + message: `Hermes CDP connection failed: ${message}`, + }; + } +} + +/** + * Fetches debuggable Hermes targets from Metro, trying `/json` then falling + * back to `/json/list`. Each attempt is bounded by an `AbortController` + * timeout. The payload must be an array; non-object entries are filtered out. + * + * @param metroPort - The Metro inspector proxy port. + * @param timeoutMs - Per-attempt timeout in milliseconds. + * @returns The discovered targets (objects only). + * @throws The last error encountered if all discovery paths fail. + */ +export async function fetchDiscoveryTargets( + metroPort: number, + timeoutMs: number, +): Promise { + let lastError: unknown; + + for (const path of DISCOVERY_PATHS) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`http://localhost:${metroPort}${path}`, { + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Metro ${path} returned HTTP ${response.status}`); + } + + const payload: unknown = await response.json(); + if (!Array.isArray(payload)) { + throw new Error(`Metro ${path} returned a non-array response`); + } + + return payload.filter(isHermesTarget); + } catch (error) { + lastError = error; + } finally { + clearTimeout(timer); + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); +} + +/** + * Narrows an unknown discovery entry to a {@link HermesTarget} (object guard). + * + * @param value - A raw entry from the Metro discovery payload. + * @returns True when `value` is a non-null object. + */ +function isHermesTarget(value: unknown): value is HermesTarget { + return typeof value === 'object' && value !== null; +} + +/** + * Selects the single safe Hermes target for the expected app, applying the + * fail-closed selection algorithm VERBATIM from the reference: + * + * `webSocketDebuggerUrl` present → drop the synthetic legacy title → strict + * `appId` match (`===`) → pin filter → ambiguity check ({@link hasAmbiguousTarget}) + * → `nativePageReloads` tiebreak → last-in-array. + * + * @param targets - All discovered targets. + * @param expectedAppId - The strict app bundle identifier to match. + * @param pinnedDeviceId - The session pin, if set, used to filter candidates. + * @returns The selected target or a classified failure. + */ +export function selectHermesTarget( + targets: HermesTarget[], + expectedAppId: string, + pinnedDeviceId: string | undefined, +): TargetSelection { + const seenAppIds = [...new Set(targets.map((target) => target.appId))].filter( + (appId): appId is string => typeof appId === 'string', + ); + + let candidates = targets + .filter((target) => Boolean(target.webSocketDebuggerUrl)) + .filter((target) => target.title !== LEGACY_SYNTHETIC_TITLE) + .filter((target) => target.appId === expectedAppId); + + if (pinnedDeviceId) { + candidates = candidates.filter( + (target) => target.reactNative?.logicalDeviceId === pinnedDeviceId, + ); + } + + if (candidates.length === 0) { + return { + ok: false, + code: HERMES_TARGET_NOT_FOUND, + message: `No Hermes debug target found for appId ${expectedAppId}. Saw appIds: ${JSON.stringify( + seenAppIds, + )}`, + }; + } + + if (hasAmbiguousTarget(candidates)) { + return { + ok: false, + code: HERMES_MULTIPLE_DEVICES, + message: `Ambiguous Hermes target after pin filtering. Candidates: ${candidates + .map( + (target) => + `${target.id ?? ''} (device=${ + target.reactNative?.logicalDeviceId ?? '' + })`, + ) + .join(', ')}`, + }; + } + + // Same-device tiebreak: prefer pages with nativePageReloads (newer RN page + // registrations) over stale entries left behind after in-app reloads. + const nativeReloadTargets = candidates.filter( + (target) => target.reactNative?.capabilities?.nativePageReloads === true, + ); + if (nativeReloadTargets.length > 0) { + candidates = nativeReloadTargets; + } + + return { ok: true, target: candidates[candidates.length - 1] }; +} + +/** + * Determines whether a multi-candidate target list is too ambiguous to choose + * from safely. Returns true when: + * - any candidate is missing a `reactNative.logicalDeviceId` (we can't route safely), or + * - candidates resolve to more than one distinct `logicalDeviceId` (multi-device + * without a session pin — picking any one would silently bind the wrong device). + * + * Returns false when all candidates share the same logical device ID. In that + * case the caller falls through to the "last-in-array" tiebreak, matching the + * React Native convention that the most recent page registration wins (e.g., + * stale + fresh page after an in-app reload). + * + * @param candidates - Targets remaining after appId/pin/capability filtering. + * @returns True when the caller must fail closed; false when tiebreak is safe. + */ +export function hasAmbiguousTarget(candidates: HermesTarget[]): boolean { + if (candidates.length <= 1) { + return false; + } + const deviceIds = candidates.map( + (target) => target.reactNative?.logicalDeviceId, + ); + // Any candidate missing a logicalDeviceId → can't safely route. + if (deviceIds.some((id) => !id)) { + return true; + } + // Multiple distinct device IDs → multi-device without pin. + return new Set(deviceIds).size > 1; +} + +/** + * Validates a target's `webSocketDebuggerUrl` before connecting. The protocol + * must be `ws:`, the hostname must be loopback (`localhost`, `127.0.0.1`, `::1`, + * or `[::1]`), and the port must equal the resolved Metro port. Ported VERBATIM + * from the reference. + * + * @param rawUrl - The candidate `webSocketDebuggerUrl`. + * @param expectedPort - The resolved Metro port the URL must point at. + * @returns `{ ok: true }` when valid, otherwise a failure with a message. + */ +export function validateWebSocketUrl( + rawUrl: string | undefined, + expectedPort: number, +): { ok: true } | { ok: false; message: string } { + if (!rawUrl) { + return { ok: false, message: 'webSocketDebuggerUrl is missing' }; + } + + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return { + ok: false, + message: `webSocketDebuggerUrl is not a valid URL: ${rawUrl}`, + }; + } + + if (parsed.protocol !== 'ws:') { + return { ok: false, message: `Unexpected protocol '${parsed.protocol}'` }; + } + if (!ALLOWED_HOSTNAMES.has(parsed.hostname)) { + return { ok: false, message: `Unexpected hostname '${parsed.hostname}'` }; + } + if (Number(parsed.port) !== expectedPort) { + return { + ok: false, + message: `Port mismatch: target=${parsed.port} expected=${expectedPort}`, + }; + } + + return { ok: true }; +} + +/** + * Opens the target WebSocket, runs the identity probe, enforces the device pin + * (persisting it via `onPin` on first success, failing closed on mismatch), + * sends the user's method, matches the response by `id`, and always closes the + * socket in a `finally`. Ported faithfully from the reference. + * + * @param target - The validated, selected target. + * @param input - The original {@link RunHermesCdpInput} (method/params/timeout). + * @param pinnedDeviceId - The session pin, if set. + * @param onPin - Callback to persist the resolved pin on first success. + * @returns A discriminated {@link HermesCdpResult}. + */ +export async function executeVerifiedCdpCommand( + target: HermesTarget, + input: RunHermesCdpInput, + pinnedDeviceId: string | undefined, + onPin: ((logicalDeviceId: string) => void) | undefined, +): Promise { + if (!target.webSocketDebuggerUrl) { + return { + ok: false, + code: HERMES_INVALID_WS_URL, + message: 'Target is missing webSocketDebuggerUrl', + }; + } + if (typeof WebSocket !== 'function') { + return { + ok: false, + code: HERMES_WEBSOCKET_UNAVAILABLE, + message: + 'Global WebSocket is unavailable. On Node 20 launch device-mcp with ' + + 'NODE_OPTIONS="--experimental-websocket" (or use Node 22+).', + }; + } + + const socket = new WebSocket(target.webSocketDebuggerUrl); + let nextId = 1; + + try { + await waitForSocketOpen(socket, input.timeoutMs); + + const probe = await runIdentityProbe(socket, input.timeoutMs, nextId); + nextId += 1; + if (!probe.ok) { + return { ok: false, code: probe.code, message: probe.message }; + } + + const targetDeviceId = target.reactNative?.logicalDeviceId; + if (!targetDeviceId) { + return { + ok: false, + code: HERMES_NOT_VERIFIED, + message: 'Target is missing reactNative.logicalDeviceId', + }; + } + if (!pinnedDeviceId) { + onPin?.(targetDeviceId); + } else if (pinnedDeviceId !== targetDeviceId) { + return { + ok: false, + code: HERMES_DEVICE_PIN_MISMATCH, + message: `Hermes target device ${targetDeviceId} does not match session pin ${pinnedDeviceId}`, + }; + } + + const response = await sendUserMethod(socket, input, nextId); + nextId += 1; + if (isCdpErrorResponse(response)) { + return { + ok: false, + code: HERMES_CDP_FAILED, + message: `Hermes CDP "${input.method}" failed: ${formatCdpError( + response, + )}`, + }; + } + + return { ok: true, result: response.result }; + } finally { + closeSocket(socket); + } +} + +/** + * Runs the {@link IDENTITY_PROBE_EXPR} against an open socket and verifies the + * response confirms a Hermes runtime. Distinguishes timeout from connection + * failure and unverified payloads. + * + * @param socket - The open target WebSocket. + * @param timeoutMs - Round-trip timeout in milliseconds. + * @param id - The CDP message id to use for this probe. + * @returns A {@link ProbeResult}. + */ +async function runIdentityProbe( + socket: WebSocket, + timeoutMs: number, + id: number, +): Promise { + socket.send( + JSON.stringify({ + id, + method: 'Runtime.evaluate', + params: { expression: IDENTITY_PROBE_EXPR, returnByValue: true }, + }), + ); + + let response: CdpSuccessResponse | CdpErrorResponse; + try { + response = await waitForCdpResponse(socket, id, timeoutMs); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('timed out')) { + return { + ok: false, + code: HERMES_TIMEOUT, + message: `Identity probe timed out after ${timeoutMs}ms`, + }; + } + return { + ok: false, + code: HERMES_CONNECTION_FAILED, + message: `Hermes CDP connection failed: ${message}`, + }; + } + + if (isCdpErrorResponse(response)) { + return { + ok: false, + code: HERMES_NOT_VERIFIED, + message: `Identity probe failed: ${formatCdpError(response)}`, + }; + } + + const remoteResult = getProbeRemoteResult(response); + if (remoteResult.subtype === 'error') { + return { + ok: false, + code: HERMES_NOT_VERIFIED, + message: 'Identity probe evaluation returned an error subtype', + }; + } + if (remoteResult.value === undefined || remoteResult.value === null) { + return { + ok: false, + code: HERMES_NOT_VERIFIED, + message: 'Identity probe response missing result.value', + }; + } + + const raw = + typeof remoteResult.value === 'string' + ? remoteResult.value + : JSON.stringify(remoteResult.value); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { + ok: false, + code: HERMES_NOT_VERIFIED, + message: `Identity probe returned non-JSON: ${raw}`, + }; + } + + if (!isHermesProbePayload(parsed)) { + return { + ok: false, + code: HERMES_NOT_VERIFIED, + message: `Identity probe did not verify Hermes runtime: ${raw}`, + }; + } + + return { ok: true }; +} + +/** + * Safely extracts the nested CDP `result.result` (the remote object) from a + * `Runtime.evaluate` success response. + * + * @param response - A CDP success response. + * @returns The remote object's `subtype`/`value`, or an empty object. + */ +function getProbeRemoteResult(response: CdpSuccessResponse): { + subtype?: unknown; + value?: unknown; +} { + if (typeof response.result !== 'object' || response.result === null) { + return {}; + } + const outer = response.result as Record; + if (typeof outer.result !== 'object' || outer.result === null) { + return {}; + } + return outer.result; +} + +/** + * Narrows a parsed probe payload to one confirming a Hermes runtime. + * + * @param value - The parsed probe JSON payload. + * @returns True when `isHermes === true`. + */ +function isHermesProbePayload(value: unknown): value is { isHermes: true } { + return ( + typeof value === 'object' && + value !== null && + (value as Record).isHermes === true + ); +} + +/** + * Sends the user's CDP method over an open, verified socket and awaits the + * matching response by id. + * + * @param socket - The open, verified target WebSocket. + * @param input - The original input carrying method/params/timeout. + * @param id - The CDP message id to use. + * @returns The matching CDP success or error response. + */ +async function sendUserMethod( + socket: WebSocket, + input: RunHermesCdpInput, + id: number, +): Promise { + socket.send( + JSON.stringify({ + id, + method: input.method, + params: input.params ?? {}, + }), + ); + return await waitForCdpResponse(socket, id, input.timeoutMs); +} + +/** + * Resolves once the socket reaches the OPEN state, rejecting on error or + * timeout. Listeners are always cleaned up. + * + * @param socket - The connecting target WebSocket. + * @param timeoutMs - Connection timeout in milliseconds. + * @returns A promise that resolves when the socket is open. + */ +async function waitForSocketOpen( + socket: WebSocket, + timeoutMs: number, +): Promise { + if (socket.readyState === WebSocket.OPEN) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + let cleanup = (): void => undefined; + const handleOpen = (): void => { + cleanup(); + resolve(); + }; + const handleError = (): void => { + cleanup(); + reject(new Error('WebSocket connection error')); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`WebSocket connection timed out after ${timeoutMs}ms`)); + }, timeoutMs); + cleanup = (): void => { + clearTimeout(timer); + socket.removeEventListener('open', handleOpen); + socket.removeEventListener('error', handleError); + }; + + socket.addEventListener('open', handleOpen); + socket.addEventListener('error', handleError); + }); +} + +/** + * Resolves with the CDP response whose `id` matches, rejecting on socket error, + * premature close, or timeout. Listeners are always cleaned up. + * + * @param socket - The open target WebSocket. + * @param id - The CDP message id to match. + * @param timeoutMs - Round-trip timeout in milliseconds. + * @returns The matching CDP success or error response. + */ +async function waitForCdpResponse( + socket: WebSocket, + id: number, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + let cleanup = (): void => undefined; + const handleMessage = (event: MessageEvent): void => { + try { + const parsed = parseCdpResponse(event.data); + if (parsed?.id !== id) { + return; + } + cleanup(); + resolve(parsed); + } catch (error) { + cleanup(); + reject(error); + } + }; + const handleError = (): void => { + cleanup(); + reject(new Error('WebSocket message error')); + }; + const handleClose = (): void => { + cleanup(); + reject(new Error('WebSocket closed before CDP response')); + }; + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Hermes CDP call timed out after ${timeoutMs}ms`)); + }, timeoutMs); + cleanup = (): void => { + clearTimeout(timer); + socket.removeEventListener('message', handleMessage); + socket.removeEventListener('error', handleError); + socket.removeEventListener('close', handleClose); + }; + + socket.addEventListener('message', handleMessage); + socket.addEventListener('error', handleError); + socket.addEventListener('close', handleClose); + }); +} + +/** + * Parses a raw WebSocket frame into a CDP response, returning undefined when it + * is not an addressable (`id`-bearing) message. + * + * @param data - The raw `MessageEvent.data`. + * @returns A CDP success/error response, or undefined when not addressable. + * @throws When the frame is not a text frame. + */ +function parseCdpResponse( + data: MessageEvent['data'], +): CdpSuccessResponse | CdpErrorResponse | undefined { + if (typeof data !== 'string') { + throw new Error('Hermes CDP returned a non-text WebSocket frame'); + } + + const parsed: unknown = JSON.parse(data); + if (typeof parsed !== 'object' || parsed === null) { + return undefined; + } + const candidate = parsed as Record; + if (typeof candidate.id !== 'number') { + return undefined; + } + if (isCdpError(candidate.error)) { + return { id: candidate.id, error: candidate.error }; + } + return { id: candidate.id, result: candidate.result }; +} + +/** + * Narrows an unknown value to a CDP error object. + * + * @param value - The candidate `error` field. + * @returns True when `value` is a non-null object. + */ +function isCdpError(value: unknown): value is CdpErrorResponse['error'] { + return typeof value === 'object' && value !== null; +} + +/** + * Discriminates a CDP response as an error response. + * + * @param response - A CDP success or error response. + * @returns True when the response carries an `error` field. + */ +function isCdpErrorResponse( + response: CdpSuccessResponse | CdpErrorResponse, +): response is CdpErrorResponse { + return 'error' in response; +} + +/** + * Formats a CDP error response into a single human-readable line. + * + * @param response - The CDP error response. + * @returns A combined message including code and data when present. + */ +function formatCdpError(response: CdpErrorResponse): string { + const parts = [response.error.message ?? 'Unknown CDP error']; + if (typeof response.error.code === 'number') { + parts.push(`code ${response.error.code}`); + } + if (response.error.data !== undefined) { + parts.push(JSON.stringify(response.error.data)); + } + return parts.join(' - '); +} + +/** + * Closes the socket if it is still connecting or open. + * + * @param socket - The target WebSocket. + */ +function closeSocket(socket: WebSocket): void { + if ( + socket.readyState === WebSocket.CONNECTING || + socket.readyState === WebSocket.OPEN + ) { + socket.close(); + } +} diff --git a/src/hermes/session.test.ts b/src/hermes/session.test.ts new file mode 100644 index 0000000..82edd02 --- /dev/null +++ b/src/hermes/session.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + DEFAULT_ANDROID_APP_ID, + DEFAULT_IOS_APP_ID, + DEFAULT_METRO_PORT, + defaultAppIdForPlatform, + getHermesSession, + HermesSession, + resetHermesSession, +} from './session.js'; + +describe('defaultAppIdForPlatform', () => { + it('returns the iOS bundle id for ios', () => { + expect(defaultAppIdForPlatform('ios')).toBe(DEFAULT_IOS_APP_ID); + }); + + it('returns the Android bundle id for android', () => { + expect(defaultAppIdForPlatform('android')).toBe(DEFAULT_ANDROID_APP_ID); + }); + + it('defaults to the iOS bundle id when platform is unknown', () => { + expect(defaultAppIdForPlatform()).toBe(DEFAULT_IOS_APP_ID); + expect(defaultAppIdForPlatform(undefined)).toBe(DEFAULT_IOS_APP_ID); + }); +}); + +describe('HermesSession metroPort resolution', () => { + it('uses the default port when no env or param is set', () => { + const session = new HermesSession({ env: {} }); + expect(session.getMetroPort()).toBe(DEFAULT_METRO_PORT); + expect(session.resolve().metroPort).toBe(DEFAULT_METRO_PORT); + }); + + it('uses HERMES_METRO_PORT env over the default', () => { + const session = new HermesSession({ env: { HERMES_METRO_PORT: '9000' } }); + expect(session.getMetroPort()).toBe(9000); + expect(session.resolve().metroPort).toBe(9000); + }); + + it('uses a per-call param over env and default', () => { + const session = new HermesSession({ env: { HERMES_METRO_PORT: '9000' } }); + expect(session.resolve({ metroPort: 8082 }).metroPort).toBe(8082); + }); + + it('falls back to 8081 when HERMES_METRO_PORT is invalid', () => { + const session = new HermesSession({ + env: { HERMES_METRO_PORT: 'not-a-number' }, + }); + expect(session.getMetroPort()).toBe(8081); + expect(session.getMetroPort()).toBe(DEFAULT_METRO_PORT); + }); + + it('falls back to 8081 when HERMES_METRO_PORT is a float (non-integer)', () => { + const session = new HermesSession({ env: { HERMES_METRO_PORT: '80.5' } }); + // parseInt('80.5') === 80 which is an integer, so the parsed value wins. + expect(session.getMetroPort()).toBe(80); + }); + + it('falls back to 8081 when HERMES_METRO_PORT is empty string', () => { + const session = new HermesSession({ env: { HERMES_METRO_PORT: '' } }); + expect(session.getMetroPort()).toBe(DEFAULT_METRO_PORT); + }); +}); + +describe('HermesSession appId resolution', () => { + it('defaults to the iOS app id when no platform/env/param', () => { + const session = new HermesSession({ env: {} }); + expect(session.getAppId()).toBe(DEFAULT_IOS_APP_ID); + expect(session.resolve().appId).toBe(DEFAULT_IOS_APP_ID); + }); + + it('uses the Android default when platform is android', () => { + const session = new HermesSession({ env: {}, platform: 'android' }); + expect(session.getAppId()).toBe(DEFAULT_ANDROID_APP_ID); + }); + + it('uses the iOS default when platform is ios', () => { + const session = new HermesSession({ env: {}, platform: 'ios' }); + expect(session.getAppId()).toBe(DEFAULT_IOS_APP_ID); + }); + + it('uses HERMES_APP_ID env over the platform default', () => { + const session = new HermesSession({ + env: { HERMES_APP_ID: 'io.custom.app' }, + platform: 'android', + }); + expect(session.getAppId()).toBe('io.custom.app'); + expect(session.resolve().appId).toBe('io.custom.app'); + }); + + it('uses a per-call appId param over env and default', () => { + const session = new HermesSession({ + env: { HERMES_APP_ID: 'io.custom.app' }, + }); + expect(session.resolve({ appId: 'io.percall.app' }).appId).toBe( + 'io.percall.app', + ); + }); + + it('resolves the Android default when platform: android is passed at call time', () => { + const session = new HermesSession({ env: {} }); + expect(session.resolve({ platform: 'android' }).appId).toBe( + DEFAULT_ANDROID_APP_ID, + ); + }); + + it('still resolves the iOS default when platform: ios is passed at call time', () => { + const session = new HermesSession({ env: {} }); + expect(session.resolve({ platform: 'ios' }).appId).toBe(DEFAULT_IOS_APP_ID); + }); + + it('keeps the iOS default (back-compat) when no platform is passed at call time', () => { + const session = new HermesSession({ env: {} }); + expect(session.resolve().appId).toBe(DEFAULT_IOS_APP_ID); + }); + + it('lets an explicit HERMES_APP_ID env win over the per-call Android platform default', () => { + const session = new HermesSession({ + env: { HERMES_APP_ID: 'io.custom.app' }, + }); + expect(session.resolve({ platform: 'android' }).appId).toBe( + 'io.custom.app', + ); + }); + + it('lets a per-call appId win over the per-call platform default', () => { + const session = new HermesSession({ env: {} }); + expect( + session.resolve({ platform: 'android', appId: 'io.percall.app' }).appId, + ).toBe('io.percall.app'); + }); +}); + +describe('HermesSession pin set/get', () => { + it('starts with no pin', () => { + const session = new HermesSession({ env: {} }); + expect(session.getPinnedHermesDeviceId()).toBeUndefined(); + expect(session.resolve().pinnedDeviceId).toBeUndefined(); + }); + + it('returns the pin after it is set', () => { + const session = new HermesSession({ env: {} }); + session.setPinnedHermesDeviceId('logical-device-1'); + expect(session.getPinnedHermesDeviceId()).toBe('logical-device-1'); + expect(session.resolve().pinnedDeviceId).toBe('logical-device-1'); + }); + + it('overwrites a prior pin', () => { + const session = new HermesSession({ env: {} }); + session.setPinnedHermesDeviceId('logical-device-1'); + session.setPinnedHermesDeviceId('logical-device-2'); + expect(session.getPinnedHermesDeviceId()).toBe('logical-device-2'); + }); +}); + +describe('HermesSession.resolve immutability', () => { + it('does not mutate the stored defaults when given per-call overrides', () => { + const session = new HermesSession({ + env: { HERMES_METRO_PORT: '8081', HERMES_APP_ID: 'io.default.app' }, + }); + + session.resolve({ metroPort: 9999, appId: 'io.other.app' }); + + expect(session.getMetroPort()).toBe(8081); + expect(session.getAppId()).toBe('io.default.app'); + expect(session.resolve()).toStrictEqual({ + metroPort: 8081, + appId: 'io.default.app', + pinnedDeviceId: undefined, + }); + }); + + it('reflects the current pin without mutating it', () => { + const session = new HermesSession({ env: {} }); + session.setPinnedHermesDeviceId('pin-1'); + const resolved = session.resolve({ metroPort: 1234 }); + expect(resolved.pinnedDeviceId).toBe('pin-1'); + expect(session.getPinnedHermesDeviceId()).toBe('pin-1'); + }); +}); + +describe('getHermesSession / resetHermesSession', () => { + beforeEach(() => { + resetHermesSession(); + }); + + afterEach(() => { + resetHermesSession(); + }); + + it('returns the same singleton instance across calls', () => { + const first = getHermesSession({ env: {} }); + const second = getHermesSession(); + expect(second).toBe(first); + }); + + it('ignores options passed after the singleton is created', () => { + const first = getHermesSession({ env: { HERMES_METRO_PORT: '7000' } }); + const second = getHermesSession({ env: { HERMES_METRO_PORT: '9000' } }); + expect(second).toBe(first); + expect(second.getMetroPort()).toBe(7000); + }); + + it('resetHermesSession isolates instances between calls', () => { + const first = getHermesSession({ env: {} }); + first.setPinnedHermesDeviceId('pin-from-first'); + + resetHermesSession(); + + const second = getHermesSession({ env: {} }); + expect(second).not.toBe(first); + expect(second.getPinnedHermesDeviceId()).toBeUndefined(); + }); +}); diff --git a/src/hermes/session.ts b/src/hermes/session.ts new file mode 100644 index 0000000..353c5f2 --- /dev/null +++ b/src/hermes/session.ts @@ -0,0 +1,261 @@ +/** + * Standalone state module for Hermes CDP sessions. + * + * Holds the resolved Metro inspector proxy port, the expected app bundle ID, + * and the pinned Hermes `logicalDeviceId`. This module is intentionally + * INDEPENDENT of `DeviceBackend` — Hermes/Metro access is orthogonal to the + * idb/adb/Appium UI automation backends, and it stores no live connection + * (the MVP opens a fresh WebSocket per tool call). + * + * Resolution precedence for both port and appId is exactly: + * per-call param > environment variable > default. + */ + +/** + * Platform identifier used to pick the default app bundle ID. + * + * Kept structurally compatible with the backend's `Platform` type without + * importing it, so this module stays decoupled from `DeviceBackend`. + */ +export type HermesPlatform = 'ios' | 'android'; + +/** Default MetaMask app bundle ID for iOS (`CFBundleIdentifier`). */ +export const DEFAULT_IOS_APP_ID = 'io.metamask.MetaMask'; + +/** Default MetaMask app bundle ID for Android (`applicationId`). */ +export const DEFAULT_ANDROID_APP_ID = 'io.metamask'; + +/** Default Metro inspector proxy port when none is configured. */ +export const DEFAULT_METRO_PORT = 8081; + +/** + * Resolve the platform default app bundle ID. + * + * Defaults to the iOS id when the platform is unknown. Agents running against + * Android should pass `appId` per call or set `HERMES_APP_ID` if they are not + * using the default Android bundle ID. + * + * @param platform - The active platform, if known. + * @returns The default app bundle ID for the platform. + */ +export function defaultAppIdForPlatform(platform?: HermesPlatform): string { + return platform === 'android' ? DEFAULT_ANDROID_APP_ID : DEFAULT_IOS_APP_ID; +} + +/** + * Parse a Metro port from an environment variable value. + * + * @param value - The raw environment variable value (or undefined). + * @returns The parsed integer port, or undefined when unset/invalid. + */ +function parseEnvPort(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) ? parsed : undefined; +} + +/** + * Per-call overrides accepted by {@link HermesSession.resolve}. + * + * Any field left undefined falls back to the session default (which itself + * falls back to the relevant environment variable, then the hardcoded default). + */ +export type HermesSessionResolveInput = { + /** Override the Metro inspector proxy port for this call. */ + metroPort?: number; + /** Override the expected app bundle ID for this call. */ + appId?: string; + /** + * The active platform, used to compute the default app bundle ID AT CALL + * TIME when neither a per-call `appId` nor the `HERMES_APP_ID` env var is set. + * This lets a session created before a device connects (when the platform is + * not yet known) still resolve the correct Android default once the backend's + * live platform becomes `'android'`. + */ + platform?: HermesPlatform; +}; + +/** + * The effective Hermes session parameters for a single tool call. + */ +export type HermesSessionResolved = { + /** The resolved Metro inspector proxy port. */ + metroPort: number; + /** The resolved expected app bundle ID. */ + appId: string; + /** The pinned Hermes `logicalDeviceId`, if one has been set. */ + pinnedDeviceId: string | undefined; +}; + +/** + * Options for constructing a {@link HermesSession}. + */ +export type HermesSessionOptions = { + /** The active platform, used only to pick the default app bundle ID. */ + platform?: HermesPlatform; + /** + * Environment source. Defaults to `process.env`. Injectable for tests so + * resolution precedence can be exercised without mutating the real env. + */ + env?: NodeJS.ProcessEnv; +}; + +/** + * Holds the Metro port, expected appId, and pinned Hermes deviceId for a + * process-lifetime Hermes session. + * + * The stored port and appId are resolved once at construction time using + * `param ?? env ?? default` precedence (with `param` being undefined at + * construction — those are supplied per call to {@link resolve}). The pinned + * deviceId starts unset and is assigned on the first successful identity probe. + */ +export class HermesSession { + readonly #metroPort: number; + + readonly #expectedAppId: string; + + /** + * Whether {@link #expectedAppId} came from an explicit source (the + * `HERMES_APP_ID` env var) rather than the platform fallback. When true, the + * stored appId is a deliberate override and {@link resolve} must not let a + * per-call `platform` recompute it; when false, the stored value is only the + * construction-time platform default and `resolve` may recompute it from the + * live platform. + */ + readonly #appIdFromEnv: boolean; + + #pinnedDeviceId: string | undefined; + + constructor(options: HermesSessionOptions = {}) { + const env = options.env ?? process.env; + + this.#metroPort = parseEnvPort(env.HERMES_METRO_PORT) ?? DEFAULT_METRO_PORT; + + this.#appIdFromEnv = env.HERMES_APP_ID !== undefined; + this.#expectedAppId = + env.HERMES_APP_ID ?? defaultAppIdForPlatform(options.platform); + + this.#pinnedDeviceId = undefined; + } + + /** + * Returns the session-default Metro inspector proxy port. + * + * @returns The resolved Metro port (env `HERMES_METRO_PORT`, else 8081). + */ + getMetroPort(): number { + return this.#metroPort; + } + + /** + * Returns the session-default expected app bundle ID. + * + * @returns The resolved app bundle ID (env `HERMES_APP_ID`, else the + * platform default). + */ + getAppId(): string { + return this.#expectedAppId; + } + + /** + * Returns the pinned Hermes `logicalDeviceId` for this session, if any. + * + * Set on the first successful identity probe via + * {@link setPinnedHermesDeviceId}. Persists for the lifetime of this + * instance. + * + * @returns The pinned `logicalDeviceId`, or undefined when no pin is set. + */ + getPinnedHermesDeviceId(): string | undefined { + return this.#pinnedDeviceId; + } + + /** + * Pin the Hermes `logicalDeviceId` for this session. + * + * Called after a successful identity probe on the first call. Subsequent + * calls then enforce this pin to prevent target switching mid-session. + * + * @param id - The `logicalDeviceId` from Metro's + * `reactNative.logicalDeviceId` field. + */ + setPinnedHermesDeviceId(id: string): void { + this.#pinnedDeviceId = id; + } + + /** + * Resolve the effective parameters for a single tool call by merging + * per-call overrides over the stored session defaults. + * + * This is PURE with respect to stored state: it never mutates the stored + * port, appId, or pin. Only {@link setPinnedHermesDeviceId} mutates the pin. + * + * Port precedence is `input.metroPort ?? this.getMetroPort()` (which itself + * already encodes `env ?? default`), mirroring the reference driver's + * `input.metroPort ?? driver.getMetroPort() ?? 8081`. + * + * AppId precedence is `input.appId ?? ?? + * defaultAppIdForPlatform(input.platform) ?? `. An explicitly + * set `HERMES_APP_ID` always WINS over the per-call platform default (env is a + * deliberate override): in that case the stored appId is returned unchanged. + * Only when the stored appId is the construction-time platform fallback does + * a per-call `platform` recompute it — without mutating stored state. When no + * `platform` is passed this preserves the original iOS-default behavior. + * + * @param input - Optional per-call overrides for port, appId, and platform. + * @returns The effective `{ metroPort, appId, pinnedDeviceId }`. + */ + resolve(input: HermesSessionResolveInput = {}): HermesSessionResolved { + return { + metroPort: input.metroPort ?? this.#metroPort, + appId: input.appId ?? this.#resolveAppId(input.platform), + pinnedDeviceId: this.#pinnedDeviceId, + }; + } + + /** + * Compute the effective default appId for a call, honouring the + * env-over-platform-default precedence documented on {@link resolve}. + * + * @param platform - The live platform passed for this call, if any. + * @returns The stored env/explicit appId, or the platform default. + */ + #resolveAppId(platform: HermesPlatform | undefined): string { + if (this.#appIdFromEnv || platform === undefined) { + return this.#expectedAppId; + } + return defaultAppIdForPlatform(platform); + } +} + +/** + * Module-level lazily-created singleton instance. + */ +let sessionInstance: HermesSession | undefined; + +/** + * Returns the process-singleton {@link HermesSession}, creating it on first + * access. + * + * @param options - Options applied only when the singleton is first created. + * @returns The shared `HermesSession` instance. + */ +export function getHermesSession( + options?: HermesSessionOptions, +): HermesSession { + if (sessionInstance === undefined) { + sessionInstance = new HermesSession(options); + } + return sessionInstance; +} + +/** + * Resets the process-singleton {@link HermesSession}. + * + * Intended for test isolation so each test can start from a clean session. + */ +export function resetHermesSession(): void { + sessionInstance = undefined; +} diff --git a/src/index.ts b/src/index.ts index abd878f..65166f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,3 +21,33 @@ export type { } from './backends/types.js'; export type { SessionConfig } from './backends/session-file.js'; + +export { + runHermesCdp, + fetchDiscoveryTargets, + selectHermesTarget, + hasAmbiguousTarget, + validateWebSocketUrl, + LEGACY_SYNTHETIC_TITLE, +} from './hermes/hermes-cdp.js'; +export type { + HermesTarget, + HermesCdpResult, + RunHermesCdpInput, +} from './hermes/hermes-cdp.js'; + +export { + HermesSession, + getHermesSession, + resetHermesSession, + defaultAppIdForPlatform, + DEFAULT_IOS_APP_ID, + DEFAULT_ANDROID_APP_ID, + DEFAULT_METRO_PORT, +} from './hermes/session.js'; +export type { + HermesPlatform, + HermesSessionOptions, + HermesSessionResolved, + HermesSessionResolveInput, +} from './hermes/session.js'; diff --git a/src/server.test.ts b/src/server.test.ts index e7349ea..9bb3141 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -30,6 +30,8 @@ const EXPECTED_TOOLS = [ 'device_tap_element', 'device_type', 'device_wait_for', + 'hermes_cdp', + 'hermes_targets', ]; function createStubBackend(): LazyDeviceBackend { @@ -88,10 +90,10 @@ describe('createMcpServer', () => { expect(server.server).toBeDefined(); }); - it('registers exactly 22 tools', () => { + it('registers exactly 28 tools', () => { const server = createMcpServer(createStubBackend()); const names = getRegisteredToolNames(server); - expect(names).toHaveLength(26); + expect(names).toHaveLength(28); }); it('registers all expected tool names', () => { diff --git a/src/server.ts b/src/server.ts index ffbeb55..57bd2f7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -28,6 +28,8 @@ import { registerGetElementTextTool, registerListDevicesTool, registerSelectDeviceTool, + registerHermesCdpTool, + registerHermesTargetsTool, } from './tools/index.js'; export function createMcpServer(backend: LazyDeviceBackend): McpServer { @@ -44,6 +46,7 @@ export function createMcpServer(backend: LazyDeviceBackend): McpServer { 'Check for system alerts with device_snapshot before interacting with app elements.', 'If multiple devices are connected and no device is selected, tools will return the device list.', 'Call device_list_devices to see available devices, then device_select_device to choose one.', + 'The Hermes tools (hermes_cdp, hermes_targets) talk to the React Native Hermes JS runtime via Metro and require a DEBUG build with Metro running (Node 20 needs NODE_OPTIONS="--experimental-websocket").', ].join('\n'), }, ); @@ -74,6 +77,8 @@ export function createMcpServer(backend: LazyDeviceBackend): McpServer { registerGetElementTextTool(server, backend); registerListDevicesTool(server, backend); registerSelectDeviceTool(server, backend); + registerHermesCdpTool(server, backend); + registerHermesTargetsTool(server, backend); return server; } diff --git a/src/tools/hermes-cdp.test.ts b/src/tools/hermes-cdp.test.ts new file mode 100644 index 0000000..cc231a7 --- /dev/null +++ b/src/tools/hermes-cdp.test.ts @@ -0,0 +1,384 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerHermesCdpTool } from './hermes-cdp.js'; +import { registerHermesTargetsTool } from './hermes-targets.js'; +import type { LazyDeviceBackend } from '../backends/index.js'; +import * as core from '../hermes/hermes-cdp.js'; +import { getHermesSession, resetHermesSession } from '../hermes/session.js'; + +vi.mock('../hermes/hermes-cdp.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + runHermesCdp: vi.fn(), + fetchDiscoveryTargets: vi.fn(), + }; +}); + +const runHermesCdpMock = vi.mocked(core.runHermesCdp); +const fetchDiscoveryTargetsMock = vi.mocked(core.fetchDiscoveryTargets); + +type CdpHandlerArgs = { + method: string; + params?: Record; + timeoutMs?: number; + metroPort?: number; + appId?: string; +}; + +type TargetsHandlerArgs = { + metroPort?: number; + appId?: string; + all?: boolean; +}; + +type ToolResult = { + content: { type: string; text: string }[]; + isError?: boolean; +}; + +function createMockServer(): McpServer { + return { registerTool: vi.fn() } as unknown as McpServer; +} + +function createMockBackend(): LazyDeviceBackend { + return { platform: 'ios' } as unknown as LazyDeviceBackend; +} + +function getCdpHandler( + server: McpServer, +): (args: CdpHandlerArgs) => Promise { + const { calls } = (server.registerTool as ReturnType).mock; + return calls[0][2] as (args: CdpHandlerArgs) => Promise; +} + +function getTargetsHandler( + server: McpServer, +): (args: TargetsHandlerArgs) => Promise { + const { calls } = (server.registerTool as ReturnType).mock; + return calls[0][2] as (args: TargetsHandlerArgs) => Promise; +} + +function target(overrides: Record = {}): core.HermesTarget { + return { + id: 'device-page-1', + title: 'io.metamask.MetaMask (iPhone 15)', + appId: 'io.metamask.MetaMask', + webSocketDebuggerUrl: 'ws://localhost:8081/inspector/debug?page=1', + reactNative: { + logicalDeviceId: 'logical-device-1', + capabilities: { nativePageReloads: true }, + }, + ...overrides, + }; +} + +describe('registerHermesCdpTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetHermesSession(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetHermesSession(); + }); + + it('registers the tool with the correct name', () => { + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + expect(server.registerTool).toHaveBeenCalledWith( + 'hermes_cdp', + expect.objectContaining({ title: 'Hermes CDP' }), + expect.any(Function), + ); + }); + + it('returns the CDP result as formatted JSON text on success', async () => { + runHermesCdpMock.mockResolvedValue({ + ok: true, + result: { result: { type: 'number', value: 2 } }, + }); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + const result = await handler({ + method: 'Runtime.evaluate', + params: { expression: '1+1', returnByValue: true }, + }); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].type).toBe('text'); + expect(JSON.parse(result.content[0].text)).toStrictEqual({ + result: { type: 'number', value: 2 }, + }); + }); + + it('maps a failure result to an error result containing [CODE]', async () => { + runHermesCdpMock.mockResolvedValue({ + ok: false, + code: core.HERMES_TARGET_NOT_FOUND, + message: 'No Hermes debug target found for appId io.metamask.MetaMask', + }); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + const result = await handler({ method: 'Runtime.evaluate' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('[HERMES_TARGET_NOT_FOUND]'); + expect(result.content[0].text).toContain('No Hermes debug target found'); + }); + + it('clamps timeoutMs above the maximum to 120_000', async () => { + runHermesCdpMock.mockResolvedValue({ ok: true, result: {} }); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + await handler({ method: 'Runtime.evaluate', timeoutMs: 999_999 }); + + expect(runHermesCdpMock).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 120_000 }), + ); + }); + + it('passes a smaller timeoutMs through unchanged', async () => { + runHermesCdpMock.mockResolvedValue({ ok: true, result: {} }); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + await handler({ method: 'Runtime.evaluate', timeoutMs: 5_000 }); + + expect(runHermesCdpMock).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 5_000 }), + ); + }); + + it('floors a tiny timeoutMs to the minimum (1_000)', async () => { + runHermesCdpMock.mockResolvedValue({ ok: true, result: {} }); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + await handler({ method: 'Runtime.evaluate', timeoutMs: 10 }); + + expect(runHermesCdpMock).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 1_000 }), + ); + }); + + it('defaults timeoutMs to 30_000 when omitted', async () => { + runHermesCdpMock.mockResolvedValue({ ok: true, result: {} }); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + await handler({ method: 'Runtime.evaluate' }); + + expect(runHermesCdpMock).toHaveBeenCalledWith( + expect.objectContaining({ timeoutMs: 30_000 }), + ); + }); + + it('reads the pin from the session and persists a new pin via onPin', async () => { + runHermesCdpMock.mockImplementation(async (input) => { + input.onPin?.('logical-device-7'); + return { ok: true, result: {} }; + }); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + await handler({ method: 'Runtime.evaluate' }); + + expect(runHermesCdpMock).toHaveBeenCalledWith( + expect.objectContaining({ pinnedDeviceId: undefined }), + ); + expect(getHermesSession().getPinnedHermesDeviceId()).toBe( + 'logical-device-7', + ); + + await handler({ method: 'Runtime.evaluate' }); + expect(runHermesCdpMock).toHaveBeenLastCalledWith( + expect.objectContaining({ pinnedDeviceId: 'logical-device-7' }), + ); + }); + + it('onPin does not overwrite an already-set differing pin (compare-and-set)', async () => { + getHermesSession().setPinnedHermesDeviceId('logical-device-existing'); + runHermesCdpMock.mockImplementation(async (input) => { + input.onPin?.('logical-device-other'); + return { ok: true, result: {} }; + }); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + await handler({ method: 'Runtime.evaluate' }); + + expect(getHermesSession().getPinnedHermesDeviceId()).toBe( + 'logical-device-existing', + ); + }); + + it('resolves metroPort and appId overrides through the session', async () => { + runHermesCdpMock.mockResolvedValue({ ok: true, result: {} }); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + await handler({ + method: 'Runtime.evaluate', + metroPort: 8090, + appId: 'io.custom.app', + }); + + expect(runHermesCdpMock).toHaveBeenCalledWith( + expect.objectContaining({ metroPort: 8090, appId: 'io.custom.app' }), + ); + }); + + it('returns an error result when the core throws', async () => { + runHermesCdpMock.mockRejectedValue(new Error('unexpected boom')); + const server = createMockServer(); + registerHermesCdpTool(server, createMockBackend()); + + const handler = getCdpHandler(server); + const result = await handler({ method: 'Runtime.evaluate' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('unexpected boom'); + }); +}); + +describe('registerHermesTargetsTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetHermesSession(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetHermesSession(); + }); + + it('registers the tool with the correct name and readOnlyHint', () => { + const server = createMockServer(); + registerHermesTargetsTool(server, createMockBackend()); + + expect(server.registerTool).toHaveBeenCalledWith( + 'hermes_targets', + expect.objectContaining({ + title: 'Hermes Targets', + annotations: { readOnlyHint: true }, + }), + expect.any(Function), + ); + }); + + it('lists candidates and reports the chosen target', async () => { + fetchDiscoveryTargetsMock.mockResolvedValue([target()]); + const server = createMockServer(); + registerHermesTargetsTool(server, createMockBackend()); + + const handler = getTargetsHandler(server); + const result = await handler({}); + const { text } = result.content[0]; + + expect(text).toContain('Metro port: 8081'); + expect(text).toContain('Expected appId: io.metamask.MetaMask'); + expect(text).toContain('Candidate 1:'); + expect(text).toContain('device-page-1'); + expect(text).toContain('Chosen target: device-page-1'); + }); + + it('reports ambiguity when multiple devices are present', async () => { + fetchDiscoveryTargetsMock.mockResolvedValue([ + target({ + id: 'a', + reactNative: { logicalDeviceId: 'd1', capabilities: {} }, + }), + target({ + id: 'b', + webSocketDebuggerUrl: 'ws://localhost:8081/b', + reactNative: { logicalDeviceId: 'd2', capabilities: {} }, + }), + ]); + const server = createMockServer(); + registerHermesTargetsTool(server, createMockBackend()); + + const handler = getTargetsHandler(server); + const result = await handler({}); + + expect(result.content[0].text).toContain('Ambiguous:'); + }); + + it('returns a clear message when no targets are discovered', async () => { + fetchDiscoveryTargetsMock.mockResolvedValue([]); + const server = createMockServer(); + registerHermesTargetsTool(server, createMockBackend()); + + const handler = getTargetsHandler(server); + const result = await handler({}); + + expect(result.content[0].text).toBe( + 'Metro not running or no debuggable app registered.', + ); + }); + + it('returns a clear message when Metro is down (ECONNREFUSED)', async () => { + fetchDiscoveryTargetsMock.mockRejectedValue(new Error('ECONNREFUSED')); + const server = createMockServer(); + registerHermesTargetsTool(server, createMockBackend()); + + const handler = getTargetsHandler(server); + const result = await handler({}); + + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe( + 'Metro not running or no debuggable app registered.', + ); + }); + + it('surfaces an error result for non-Metro-down failures', async () => { + fetchDiscoveryTargetsMock.mockRejectedValue(new Error('weird parse error')); + const server = createMockServer(); + registerHermesTargetsTool(server, createMockBackend()); + + const handler = getTargetsHandler(server); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('weird parse error'); + }); + + it('all:true bypasses the appId filter and lists every target', async () => { + fetchDiscoveryTargetsMock.mockResolvedValue([ + target({ id: 'mine' }), + target({ + id: 'theirs', + appId: 'io.other.app', + webSocketDebuggerUrl: 'ws://localhost:8081/other', + reactNative: { logicalDeviceId: 'd2', capabilities: {} }, + }), + ]); + const server = createMockServer(); + registerHermesTargetsTool(server, createMockBackend()); + + const handler = getTargetsHandler(server); + const result = await handler({ all: true }); + const { text } = result.content[0]; + + expect(text).toContain('(filter bypassed by all=true)'); + expect(text).toContain('Candidates listed: 2'); + expect(text).toContain('mine'); + expect(text).toContain('theirs'); + }); +}); diff --git a/src/tools/hermes-cdp.ts b/src/tools/hermes-cdp.ts new file mode 100644 index 0000000..80cb114 --- /dev/null +++ b/src/tools/hermes-cdp.ts @@ -0,0 +1,118 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { errorResult } from './shared.js'; +import type { DeviceBackend } from '../backends/types.js'; +import { runHermesCdp } from '../hermes/hermes-cdp.js'; +import { getHermesSession } from '../hermes/session.js'; + +const DEFAULT_TIMEOUT_MS = 30_000; +const MIN_TIMEOUT_MS = 1_000; +const MAX_TIMEOUT_MS = 120_000; + +/** + * Registers the `hermes_cdp` MCP tool, which speaks raw Chrome DevTools + * Protocol (CDP) to the React Native Hermes runtime of a running app via + * Metro's inspector proxy (iOS and Android). + * + * The handler is intentionally THIN: it resolves the Metro port/appId and the + * current device pin from {@link getHermesSession}, delegates ALL discovery, + * verification, and execution logic to {@link runHermesCdp}, and persists the + * resolved pin via the core's `onPin` callback. + * + * @param server - The MCP server to register the tool on. + * @param backend - The lazy device backend, used at call time only to read the + * live platform so the session can resolve the correct default app bundle ID. + */ +export function registerHermesCdpTool( + server: McpServer, + backend: DeviceBackend, +): void { + server.registerTool( + 'hermes_cdp', + { + title: 'Hermes CDP', + description: + 'Speak raw Chrome DevTools Protocol (CDP) to the React Native Hermes ' + + 'JS runtime of a running app via Metro\u2019s inspector proxy (iOS and ' + + 'Android). Requires a DEBUG build with Metro running; release builds ' + + 'expose no inspector. Blocked methods (for safety): ' + + 'Runtime.terminateExecution, Inspector.detached. Node 20 must be ' + + 'launched with NODE_OPTIONS="--experimental-websocket" (Node 22+ works ' + + 'out of the box). Example: method "Runtime.evaluate" with params ' + + '{"expression":"1+1","returnByValue":true} \u2014 the raw response nests ' + + 'the value at result.result.value.', + inputSchema: { + method: z + .string() + .describe('The CDP method to invoke, e.g. "Runtime.evaluate".'), + params: z + .record(z.string(), z.unknown()) + .optional() + .describe('Optional CDP method parameters.'), + timeoutMs: z + .number() + .int() + .positive() + .optional() + .describe( + `Timeout in milliseconds applied to each phase independently: ` + + `discovery, socket open, identity probe, and the CDP call ` + + `(default ${DEFAULT_TIMEOUT_MS}, floored to ${MIN_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}).`, + ), + metroPort: z + .number() + .optional() + .describe('Override the Metro inspector proxy port for this call.'), + appId: z + .string() + .optional() + .describe('Override the expected app bundle identifier.'), + }, + }, + async ({ method, params, timeoutMs, metroPort, appId }) => { + try { + const session = getHermesSession(); + const resolved = session.resolve({ + metroPort, + appId, + platform: backend.platform, + }); + const clampedTimeoutMs = Math.min( + Math.max(timeoutMs ?? DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS), + MAX_TIMEOUT_MS, + ); + + const result = await runHermesCdp({ + method, + params, + timeoutMs: clampedTimeoutMs, + metroPort: resolved.metroPort, + appId: resolved.appId, + pinnedDeviceId: resolved.pinnedDeviceId, + onPin: (id) => { + const currentPin = session.getPinnedHermesDeviceId(); + if (currentPin === undefined) { + session.setPinnedHermesDeviceId(id); + } + }, + }); + + if (!result.ok) { + return errorResult(new Error(`[${result.code}] ${result.message}`)); + } + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(result.result, null, 2), + }, + ], + }; + } catch (error) { + return errorResult(error); + } + }, + ); +} diff --git a/src/tools/hermes-targets.ts b/src/tools/hermes-targets.ts new file mode 100644 index 0000000..57f7690 --- /dev/null +++ b/src/tools/hermes-targets.ts @@ -0,0 +1,166 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +import { errorResult } from './shared.js'; +import type { DeviceBackend } from '../backends/types.js'; +import { + fetchDiscoveryTargets, + hasAmbiguousTarget, + LEGACY_SYNTHETIC_TITLE, + selectHermesTarget, +} from '../hermes/hermes-cdp.js'; +import type { HermesTarget } from '../hermes/hermes-cdp.js'; +import { getHermesSession } from '../hermes/session.js'; + +const DISCOVERY_TIMEOUT_MS = 10_000; + +function formatTarget(target: HermesTarget): string { + const { reactNative } = target; + return [ + ` id: ${target.id ?? ''}`, + ` title: ${target.title ?? ''}`, + ` appId: ${target.appId ?? ''}`, + ` logicalDeviceId: ${reactNative?.logicalDeviceId ?? ''}`, + ` nativePageReloads: ${ + reactNative?.capabilities?.nativePageReloads ?? '' + }`, + ` wsUrl: ${target.webSocketDebuggerUrl ?? ''}`, + ].join('\n'); +} + +function describeMetroDown(error: unknown): string | undefined { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('ECONNREFUSED') || message.includes('fetch failed')) { + return 'Metro not running or no debuggable app registered.'; + } + return undefined; +} + +/** + * Registers the `hermes_targets` MCP tool, which lists and diagnoses the + * debuggable Hermes targets that Metro's inspector proxy currently exposes + * (iOS and Android). + * + * The handler is THIN: it resolves the Metro port/appId from + * {@link getHermesSession}, delegates discovery to {@link fetchDiscoveryTargets} + * and selection/ambiguity reporting to {@link selectHermesTarget} / + * {@link hasAmbiguousTarget}, and formats a readable report. With `all` set, + * it bypasses the appId filter so agents can discover the real appId / confirm + * Metro is up. No discovery or selection LOGIC lives in this file. + * + * @param server - The MCP server to register the tool on. + * @param backend - The lazy device backend, used at call time only to read the + * live platform so the session can resolve the correct default app bundle ID. + */ +export function registerHermesTargetsTool( + server: McpServer, + backend: DeviceBackend, +): void { + server.registerTool( + 'hermes_targets', + { + title: 'Hermes Targets', + description: + 'List and diagnose the debuggable React Native Hermes targets that ' + + 'Metro\u2019s inspector proxy currently exposes (iOS and Android). ' + + 'Reports each candidate (id / title / appId / logicalDeviceId / ' + + 'nativePageReloads / wsUrl) plus which target would be chosen or why ' + + 'it is ambiguous. Use it to confirm Metro is running and your app is ' + + 'registered. Requires a DEBUG build with Metro running.', + inputSchema: { + metroPort: z + .number() + .optional() + .describe('Override the Metro inspector proxy port for this call.'), + appId: z + .string() + .optional() + .describe('Override the expected app bundle identifier.'), + all: z + .boolean() + .optional() + .describe( + 'Bypass the appId filter and list ALL candidates (diagnostics).', + ), + }, + annotations: { readOnlyHint: true }, + }, + async ({ metroPort, appId, all }) => { + try { + const resolved = getHermesSession().resolve({ + metroPort, + appId, + platform: backend.platform, + }); + const targets = await fetchDiscoveryTargets( + resolved.metroPort, + DISCOVERY_TIMEOUT_MS, + ); + + if (targets.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: 'Metro not running or no debuggable app registered.', + }, + ], + }; + } + + const candidates = all + ? targets + : targets + .filter((target) => Boolean(target.webSocketDebuggerUrl)) + .filter((target) => target.title !== LEGACY_SYNTHETIC_TITLE) + .filter((target) => target.appId === resolved.appId); + + const lines: string[] = [ + `Metro port: ${resolved.metroPort}`, + `Expected appId: ${resolved.appId}${all ? ' (filter bypassed by all=true)' : ''}`, + `Targets discovered: ${targets.length}`, + `Candidates listed: ${candidates.length}`, + '', + ]; + + candidates.forEach((target, index) => { + lines.push(`Candidate ${index + 1}:`, formatTarget(target), ''); + }); + + const selection = selectHermesTarget( + targets, + resolved.appId, + resolved.pinnedDeviceId, + ); + if (selection.ok) { + lines.push( + `Chosen target: ${selection.target.id ?? ''} (device=${ + selection.target.reactNative?.logicalDeviceId ?? '' + })`, + ); + } else if ( + selection.code === 'HERMES_MULTIPLE_DEVICES' && + hasAmbiguousTarget(candidates) + ) { + lines.push(`Ambiguous: ${selection.message}`); + } else { + lines.push( + `No target chosen: [${selection.code}] ${selection.message}`, + ); + } + + return { + content: [{ type: 'text' as const, text: lines.join('\n') }], + }; + } catch (error) { + const metroDown = describeMetroDown(error); + if (metroDown) { + return { + content: [{ type: 'text' as const, text: metroDown }], + }; + } + return errorResult(error); + } + }, + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index c77d28f..6fb4674 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -24,3 +24,5 @@ export { registerGenerateLocatorsTool } from './generate-locators.js'; export { registerGetElementTextTool } from './get-element-text.js'; export { registerListDevicesTool } from './list-devices.js'; export { registerSelectDeviceTool } from './select-device.js'; +export { registerHermesCdpTool } from './hermes-cdp.js'; +export { registerHermesTargetsTool } from './hermes-targets.js';