From a6b6e31485b62dfc0cb3a0beca306689bce7ffd7 Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Wed, 28 Oct 2026 09:54:07 +0100 Subject: [PATCH 1/3] feat: add android clipboard helper --- android/snapshot-helper/AndroidManifest.xml | 7 + .../ClipboardInstrumentation.java | 77 +++++++++ .../snapshothelper/HelperProtocol.java | 7 +- scripts/build-android-helper.sh | 1 + src/backends/adb-backend.test.ts | 155 ++++++++++++++++++ src/backends/adb-backend.ts | 68 +++++++- .../android-instrumentation/artifact.test.ts | 31 ++++ .../android-instrumentation/artifact.ts | 16 ++ .../android-instrumentation/clipboard.test.ts | 121 ++++++++++++++ .../android-instrumentation/clipboard.ts | 95 +++++++++++ src/tools/clipboard.ts | 2 +- 11 files changed, 570 insertions(+), 10 deletions(-) create mode 100644 android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/ClipboardInstrumentation.java create mode 100644 src/backends/android-instrumentation/clipboard.test.ts create mode 100644 src/backends/android-instrumentation/clipboard.ts diff --git a/android/snapshot-helper/AndroidManifest.xml b/android/snapshot-helper/AndroidManifest.xml index c525654..98aba9b 100644 --- a/android/snapshot-helper/AndroidManifest.xml +++ b/android/snapshot-helper/AndroidManifest.xml @@ -30,4 +30,11 @@ android:targetPackage="io.metamask.devicemcp.snapshothelper" android:label="Device MCP Snapshot Helper" android:functionalTest="true" /> + + + diff --git a/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/ClipboardInstrumentation.java b/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/ClipboardInstrumentation.java new file mode 100644 index 0000000..52c1f81 --- /dev/null +++ b/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/ClipboardInstrumentation.java @@ -0,0 +1,77 @@ +package io.metamask.devicemcp.snapshothelper; + +import android.app.Instrumentation; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.os.Bundle; +import android.util.Base64; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Writes text to the device clipboard so an app's own Paste control can read it. + * + *

`cmd clipboard` has no shell implementation on recent Android images, and + * background apps cannot write the clipboard. An instrumentation runs with the + * permissions the clipboard service grants to test runners, so it can write + * without being in the foreground. + * + *

