Skip to content

Commit 3763772

Browse files
authored
fix: clarify android runtime hint failures (#202)
1 parent f23df4d commit 3763772

2 files changed

Lines changed: 155 additions & 8 deletions

File tree

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
33
import { promises as fs } from 'node:fs';
44
import os from 'node:os';
55
import path from 'node:path';
6+
import { AppError } from '../../utils/errors.ts';
67
import {
78
applyRuntimeHintsToApp,
89
clearRuntimeHintsFromApp,
@@ -39,7 +40,27 @@ async function withMockedAdb(
3940
' fi',
4041
' exit 1',
4142
'fi',
43+
'if [ "$1" = "shell" ] && [ "$2" = "run-as" ] && [ "$4" = "id" ]; then',
44+
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT" ]; then',
45+
' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT"',
46+
' else',
47+
' printf "%s\\n" "uid=10162(u0_a162) gid=10162(u0_a162) groups=10162(u0_a162)"',
48+
' fi',
49+
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_ID_STDERR" ]; then',
50+
' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_ID_STDERR" >&2',
51+
' fi',
52+
' exit "${AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE:-0}"',
53+
'fi',
4254
'if [ "$1" = "shell" ] && [ "$2" = "run-as" ] && [ "$4" = "sh" ] && [ "$5" = "-c" ]; then',
55+
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT" ]; then',
56+
' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT"',
57+
' fi',
58+
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR" ]; then',
59+
' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR" >&2',
60+
' fi',
61+
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE" ] && [ "$AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE" != "0" ]; then',
62+
' exit "$AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE"',
63+
' fi',
4364
' printf "%s" "$6" > "$AGENT_DEVICE_TEST_SCRIPT_FILE"',
4465
' exit 0',
4566
'fi',
@@ -55,6 +76,12 @@ async function withMockedAdb(
5576
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
5677
const previousReadFile = process.env.AGENT_DEVICE_TEST_READ_FILE;
5778
const previousScriptFile = process.env.AGENT_DEVICE_TEST_SCRIPT_FILE;
79+
const previousRunAsIdExitCode = process.env.AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE;
80+
const previousRunAsIdStdout = process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT;
81+
const previousRunAsIdStderr = process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDERR;
82+
const previousRunAsWriteExitCode = process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE;
83+
const previousRunAsWriteStdout = process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT;
84+
const previousRunAsWriteStderr = process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR;
5885
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
5986
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
6087
process.env.AGENT_DEVICE_TEST_READ_FILE = readFilePath;
@@ -75,6 +102,12 @@ async function withMockedAdb(
75102
restoreEnv('AGENT_DEVICE_TEST_ARGS_FILE', previousArgsFile);
76103
restoreEnv('AGENT_DEVICE_TEST_READ_FILE', previousReadFile);
77104
restoreEnv('AGENT_DEVICE_TEST_SCRIPT_FILE', previousScriptFile);
105+
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE', previousRunAsIdExitCode);
106+
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT', previousRunAsIdStdout);
107+
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_ID_STDERR', previousRunAsIdStderr);
108+
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE', previousRunAsWriteExitCode);
109+
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT', previousRunAsWriteStdout);
110+
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR', previousRunAsWriteStderr);
78111
await fs.rm(tmpDir, { recursive: true, force: true });
79112
}
80113
}
@@ -174,6 +207,76 @@ test('applyRuntimeHintsToApp writes React Native Android dev prefs', async () =>
174207
});
175208
});
176209

