Skip to content

Commit 10e0833

Browse files
committed
fix(android): shell-quote free-text arguments reaching the device shell
Text entry (input text) and clipboard write (cmd clipboard set text) now quote their free-form text argument with the same shellQuoteIfNeeded helper app-lifecycle.ts already uses for deep-link URLs and launch arguments, and app-lifecycle.ts's local duplicate of that helper is retired in favor of the shared one. Multi-word clipboard writes also now arrive at the device as a single argument instead of being re-tokenized into separate ones. Updates the provider-scenario test harness's scripted clipboard-state simulator to unwrap shell quoting the same way a device shell does, so it keeps modelling what the device actually receives.
1 parent 13cc90f commit 10e0833

7 files changed

Lines changed: 83 additions & 13 deletions

File tree

src/platforms/android/__tests__/device-input-state.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
dismissAndroidKeyboard,
88
getAndroidKeyboardState,
99
getAndroidKeyboardStatusWithAdb,
10+
writeAndroidClipboardWithAdb,
1011
} from '../device-input-state.ts';
1112
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts';
1213
import { assertRejectsAppError, withFakeAdb } from '../../../__tests__/test-utils/index.ts';
@@ -206,6 +207,30 @@ test('getAndroidKeyboardState treats stale input view as hidden when the IME win
206207
);
207208
});
208209

