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..fa27370
--- /dev/null
+++ b/android/snapshot-helper/src/main/java/io/metamask/devicemcp/snapshothelper/ClipboardInstrumentation.java
@@ -0,0 +1,92 @@
+package io.metamask.devicemcp.snapshothelper;
+
+import android.app.Instrumentation;
+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;
+
+/**
+ * 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. 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
+ 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");
+ }
+ 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);
+ }
+ });
+ 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,182 @@ 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('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));
+
+ 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..3c8f53a 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,76 @@ 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.
+ if (!this.#helperInstalled) {
+ await ensureHelperInstalled(this.#serial);
+ this.#helperInstalled = true;
+ }
+ // Trust: per use, as for snapshots. Never write through a squatter.
+ 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',
+ ['-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..8f8b60f
--- /dev/null
+++ b/src/backends/android-instrumentation/clipboard.ts
@@ -0,0 +1,92 @@
+/**
+ * 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';
+
+/**
+ * `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/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';
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'])