210+
test('applyRuntimeHintsToApp distinguishes run-as denial from general write failures', async () => {
211+
await withMockedAdb(async ({ device }) => {
212+
process.env.AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE = '1';
213+
process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDERR = 'run-as: package not debuggable: com.example.demo';
214+
try {
215+
await assert.rejects(
216+
applyRuntimeHintsToApp({
217+
device,
218+
appId: 'com.example.demo',
219+
runtime: {
220+
platform: 'android',
221+
metroHost: '10.0.0.10',
222+
metroPort: 8081,
223+
},
224+
}),
225+
(error: unknown) => {
226+
assert.ok(error instanceof AppError);
227+
assert.equal(error.message, 'Failed to access Android app sandbox for com.example.demo');
228+
assert.equal(
229+
error.details?.hint,
230+
'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.',
231+
);
232+
assert.equal(error.details?.exitCode, 1);
233+
assert.match(String(error.details?.stderr), /not debuggable/);
234+
return true;
235+
},
236+
);
237+
} finally {
238+
delete process.env.AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE;
239+
delete process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDERR;
240+
}
241+
});
242+
});
243+
244+
test('applyRuntimeHintsToApp preserves write failures after a successful run-as probe', async () => {
245+
await withMockedAdb(async ({ device }) => {
246+
process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE = '1';
247+
process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR =
248+
"sh: can't create shared_prefs/ReactNativeDevPrefs.xml: Permission denied";
249+
try {
250+
await assert.rejects(
251+
applyRuntimeHintsToApp({
252+
device,
253+
appId: 'com.example.demo',
254+
runtime: {
255+
platform: 'android',
256+
metroHost: '10.0.0.10',
257+
metroPort: 8081,
258+
},
259+
}),
260+
(error: unknown) => {
261+
assert.ok(error instanceof AppError);
262+
assert.equal(error.message, 'Failed to write Android runtime hints for com.example.demo');
263+
assert.equal(
264+
error.details?.hint,
265+
'adb run-as succeeded, but writing ReactNativeDevPrefs.xml failed. Inspect stderr/details for the failing shell command.',
266+
);
267+
assert.equal(error.details?.phase, 'write-runtime-hints');
268+
assert.equal(error.details?.exitCode, 1);
269+
assert.match(String(error.details?.stderr), /permission denied/i);
270+
return true;
271+
},
272+
);
273+
} finally {
274+
delete process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE;
275+
delete process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR;
276+
}
277+
});
278+
});
279+
177280
test('clearRuntimeHintsFromApp removes managed Android runtime prefs but preserves unrelated entries', async () => {
178281
await withMockedAdb(async ({ device, readFilePath, scriptFilePath }) => {
179282
await fs.writeFile(

src/daemon/runtime-hints.ts

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { URL } from 'node:url';
22
import type { DeviceInfo } from '../utils/device.ts';
3-
import { AppError } from '../utils/errors.ts';
3+
import { AppError, asAppError } from '../utils/errors.ts';
44
import { runCmd } from '../utils/exec.ts';
55
import type { SessionRuntimeHints } from './types.ts';
66
import { adbArgs } from '../platforms/android/adb.ts';
@@ -11,6 +11,10 @@ const ANDROID_DEBUG_HOST_KEY = 'debug_http_host';
1111
const ANDROID_HTTPS_KEY = 'dev_server_https';
1212
const IOS_JS_LOCATION_KEY = 'RCT_jsLocation';
1313
const IOS_PACKAGER_SCHEME_KEY = 'RCT_packager_scheme';
14+
const ANDROID_RUN_AS_HINT =
15+
'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.';
16+
const ANDROID_WRITE_HINT =
17+
'adb run-as succeeded, but writing ReactNativeDevPrefs.xml failed. Inspect stderr/details for the failing shell command.';
1418
const DEFAULT_ANDROID_PREFS_XML = [
1519
'<?xml version="1.0" encoding="utf-8" standalone="yes" ?>',
1620
'<map>',
@@ -122,26 +126,53 @@ async function readAndroidDevPrefs(device: DeviceInfo, packageName: string): Pro
122126
}
123127

124128
async function writeAndroidDevPrefs(device: DeviceInfo, packageName: string, xml: string): Promise<void> {
129+
const probeArgs = adbArgs(device, ['shell', 'run-as', packageName, 'id']);
130+
const probeResult = await runCmd('adb', probeArgs, { allowFailure: true });
131+
if (probeResult.exitCode !== 0) {
132+
throw new AppError(
133+
'COMMAND_FAILED',
134+
`Failed to access Android app sandbox for ${packageName}`,
135+
{
136+
package: packageName,
137+
cmd: 'adb',
138+
args: probeArgs,
139+
stdout: probeResult.stdout,
140+
stderr: probeResult.stderr,
141+
exitCode: probeResult.exitCode,
142+
hint: ANDROID_RUN_AS_HINT,
143+
},
144+
);
145+
}
146+
125147
const script = [
126148
'mkdir -p shared_prefs',
127149
`cat > ${ANDROID_DEV_PREFS_PATH} <<'EOF'`,
128150
xml.trimEnd(),
129151
'EOF',
130152
].join('\n');
153+
const writeArgs = adbArgs(device, ['shell', 'run-as', packageName, 'sh', '-c', script]);
131154
try {
132-
await runCmd(
133-
'adb',
134-
adbArgs(device, ['shell', 'run-as', packageName, 'sh', '-c', script]),
135-
);
155+
await runCmd('adb', writeArgs);
136156
} catch (error) {
157+
const appErr = asAppError(error);
158+
if (appErr.code === 'TOOL_MISSING') throw appErr;
159+
const stdout = typeof appErr.details?.stdout === 'string' ? appErr.details.stdout : '';
160+
const stderr = typeof appErr.details?.stderr === 'string' ? appErr.details.stderr : '';
161+
const runAsDenied = isAndroidRunAsDeniedOutput(stdout, stderr);
137162
throw new AppError(
138163
'COMMAND_FAILED',
139-
`Failed to configure Android runtime hints for ${packageName}`,
164+
runAsDenied
165+
? `Failed to access Android app sandbox for ${packageName}`
166+
: `Failed to write Android runtime hints for ${packageName}`,
140167
{
168+
...(appErr.details ?? {}),
141169
package: packageName,
142-
hint: 'React Native runtime hints require a debuggable Android app so adb run-as can update ReactNativeDevPrefs.xml.',
170+
cmd: 'adb',
171+
args: writeArgs,
172+
phase: 'write-runtime-hints',
173+
hint: runAsDenied ? ANDROID_RUN_AS_HINT : ANDROID_WRITE_HINT,
143174
},
144-
error as Error,
175+
appErr,
145176
);
146177
}
147178
}
@@ -251,3 +282,16 @@ function escapeXmlText(value: string): string {
251282
.replaceAll('"', '&quot;')
252283
.replaceAll('\'', '&apos;');
253284
}
285+
286+
function isAndroidRunAsDeniedOutput(stdout: string, stderr: string): boolean {
287+
const output = `${stdout}\n${stderr}`.toLowerCase();
288+
return [
289+
'run-as: package not debuggable',
290+
'run-as: permission denied',
291+
'run-as: package is unknown',
292+
'run-as: unknown package',
293+
'is unknown',
294+
'is not an application',
295+
'could not set capabilities',
296+
].some((pattern) => output.includes(pattern));
297+
}

0 commit comments

Comments
 (0)