210+
test('writeAndroidClipboardWithAdb shell-quotes text containing metacharacters', async () => {
211+
const calls: string[][] = [];
212+
const adb: AndroidAdbExecutor = async (args) => {
213+
calls.push(args);
214+
return { stdout: '', stderr: '', exitCode: 0 };
215+
};
216+
217+
await writeAndroidClipboardWithAdb(adb, 'otp; echo pwned');
218+
219+
assert.deepEqual(calls, [['shell', 'cmd', 'clipboard', 'set', 'text', "'otp; echo pwned'"]]);
220+
});
221+
222+
test('writeAndroidClipboardWithAdb leaves safe text unquoted', async () => {
223+
const calls: string[][] = [];
224+
const adb: AndroidAdbExecutor = async (args) => {
225+
calls.push(args);
226+
return { stdout: '', stderr: '', exitCode: 0 };
227+
};
228+
229+
await writeAndroidClipboardWithAdb(adb, 'android-otp');
230+
231+
assert.deepEqual(calls, [['shell', 'cmd', 'clipboard', 'set', 'text', 'android-otp']]);
232+
});
233+
209234
test('dismissAndroidKeyboard skips keyevent when keyboard is already hidden', async () => {
210235
await withFakeAdb(
211236
(args) => {

src/platforms/android/__tests__/input-actions.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,31 @@ test('typeAndroid sends one character at a time when delay is requested', async
202202
);
203203
});
204204

205+
test('typeAndroid shell-quotes text containing shell metacharacters', async () => {
206+
await withFakeAdb(
207+
() => undefined,
208+
async ({ calls, device }) => {
209+
await typeAndroid(device, 'otp; echo pwned');
210+
// The chunk carrying `;` is single-quoted so the device shell cannot
211+
// re-tokenize it into a second command.
212+
assert.deepEqual(shellInputTextCalls(calls), [
213+
['shell', 'input', 'text', "'otp;%sech'"],
214+
['shell', 'input', 'text', 'o%spwned'],
215+
]);
216+
},
217+
);
218+
});
219+
220+
test('typeAndroid leaves safe text unquoted', async () => {
221+
await withFakeAdb(
222+
() => undefined,
223+
async ({ calls, device }) => {
224+
await typeAndroid(device, 'hello');
225+
assert.deepEqual(shellInputTextCalls(calls), [['shell', 'input', 'text', 'hello']]);
226+
},
227+
);
228+
});
229+
205230
test('fillAndroid uses chunk-safe shell input and retries when verification still fails', async () => {
206231
// First `input text` writes a wrong partial value, so attempt 1 fails
207232
// verification and production retries with the smaller chunk size.

src/platforms/android/app-lifecycle.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { sleep } from '../../utils/timeouts.ts';
77
import type { AppsFilter } from '@agent-device/contracts/device';
88
import type { DeviceInfo } from '@agent-device/kernel/device';
99
import { isDeepLinkTarget } from '@agent-device/contracts/command';
10+
import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts';
1011
import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts';
1112
import { waitForAndroidBoot } from './emulator-lifecycle.ts';
1213
import { runAndroidAdb } from './adb.ts';
@@ -306,13 +307,8 @@ export type OpenAndroidAppOptions = {
306307
// characters, so they round-trip untouched. URLs and launch arguments are
307308
// user-supplied and may contain JSON, spaces, `#`, or `&`; each is single-quoted
308309
// unless it consists entirely of safe shell characters.
309-
function quoteAndroidShellArg(arg: string): string {
310-
if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(arg)) return arg;
311-
return `'${arg.replace(/'/g, `'\\''`)}'`;
312-
}
313-
314310
function androidLaunchArgs(options: OpenAndroidAppOptions): string[] {
315-
return (options.launchArgs ?? []).map(quoteAndroidShellArg);
311+
return (options.launchArgs ?? []).map(shellQuoteIfNeeded);
316312
}
317313

318314
export async function openAndroidApp(
@@ -367,7 +363,7 @@ async function openAndroidDeepLink(
367363
'-a',
368364
'android.intent.action.VIEW',
369365
'-d',
370-
quoteAndroidShellArg(target),
366+
shellQuoteIfNeeded(target),
371367
...androidDeepLinkPackageArgs(options.appBundleId),
372368
...androidLaunchArgs(options),
373369
]);
@@ -398,7 +394,7 @@ async function openAndroidAppBoundDeepLink(
398394
'-a',
399395
'android.intent.action.VIEW',
400396
'-d',
401-
quoteAndroidShellArg(deepLinkUrl),
397+
shellQuoteIfNeeded(deepLinkUrl),
402398
'-p',
403399
resolved,
404400
...androidLaunchArgs(options),

src/platforms/android/device-input-state.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { emitDiagnostic } from '../../utils/diagnostics.ts';
22
import type { DeviceInfo } from '@agent-device/kernel/device';
33
import { AppError } from '@agent-device/kernel/errors';
4+
import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts';
45
import { isClipboardShellUnsupported, sleep } from './adb.ts';
56
import {
67
androidAdbResultError,
@@ -308,7 +309,7 @@ export async function writeAndroidClipboardWithAdb(
308309
): Promise<void> {
309310
await runAndroidClipboardShellCommand(
310311
adb,
311-
['shell', 'cmd', 'clipboard', 'set', 'text', text],
312+
['shell', 'cmd', 'clipboard', 'set', 'text', shellQuoteIfNeeded(text)],
312313
'write',
313314
);
314315
}

src/platforms/android/input-actions.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import type { DeviceInfo } from '@agent-device/kernel/device';
1212
import { AppError } from '@agent-device/kernel/errors';
1313
import { emitDiagnostic } from '../../utils/diagnostics.ts';
14+
import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts';
1415
import {
1516
resolveAndroidAdbExecutor,
1617
resolveAndroidTextInjector,
@@ -392,7 +393,12 @@ async function typeAndroidShell(
392393
async function typeAndroidShellChunk(device: DeviceInfo, text: string): Promise<void> {
393394
if (!text) return;
394395
try {
395-
await runAndroidAdb(device, ['shell', 'input', 'text', encodeAndroidInputText(text)]);
396+
await runAndroidAdb(device, [
397+
'shell',
398+
'input',
399+
'text',
400+
shellQuoteIfNeeded(encodeAndroidInputText(text)),
401+
]);
396402
} catch (error) {
397403
if (isAndroidInputTextUnsupported(error)) {
398404
throw unsupportedAndroidShellTextError(text, error);

test/integration/provider-scenarios/android-lifecycle.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1482,7 +1482,7 @@ function assertAndroidPushAndEventContract(world: AndroidSettingsWorld): void {
14821482
'com.example.demo',
14831483
]);
14841484
assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'get', 'text']);
1485-
assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'set', 'text', 'android otp']);
1485+
assertCommandCall(adbCalls, ['shell', 'cmd', 'clipboard', 'set', 'text', "'android otp'"]);
14861486
assertCommandCall(adbCalls, ['shell', 'dumpsys', 'input_method']);
14871487
}
14881488

test/integration/provider-scenarios/android-world.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,33 @@ function createAndroidProviderShellState(): AndroidProviderShellState {
263263
return { searchText: '', clipboardText: 'hello' };
264264
}
265265

266+
const ANDROID_CLIPBOARD_SET_TEXT_PREFIX = ['shell', 'cmd', 'clipboard', 'set', 'text'];
267+
266268
function updateAndroidProviderShellState(args: string[], state: AndroidProviderShellState): void {
267269
if (args[0] === 'shell' && args[1] === 'input' && args[2] === 'text') {
268270
state.searchText = String(args[3] ?? '').replaceAll('%s', ' ');
269271
return;
270272
}
271-
if (args.join(' ') === 'shell cmd clipboard set text android otp') {
272-
state.clipboardText = 'android otp';
273+
if (argsStartWith(args, ANDROID_CLIPBOARD_SET_TEXT_PREFIX)) {
274+
state.clipboardText = unquoteAndroidShellArg(
275+
String(args[ANDROID_CLIPBOARD_SET_TEXT_PREFIX.length] ?? ''),
276+
);
273277
}
274278
}
275279

280+
function argsStartWith(args: string[], prefix: string[]): boolean {
281+
return prefix.every((value, index) => args[index] === value);
282+
}
283+
284+
// The real device shell unwraps a single-quoted argument (and collapses the
285+
// `'\''` escape back to `'`) before `cmd` ever sees it, so this harness has
286+
// to mirror that unwrap to keep modelling what the device actually receives
287+
// — the inverse of the quoting in src/utils/shell-quote.ts.
288+
function unquoteAndroidShellArg(value: string): string {
289+
if (!value.startsWith("'") || !value.endsWith("'") || value.length < 2) return value;
290+
return value.slice(1, -1).replaceAll("'\\''", "'");
291+
}
292+
276293
function androidDeviceStateAdbResult(
277294
key: string,
278295
args: string[],

0 commit comments

Comments
 (0)