The text arrives base64-encoded in the {@code textB64} argument so spaces + * and shell metacharacters survive {@code am instrument -e}. The result never + * contains the text: callers may be passing a secret recovery phrase. + */ +public final class ClipboardInstrumentation extends Instrumentation { + private static final String CLIP_LABEL = "device-mcp"; + + private Bundle arguments; + + @Override + public void onCreate(Bundle arguments) { + super.onCreate(arguments); + this.arguments = arguments == null ? new Bundle() : arguments; + start(); + } + + @Override + public void onStart() { + super.onStart(); + Bundle result = new Bundle(); + result.putString("agentDeviceProtocol", HelperProtocol.PROTOCOL); + result.putInt("helperApiVersion", HelperProtocol.HELPER_API_VERSION); + try { + String encoded = arguments.getString("textB64"); + if (encoded == null) { + throw new IllegalArgumentException("Missing textB64 argument"); + } + final String text = + new String(Base64.decode(encoded, Base64.DEFAULT), StandardCharsets.UTF_8); + final Context context = getContext(); + final AtomicReference failure = new AtomicReference<>(); + // ClipboardManager must be used from a thread with a Looper. + runOnMainSync( + () -> { + try { + ClipboardManager clipboard = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null) { + throw new IllegalStateException("Clipboard service unavailable"); + } + clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, text)); + } catch (Throwable error) { + failure.set(error); + } + }); + if (failure.get() != null) { + throw failure.get(); + } + result.putString("ok", "true"); + result.putString("length", Integer.toString(text.length())); + } catch (Throwable error) { + // Report only the error type: a message could echo the input. + result.putString("ok", "false"); + result.putString("errorType", error.getClass().getName()); + } + finish(android.app.Activity.RESULT_OK, result); + } +} diff --git a/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/HelperProtocol.java b/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/HelperProtocol.java index bd48d5e..85757f1 100644 --- a/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/HelperProtocol.java +++ b/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/HelperProtocol.java @@ -8,8 +8,11 @@ final class HelperProtocol { /** Literal emitted as {@code agentDeviceProtocol} in every status record. */ static final String PROTOCOL = "device-mcp-snapshot-helper-v1"; - /** Bumped when the output contract changes in a host-incompatible way. */ - static final int HELPER_API_VERSION = 1; + /** + * Bumped when the output contract changes in a host-incompatible way. 2 adds + * {@link ClipboardInstrumentation}; snapshot output is unchanged. + */ + static final int HELPER_API_VERSION = 2; /** Output format label for the chunked hierarchy payload. */ static final String OUTPUT_FORMAT = "uiautomator-xml"; diff --git a/scripts/build-android-helper.sh b/scripts/build-android-helper.sh index e2744eb..b5bde5f 100755 --- a/scripts/build-android-helper.sh +++ b/scripts/build-android-helper.sh @@ -203,6 +203,7 @@ cat > "$MANIFEST" < ({ }, ensureHelperInstalled: vi.fn().mockResolvedValue(undefined), assertInstalledHelperTrusted: vi.fn().mockResolvedValue(undefined), + installHelper: vi.fn().mockResolvedValue(undefined), INSTRUMENTATION_NOT_FOUND_SIGNATURE: 'INSTRUMENTATION_FAILED', })); @@ -60,6 +62,7 @@ const mockAssertInstalledHelperTrusted = vi.mocked( assertInstalledHelperTrusted, ); const mockReaddir = vi.mocked(readdir); +const mockInstallHelper = vi.mocked(installHelper); const mockLoadHelperArtifact = vi.mocked(loadHelperArtifact); const ARTIFACT_MISSING = new SnapshotHelperError( 'ARTIFACT_INVALID', @@ -1522,3 +1525,155 @@ describe('AdbBackend.webviewCdp', () => { expect(outcome.code).toBe('WEBVIEW_SOCKET_AMBIGUOUS'); }); }); + +describe('AdbBackend clipboard', () => { + const CLIPBOARD_RUNNER = + 'io.metamask.devicemcp.snapshothelper/.ClipboardInstrumentation'; + const SECRET = 'umbrella review narrow orphan fork extra'; + + // `null` models a helper manifest built before clipboard support. + const artifact = ( + clipboardInstrumentationRunner: string | null = CLIPBOARD_RUNNER, + ): Awaited> => ({ + version: '0.5.1', + versionCode: 5001, + packageName: 'io.metamask.devicemcp.snapshothelper', + instrumentationRunner: + 'io.metamask.devicemcp.snapshothelper/.SnapshotInstrumentation', + ...(clipboardInstrumentationRunner === null + ? {} + : { clipboardInstrumentationRunner }), + apkPath: '/tmp/helper.apk', + sha256: 'a'.repeat(64), + signerSha256: 'b'.repeat(64), + minSdk: 23, + }); + + const okStdout = (length: number): string => + `INSTRUMENTATION_RESULT: ok=true\r\nINSTRUMENTATION_RESULT: length=${length}\r\nINSTRUMENTATION_CODE: -1\r\n`; + + const clipboardCalls = (): string[][] => + mockExec.mock.calls + .map((call) => call[1] ?? []) + .filter((args) => args.includes(CLIPBOARD_RUNNER)); + + let backend: AdbBackend; + + beforeEach(() => { + vi.resetAllMocks(); + backend = new AdbBackend('emulator-5554'); + mockLoadHelperArtifact.mockResolvedValue(artifact()); + mockEnsureHelperInstalled.mockResolvedValue(undefined); + mockAssertInstalledHelperTrusted.mockResolvedValue(undefined); + mockInstallHelper.mockResolvedValue(undefined); + mockExec.mockResolvedValue({ + stdout: okStdout(SECRET.length), + stderr: '', + exitCode: 0, + }); + }); + + it('writes the text through the clipboard instrumentation', async () => { + await backend.setClipboard(SECRET); + + const calls = clipboardCalls(); + expect(calls).toHaveLength(1); + const [args] = calls; + expect(args.slice(0, 2)).toStrictEqual(['-s', 'emulator-5554']); + const encoded = args[args.indexOf('textB64') + 1]; + expect(Buffer.from(encoded, 'base64').toString('utf8')).toBe(SECRET); + }); + + it('installs and trust-checks the helper before writing', async () => { + await backend.setClipboard(SECRET); + + expect(mockEnsureHelperInstalled).toHaveBeenCalledWith('emulator-5554'); + expect(mockAssertInstalledHelperTrusted).toHaveBeenCalledTimes(1); + const trustOrder = + mockAssertInstalledHelperTrusted.mock.invocationCallOrder[0]; + const execOrder = mockExec.mock.invocationCallOrder[0]; + expect(trustOrder).toBeLessThan(execOrder); + }); + + it('never writes through an untrusted helper', async () => { + mockAssertInstalledHelperTrusted.mockRejectedValue( + new UntrustedHelperError('signer mismatch', { + expectedSignerSha256: 'b'.repeat(64), + }), + ); + + await expect(backend.setClipboard(SECRET)).rejects.toBeInstanceOf( + UntrustedHelperError, + ); + expect(clipboardCalls()).toHaveLength(0); + }); + + it('never passes the plain text to adb', async () => { + await backend.setClipboard(SECRET); + + for (const call of mockExec.mock.calls) { + expect((call[1] ?? []).join(' ')).not.toContain('umbrella'); + } + }); + + // Matches a real device: `am` prints the missing runner on stderr, exit 1. + const missingRunner = { + stdout: + 'INSTRUMENTATION_STATUS: Error=Unable to find instrumentation info\r\nINSTRUMENTATION_STATUS_CODE: -1\r\n', + stderr: `android.util.AndroidException: INSTRUMENTATION_FAILED: ${CLIPBOARD_RUNNER}\n`, + exitCode: 1, + }; + + it('reinstalls once when the installed helper lacks the clipboard runner', async () => { + mockExec.mockResolvedValueOnce(missingRunner).mockResolvedValueOnce({ + stdout: okStdout(SECRET.length), + stderr: '', + exitCode: 0, + }); + + await backend.setClipboard(SECRET); + + expect(mockInstallHelper).toHaveBeenCalledTimes(1); + expect(clipboardCalls()).toHaveLength(2); + expect(mockAssertInstalledHelperTrusted).toHaveBeenCalledTimes(2); + }); + + it('fails when the reinstalled helper still lacks the runner', async () => { + mockExec.mockResolvedValue(missingRunner); + + await expect(backend.setClipboard(SECRET)).rejects.toThrow( + /no result record/u, + ); + expect(mockInstallHelper).toHaveBeenCalledTimes(1); + }); + + it('fails clearly when the bundled helper predates clipboard support', async () => { + mockLoadHelperArtifact.mockResolvedValue(artifact(null)); + + await expect(backend.setClipboard(SECRET)).rejects.toThrow( + /predates clipboard support/u, + ); + expect(clipboardCalls()).toHaveLength(0); + }); + + it('surfaces a helper failure without the text', async () => { + mockExec.mockResolvedValue({ + stdout: + 'INSTRUMENTATION_RESULT: ok=false\r\nINSTRUMENTATION_RESULT: errorType=java.lang.SecurityException\r\nINSTRUMENTATION_CODE: -1\r\n', + stderr: '', + exitCode: 0, + }); + + const error = await backend.setClipboard(SECRET).then( + () => null, + (caught: unknown) => caught, + ); + + expect(String(error)).toMatch(/SecurityException/u); + expect(String(error)).not.toContain('umbrella'); + }); + + it('does not support reading the clipboard', async () => { + await expect(backend.getClipboard()).rejects.toThrow(/not supported/u); + }); +}); diff --git a/src/backends/adb-backend.ts b/src/backends/adb-backend.ts index 2f6357e..318315a 100644 --- a/src/backends/adb-backend.ts +++ b/src/backends/adb-backend.ts @@ -13,6 +13,10 @@ import { selectWebViewSocket, } from './adb-forward.js'; import { loadHelperArtifact } from './android-instrumentation/artifact.js'; +import { + assertClipboardWriteSucceeded, + buildClipboardInstrumentArgs, +} from './android-instrumentation/clipboard.js'; import { isHelperIncompleteCaptureError, isHelperNotInstalledError, @@ -22,6 +26,7 @@ import { assertInstalledHelperTrusted, ensureHelperInstalled, HelperTrustCache, + installHelper, INSTRUMENTATION_NOT_FOUND_SIGNATURE, } from './android-instrumentation/installer.js'; import { @@ -82,6 +87,9 @@ const INSTRUMENT_TIMEOUT_MS = 8_000; // 10MB guard to keep an overflow from masquerading as a helper crash. const INSTRUMENT_MAX_BUFFER = 16 * 1024 * 1024; +// Writing the clipboard is a single binder call; bound it like a snapshot. +const CLIPBOARD_TIMEOUT_MS = 15_000; + // In `auto` mode, try a single fast uiautomator dump first (it wins instantly // on idle screens) before paying for the instrumentation path. const AUTO_PROBE_DUMP_ATTEMPTS = 1; @@ -1024,17 +1032,63 @@ export class AdbBackend implements DeviceBackend { } async getClipboard(): Promise { + // Reading back is intentionally unsupported: callers write secrets (e.g. a + // recovery phrase) and must not be able to echo them. throw new Error( - 'Clipboard access via ADB is not supported on modern Android. ' + - 'Use the Appium backend for clipboard operations.', + 'Reading the clipboard via ADB is not supported. ' + + 'Use the Appium backend to read clipboard contents.', ); } - async setClipboard(_text: string): Promise { - throw new Error( - 'Clipboard access via ADB is not supported on modern Android. ' + - 'Use the Appium backend for clipboard operations.', - ); + /** + * Write text to the device clipboard through the helper's clipboard + * instrumentation, so an app's own Paste control can read it. The text is + * never logged or included in errors. + * + * @param text - The text to place on the clipboard. + */ + async setClipboard(text: string): Promise { + await this.#serializeCapture(async () => { + let reinstalled = false; + for (;;) { + const artifact = await loadHelperArtifact(); + const runner = artifact.clipboardInstrumentationRunner; + if (!runner) { + throw new Error( + 'The bundled Android helper predates clipboard support. ' + + 'Rebuild device-mcp with build:android-helper.', + ); + } + + // Freshness: upgrades a helper installed before clipboard support. + await ensureHelperInstalled(this.#serial); + this.#helperInstalled = true; + // Trust: per use, as for snapshots. Never write through a squatter. + await assertInstalledHelperTrusted(this.#serial, this.#helperTrust); + + const { stdout, stderr } = await exec( + 'adb', + ['-s', this.#serial, ...buildClipboardInstrumentArgs(runner, text)], + { timeoutMs: CLIPBOARD_TIMEOUT_MS }, + ); + + // `am` reports a missing runner as an exception on stderr. + if ( + !reinstalled && + `${stdout}\n${stderr}`.includes(INSTRUMENTATION_NOT_FOUND_SIGNATURE) + ) { + // A same-versionCode helper without the clipboard runner: reinstall. + reinstalled = true; + this.#helperInstalled = false; + this.#helperTrust.forget(); + await installHelper(this.#serial, artifact); + continue; + } + + assertClipboardWriteSucceeded(stdout, text.length); + return; + } + }); } async startScreenRecording(outputPath?: string): Promise { diff --git a/src/backends/android-instrumentation/artifact.test.ts b/src/backends/android-instrumentation/artifact.test.ts index 51fe67d..39526e4 100644 --- a/src/backends/android-instrumentation/artifact.test.ts +++ b/src/backends/android-instrumentation/artifact.test.ts @@ -157,4 +157,35 @@ describe('loadHelperArtifact', () => { await expect(loadHelperArtifact()).rejects.toThrow(/not valid JSON/u); }); + + it('reads the clipboard runner when the manifest declares it', async () => { + wireFs( + validManifest({ + clipboardInstrumentationRunner: + 'io.metamask.devicemcp.snapshothelper/.ClipboardInstrumentation', + }), + ); + + const artifact = await loadHelperArtifact(); + + expect(artifact.clipboardInstrumentationRunner).toBe( + 'io.metamask.devicemcp.snapshothelper/.ClipboardInstrumentation', + ); + }); + + it('leaves the clipboard runner unset for a helper built before clipboard support', async () => { + wireFs(validManifest()); + + const artifact = await loadHelperArtifact(); + + expect(artifact.clipboardInstrumentationRunner).toBeUndefined(); + }); + + it('rejects a clipboard runner that is not a string', async () => { + wireFs(validManifest({ clipboardInstrumentationRunner: 42 })); + + await expect(loadHelperArtifact()).rejects.toThrow( + /clipboardInstrumentationRunner/u, + ); + }); }); diff --git a/src/backends/android-instrumentation/artifact.ts b/src/backends/android-instrumentation/artifact.ts index 7eada6a..0e6f731 100644 --- a/src/backends/android-instrumentation/artifact.ts +++ b/src/backends/android-instrumentation/artifact.ts @@ -28,6 +28,11 @@ export type HelperArtifact = { versionCode: number; packageName: string; instrumentationRunner: string; + /** + * Runner for the clipboard instrumentation. Absent in helpers built before + * clipboard support; callers must treat that as "clipboard unsupported". + */ + clipboardInstrumentationRunner?: string; apkPath: string; sha256: string; signerSha256: string; @@ -182,6 +187,14 @@ export async function loadHelperArtifact(): Promise { ); } + const clipboardInstrumentationRunner = + manifest.clipboardInstrumentationRunner === undefined + ? undefined + : requireString( + manifest.clipboardInstrumentationRunner, + 'clipboardInstrumentationRunner', + ); + return { version: requireString(manifest.version, 'version'), versionCode: requireInt(manifest.versionCode, 'versionCode'), @@ -190,6 +203,9 @@ export async function loadHelperArtifact(): Promise { manifest.instrumentationRunner, 'instrumentationRunner', ), + ...(clipboardInstrumentationRunner === undefined + ? {} + : { clipboardInstrumentationRunner }), apkPath, sha256, signerSha256: requireSha256(manifest.signerSha256, 'signerSha256'), diff --git a/src/backends/android-instrumentation/clipboard.test.ts b/src/backends/android-instrumentation/clipboard.test.ts new file mode 100644 index 0000000..69281a7 --- /dev/null +++ b/src/backends/android-instrumentation/clipboard.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; + +import { + assertClipboardWriteSucceeded, + buildClipboardInstrumentArgs, + CLIPBOARD_TEXT_ARG, +} from './clipboard.js'; + +const RUNNER = 'io.metamask.devicemcp.snapshothelper/.ClipboardInstrumentation'; + +const SECRET = 'umbrella review narrow orphan fork extra blouse idle'; + +/** + * Build `am instrument -w` stdout as the clipboard instrumentation emits it. + * + * @param result - Key/value pairs for the `INSTRUMENTATION_RESULT:` record. + * @param code - The terminal `INSTRUMENTATION_CODE:` value. + * @returns Raw stdout with adb's CRLF line endings. + */ +function buildStdout(result: Record, code = '-1'): string { + const lines = Object.entries(result).map( + ([key, value]) => `INSTRUMENTATION_RESULT: ${key}=${value}`, + ); + lines.push(`INSTRUMENTATION_CODE: ${code}`); + return `${lines.join('\r\n')}\r\n`; +} + +describe('buildClipboardInstrumentArgs', () => { + it('targets the runner and base64-encodes the text', () => { + const args = buildClipboardInstrumentArgs(RUNNER, SECRET); + + expect(args.slice(0, 4)).toStrictEqual(['shell', 'am', 'instrument', '-w']); + expect(args.at(-1)).toBe(RUNNER); + const index = args.indexOf(CLIPBOARD_TEXT_ARG); + expect(args[index - 1]).toBe('-e'); + expect(Buffer.from(args[index + 1], 'base64').toString('utf8')).toBe( + SECRET, + ); + }); + + it('never passes the plain text as an argument', () => { + const args = buildClipboardInstrumentArgs(RUNNER, SECRET); + + expect(args.join(' ')).not.toContain('umbrella'); + }); + + it('keeps shell metacharacters out of the argument', () => { + const text = `it's $HOME; rm -rf / && echo "x" | cat`; + const args = buildClipboardInstrumentArgs(RUNNER, text); + const encoded = args[args.indexOf(CLIPBOARD_TEXT_ARG) + 1]; + + expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/u); + expect(Buffer.from(encoded, 'base64').toString('utf8')).toBe(text); + }); +}); + +describe('assertClipboardWriteSucceeded', () => { + it('accepts a successful write of the full text', () => { + expect(() => + assertClipboardWriteSucceeded( + buildStdout({ ok: 'true', length: String(SECRET.length) }), + SECRET.length, + ), + ).not.toThrow(); + }); + + it('rejects output with no result record', () => { + expect(() => + assertClipboardWriteSucceeded( + 'INSTRUMENTATION_FAILED: io.metamask.devicemcp.snapshothelper/.ClipboardInstrumentation\r\n', + 5, + ), + ).toThrow(/no result record/u); + }); + + it('reports the error type when the helper fails', () => { + expect(() => + assertClipboardWriteSucceeded( + buildStdout( + { ok: 'false', errorType: 'java.lang.SecurityException' }, + '-1', + ), + 5, + ), + ).toThrow(/reported failure \(java\.lang\.SecurityException\)/u); + }); + + it('rejects a non-success instrumentation code', () => { + expect(() => + assertClipboardWriteSucceeded( + buildStdout({ ok: 'true', length: '5' }, '0'), + 5, + ), + ).toThrow(/ended with code 0/u); + }); + + it('rejects a length mismatch', () => { + expect(() => + assertClipboardWriteSucceeded( + buildStdout({ ok: 'true', length: '3' }), + 5, + ), + ).toThrow(/wrote 3 characters, expected 5/u); + }); + + it('never includes the text in an error', () => { + const error = ((): unknown => { + try { + assertClipboardWriteSucceeded( + buildStdout({ ok: 'false', errorType: 'java.lang.Error' }), + SECRET.length, + ); + return null; + } catch (caught) { + return caught; + } + })(); + + expect(String(error)).not.toContain('umbrella'); + }); +}); diff --git a/src/backends/android-instrumentation/clipboard.ts b/src/backends/android-instrumentation/clipboard.ts new file mode 100644 index 0000000..5e22be0 --- /dev/null +++ b/src/backends/android-instrumentation/clipboard.ts @@ -0,0 +1,95 @@ +/** + * Arguments and result parsing for the helper's clipboard instrumentation + * (`io.metamask.devicemcp.snapshothelper/.ClipboardInstrumentation`). + * + * `cmd clipboard` has no shell implementation on recent Android images and + * background apps cannot write the clipboard, so the helper sets it from an + * instrumentation. The text travels base64-encoded so spaces and shell + * metacharacters survive `am instrument -e`. Callers may pass a secret recovery + * phrase: nothing here returns, logs, or embeds the text in an error. + * + * Orchestration (install, trust check, invoke) lives in the ADB backend. + */ + +import { parseInstrumentationOutput } from './snapshot.js'; + +/** Instrumentation argument carrying the base64-encoded clipboard text. */ +export const CLIPBOARD_TEXT_ARG = 'textB64'; + +/** Lowest `helperApiVersion` that ships the clipboard instrumentation. */ +export const CLIPBOARD_MIN_HELPER_API_VERSION = 2; + +/** + * `INSTRUMENTATION_CODE: -1` is `Activity.RESULT_OK`, the helper's success code. + */ +const SUCCESS_INSTRUMENTATION_CODE = '-1'; + +/** + * Build the `adb shell` arguments that write `text` to the device clipboard. + * + * @param runner - The clipboard instrumentation runner from the artifact. + * @param text - The text to place on the clipboard. + * @returns Arguments to pass after `adb -s `. + */ +export function buildClipboardInstrumentArgs( + runner: string, + text: string, +): string[] { + const encoded = Buffer.from(text, 'utf8').toString('base64'); + return [ + 'shell', + 'am', + 'instrument', + '-w', + '-e', + CLIPBOARD_TEXT_ARG, + encoded, + runner, + ]; +} + +/** + * Validate the clipboard instrumentation's output. + * + * @param stdout - Raw stdout from `adb shell am instrument -w ...`. + * @param expectedLength - Length of the text that was sent, in UTF-16 code + * units (Java's `String.length()`), used to confirm the helper decoded it. + * @throws When the helper did not report a successful write of the full text. + * Messages never include the text. + */ +export function assertClipboardWriteSucceeded( + stdout: string, + expectedLength: number, +): void { + const { result, instrumentationCode } = parseInstrumentationOutput(stdout); + + if (result.size === 0) { + throw new Error( + 'Clipboard helper produced no result record (the instrumentation was ' + + 'interrupted or is not installed).', + ); + } + + if (result.get('ok') !== 'true') { + const errorType = result.get('errorType'); + throw new Error( + `Clipboard helper reported failure${errorType ? ` (${errorType})` : ''}.`, + ); + } + + if ( + instrumentationCode !== null && + instrumentationCode !== SUCCESS_INSTRUMENTATION_CODE + ) { + throw new Error( + `Clipboard helper ended with code ${instrumentationCode} (expected ${SUCCESS_INSTRUMENTATION_CODE}).`, + ); + } + + const length = Number(result.get('length')); + if (!Number.isInteger(length) || length !== expectedLength) { + throw new Error( + `Clipboard helper wrote ${String(result.get('length'))} characters, expected ${expectedLength}.`, + ); + } +} diff --git a/src/tools/clipboard.ts b/src/tools/clipboard.ts index c102c1c..cfc0590 100644 --- a/src/tools/clipboard.ts +++ b/src/tools/clipboard.ts @@ -15,7 +15,7 @@ export function registerClipboardTool( description: 'Read or write the device clipboard. ' + 'Use action=get to read clipboard text, action=set to write. ' + - 'On Android, clipboard access requires the Appium backend.', + 'On Android (adb), only action=set is supported; reading requires the Appium backend.', inputSchema: { action: z .enum(['get', 'set']) From 0675e4adc796ea2502525692d55847fbadbf040e Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Wed, 28 Oct 2026 10:12:49 +0100 Subject: [PATCH 2/3] fix: review --- src/backends/adb-backend.ts | 6 ++++-- src/backends/android-instrumentation/clipboard.ts | 3 --- src/backends/android-instrumentation/snapshot.ts | 5 ++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/backends/adb-backend.ts b/src/backends/adb-backend.ts index 318315a..4318305 100644 --- a/src/backends/adb-backend.ts +++ b/src/backends/adb-backend.ts @@ -1061,8 +1061,10 @@ export class AdbBackend implements DeviceBackend { } // Freshness: upgrades a helper installed before clipboard support. - await ensureHelperInstalled(this.#serial); - this.#helperInstalled = true; + if (!this.#helperInstalled) { + await ensureHelperInstalled(this.#serial); + this.#helperInstalled = true; + } // Trust: per use, as for snapshots. Never write through a squatter. await assertInstalledHelperTrusted(this.#serial, this.#helperTrust); diff --git a/src/backends/android-instrumentation/clipboard.ts b/src/backends/android-instrumentation/clipboard.ts index 5e22be0..8f8b60f 100644 --- a/src/backends/android-instrumentation/clipboard.ts +++ b/src/backends/android-instrumentation/clipboard.ts @@ -16,9 +16,6 @@ import { parseInstrumentationOutput } from './snapshot.js'; /** Instrumentation argument carrying the base64-encoded clipboard text. */ export const CLIPBOARD_TEXT_ARG = 'textB64'; -/** Lowest `helperApiVersion` that ships the clipboard instrumentation. */ -export const CLIPBOARD_MIN_HELPER_API_VERSION = 2; - /** * `INSTRUMENTATION_CODE: -1` is `Activity.RESULT_OK`, the helper's success code. */ diff --git a/src/backends/android-instrumentation/snapshot.ts b/src/backends/android-instrumentation/snapshot.ts index 7545081..d739066 100644 --- a/src/backends/android-instrumentation/snapshot.ts +++ b/src/backends/android-instrumentation/snapshot.ts @@ -64,9 +64,8 @@ const STATUS_CODE_PREFIX = 'INSTRUMENTATION_STATUS_CODE:'; const RESULT_CODE_PREFIX = 'INSTRUMENTATION_CODE:'; /** - * `INSTRUMENTATION_CODE: -1` is `Activity.RESULT_FIRST_USER`, the helper's - * success sentinel. Any other terminal code means the run did not finish - * cleanly. + * `INSTRUMENTATION_CODE: -1` is `Activity.RESULT_OK`, the helper's success + * code. Any other terminal code means the run did not finish cleanly. */ const SUCCESS_INSTRUMENTATION_CODE = '-1'; From a909a293a8410ed2c8b9bed8cfac042388788b4e Mon Sep 17 00:00:00 2001 From: Priya Narayanaswamy Date: Thu, 29 Oct 2026 13:05:17 +0100 Subject: [PATCH 3/3] fix: address review --- .../ClipboardInstrumentation.java | 19 +++++++++++-- src/backends/adb-backend.test.ts | 27 +++++++++++++++++++ src/backends/adb-backend.ts | 13 ++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/ClipboardInstrumentation.java b/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/ClipboardInstrumentation.java index 52c1f81..fa27370 100644 --- a/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/ClipboardInstrumentation.java +++ b/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/ClipboardInstrumentation.java @@ -4,7 +4,9 @@ import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; +import android.os.Build; import android.os.Bundle; +import android.os.PersistableBundle; import android.util.Base64; import java.nio.charset.StandardCharsets; import java.util.concurrent.atomic.AtomicReference; @@ -19,11 +21,17 @@ * *

The text arrives base64-encoded in the {@code textB64} argument so spaces * and shell metacharacters survive {@code am instrument -e}. The result never - * contains the text: callers may be passing a secret recovery phrase. + * contains the text: callers may be passing a secret recovery phrase. The clip + * is marked sensitive so the Android 13+ copy overlay and keyboard suggestions + * do not show it. */ public final class ClipboardInstrumentation extends Instrumentation { private static final String CLIP_LABEL = "device-mcp"; + // ClipDescription.EXTRA_IS_SENSITIVE: the constant is API 33, the key is + // honored from API 33. Use the literal so the helper keeps minSdk 23. + private static final String EXTRA_IS_SENSITIVE = "android.content.extra.IS_SENSITIVE"; + private Bundle arguments; @Override @@ -57,7 +65,14 @@ public void onStart() { if (clipboard == null) { throw new IllegalStateException("Clipboard service unavailable"); } - clipboard.setPrimaryClip(ClipData.newPlainText(CLIP_LABEL, text)); + ClipData clip = ClipData.newPlainText(CLIP_LABEL, text); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // ClipDescription.setExtras is API 24. + PersistableBundle extras = new PersistableBundle(); + extras.putBoolean(EXTRA_IS_SENSITIVE, true); + clip.getDescription().setExtras(extras); + } + clipboard.setPrimaryClip(clip); } catch (Throwable error) { failure.set(error); } diff --git a/src/backends/adb-backend.test.ts b/src/backends/adb-backend.test.ts index 555f47a..dd5ea7c 100644 --- a/src/backends/adb-backend.test.ts +++ b/src/backends/adb-backend.test.ts @@ -1647,6 +1647,33 @@ describe('AdbBackend clipboard', () => { expect(mockInstallHelper).toHaveBeenCalledTimes(1); }); + it('reinstalls when the helper was removed after it was installed', async () => { + await backend.setClipboard(SECRET); + expect(mockEnsureHelperInstalled).toHaveBeenCalledTimes(1); + + mockAssertInstalledHelperTrusted.mockRejectedValueOnce( + new HelperNotInstalledError('Snapshot helper is not installed'), + ); + + await backend.setClipboard(SECRET); + + expect(mockEnsureHelperInstalled).toHaveBeenCalledTimes(2); + expect(mockAssertInstalledHelperTrusted).toHaveBeenCalledTimes(3); + expect(clipboardCalls()).toHaveLength(2); + }); + + it('fails after one reinstall when the helper stays missing', async () => { + mockAssertInstalledHelperTrusted.mockRejectedValue( + new HelperNotInstalledError('Snapshot helper is not installed'), + ); + + await expect(backend.setClipboard(SECRET)).rejects.toBeInstanceOf( + HelperNotInstalledError, + ); + expect(mockAssertInstalledHelperTrusted).toHaveBeenCalledTimes(2); + expect(clipboardCalls()).toHaveLength(0); + }); + it('fails clearly when the bundled helper predates clipboard support', async () => { mockLoadHelperArtifact.mockResolvedValue(artifact(null)); diff --git a/src/backends/adb-backend.ts b/src/backends/adb-backend.ts index 4318305..3c8f53a 100644 --- a/src/backends/adb-backend.ts +++ b/src/backends/adb-backend.ts @@ -1066,7 +1066,18 @@ export class AdbBackend implements DeviceBackend { this.#helperInstalled = true; } // Trust: per use, as for snapshots. Never write through a squatter. - await assertInstalledHelperTrusted(this.#serial, this.#helperTrust); + try { + await assertInstalledHelperTrusted(this.#serial, this.#helperTrust); + } catch (error) { + // Removed since it was installed (e.g. emulator wipe): reinstall once. + if (!reinstalled && isHelperNotInstalledError(error)) { + reinstalled = true; + this.#helperInstalled = false; + this.#helperTrust.forget(); + continue; + } + throw error; + } const { stdout, stderr } = await exec( 'adb',