Skip to content

Commit 3bb5e6c

Browse files
thymikeeclaude
andauthored
fix: prevent APK file paths from being used as package names in adb run-as (#204)
resolveAndroidApp treated any input containing a dot as a package name, which caused APK file paths (e.g. /path/to/app-debug.apk) to flow through as appId into runtime hints, failing with a misleading "app not debuggable" diagnosis. Android package names never contain slashes, so exclude paths with '/' from the dot-based package heuristic. Also differentiate probe failures in writeAndroidDevPrefs: check isAndroidRunAsDeniedOutput on the probe result so connectivity/transport errors are not misattributed to debuggability. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3847958 commit 3bb5e6c

4 files changed

Lines changed: 69 additions & 3 deletions

File tree

src/daemon/__tests__/runtime-hints.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,40 @@ test('applyRuntimeHintsToApp distinguishes run-as denial from general write fail
249249
});
250250
});
251251

252+
test('applyRuntimeHintsToApp uses generic probe hint when probe fails without run-as denial output', async () => {
253+
await withMockedAdb(async ({ device }) => {
254+
process.env.AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE = '1';
255+
process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDERR = 'error: device not found';
256+
try {
257+
await assert.rejects(
258+
applyRuntimeHintsToApp({
259+
device,
260+
appId: 'com.example.demo',
261+
runtime: {
262+
platform: 'android',
263+
metroHost: '10.0.0.10',
264+
metroPort: 8081,
265+
},
266+
}),
267+
(error: unknown) => {
268+
assert.ok(error instanceof AppError);
269+
assert.equal(error.message, 'Failed to probe Android app sandbox for com.example.demo');
270+
assert.equal(
271+
error.details?.hint,
272+
'adb shell run-as probe failed. Check adb connectivity and that the device is reachable. Inspect stderr/details for more information.',
273+
);
274+
assert.equal(error.details?.exitCode, 1);
275+
assert.match(String(error.details?.stderr), /device not found/);
276+
return true;
277+
},
278+
);
279+
} finally {
280+
delete process.env.AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE;
281+
delete process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDERR;
282+
}
283+
});
284+
});
285+
252286
test('applyRuntimeHintsToApp preserves write failures after a successful run-as probe', async () => {
253287
await withMockedAdb(async ({ device }) => {
254288
process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE = '1';

src/daemon/runtime-hints.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const ANDROID_RUN_AS_HINT =
1515
'React Native runtime hints require adb run-as access to the app sandbox. Verify the app is debuggable and the selected package/device are correct.';
1616
const ANDROID_WRITE_HINT =
1717
'adb run-as succeeded, but writing ReactNativeDevPrefs.xml failed. Inspect stderr/details for the failing shell command.';
18+
const ANDROID_PROBE_HINT =
19+
'adb shell run-as probe failed. Check adb connectivity and that the device is reachable. Inspect stderr/details for more information.';
1820
const DEFAULT_ANDROID_PREFS_XML = [
1921
'<?xml version="1.0" encoding="utf-8" standalone="yes" ?>',
2022
'<map>',
@@ -129,17 +131,20 @@ async function writeAndroidDevPrefs(device: DeviceInfo, packageName: string, xml
129131
const probeArgs = adbArgs(device, ['shell', 'run-as', packageName, 'id']);
130132
const probeResult = await runCmd('adb', probeArgs, { allowFailure: true });
131133
if (probeResult.exitCode !== 0) {
134+
const runAsDenied = isAndroidRunAsDeniedOutput(probeResult.stdout, probeResult.stderr);
132135
throw new AppError(
133136
'COMMAND_FAILED',
134-
`Failed to access Android app sandbox for ${packageName}`,
137+
runAsDenied
138+
? `Failed to access Android app sandbox for ${packageName}`
139+
: `Failed to probe Android app sandbox for ${packageName}`,
135140
{
136141
package: packageName,
137142
cmd: 'adb',
138143
args: probeArgs,
139144
stdout: probeResult.stdout,
140145
stderr: probeResult.stderr,
141146
exitCode: probeResult.exitCode,
142-
hint: ANDROID_RUN_AS_HINT,
147+
hint: runAsDenied ? ANDROID_RUN_AS_HINT : ANDROID_PROBE_HINT,
143148
},
144149
);
145150
}

src/platforms/android/__tests__/index.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
listAndroidApps,
1414
openAndroidApp,
1515
parseAndroidLaunchComponent,
16+
resolveAndroidApp,
1617
pushAndroidNotification,
1718
readAndroidClipboardText,
1819
setAndroidSetting,
@@ -772,6 +773,32 @@ test('swipeAndroid invokes adb input swipe with duration', async () => {
772773
}
773774
});
774775

776+
test('resolveAndroidApp does not treat file paths as package names', async () => {
777+
await withMockedAdb(
778+
'agent-device-android-resolve-path-',
779+
[
780+
'#!/bin/sh',
781+
'if [ "$1" = "-s" ]; then shift; shift; fi',
782+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ]; then',
783+
' echo "package:com.example.demo"',
784+
' exit 0',
785+
'fi',
786+
'exit 0',
787+
'',
788+
].join('\n'),
789+
async ({ device }) => {
790+
await assert.rejects(
791+
resolveAndroidApp(device, '/path/to/app-debug.apk'),
792+
(error: unknown) => {
793+
assert.ok(error instanceof AppError);
794+
assert.equal(error.code, 'APP_NOT_INSTALLED');
795+
return true;
796+
},
797+
);
798+
},
799+
);
800+
});
801+
775802
test('openAndroidApp default launch uses -p package flag', async () => {
776803
await withMockedAdb(
777804
'agent-device-android-open-default-',

src/platforms/android/app-lifecycle.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export async function resolveAndroidApp(
2020
app: string,
2121
): Promise<{ type: 'intent' | 'package'; value: string }> {
2222
const trimmed = app.trim();
23-
if (trimmed.includes('.')) return { type: 'package', value: trimmed };
23+
if (trimmed.includes('.') && !trimmed.includes('/')) return { type: 'package', value: trimmed };
2424

2525
const alias = ALIASES[trimmed.toLowerCase()];
2626
if (alias) return alias;

0 commit comments

Comments
 (0)