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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions android/snapshot-helper/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,11 @@
android:targetPackage="io.metamask.devicemcp.snapshothelper"
android:label="Device MCP Snapshot Helper"
android:functionalTest="true" />

<!-- Writes the device clipboard for an app's own Paste control. -->
<instrumentation
android:name=".ClipboardInstrumentation"
android:targetPackage="io.metamask.devicemcp.snapshothelper"
android:label="Device MCP Clipboard Helper"
android:functionalTest="true" />
</manifest>
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>`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.
*
* <p>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<Throwable> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions scripts/build-android-helper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ cat > "$MANIFEST" <<JSON
"versionCode": $VERSION_CODE,
"packageName": "io.metamask.devicemcp.snapshothelper",
"instrumentationRunner": "io.metamask.devicemcp.snapshothelper/.SnapshotInstrumentation",
"clipboardInstrumentationRunner": "io.metamask.devicemcp.snapshothelper/.ClipboardInstrumentation",
"assetName": "device-mcp-android-snapshot-helper-$VERSION.apk",
"sha256": "$APK_SHA256",
"signerSha256": "$SIGNER_SHA256",
Expand Down
182 changes: 182 additions & 0 deletions src/backends/adb-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import {
assertInstalledHelperTrusted,
ensureHelperInstalled,
installHelper,
} from './android-instrumentation/installer.js';
import type { UIElement } from './types.js';
import * as webviewCdpModule from './webview-cdp.js';
Expand All @@ -41,6 +42,7 @@ vi.mock('./android-instrumentation/installer.js', () => ({
},
ensureHelperInstalled: vi.fn().mockResolvedValue(undefined),
assertInstalledHelperTrusted: vi.fn().mockResolvedValue(undefined),
installHelper: vi.fn().mockResolvedValue(undefined),
INSTRUMENTATION_NOT_FOUND_SIGNATURE: 'INSTRUMENTATION_FAILED',
}));

Expand All @@ -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',
Expand Down Expand Up @@ -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<ReturnType<typeof loadHelperArtifact>> => ({
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);
});
});
Loading
Loading