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
103 changes: 103 additions & 0 deletions src/daemon/__tests__/runtime-hints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { AppError } from '../../utils/errors.ts';
import {
applyRuntimeHintsToApp,
clearRuntimeHintsFromApp,
Expand Down Expand Up @@ -39,7 +40,27 @@ async function withMockedAdb(
' fi',
' exit 1',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "run-as" ] && [ "$4" = "id" ]; then',
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT" ]; then',
' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT"',
' else',
' printf "%s\\n" "uid=10162(u0_a162) gid=10162(u0_a162) groups=10162(u0_a162)"',
' fi',
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_ID_STDERR" ]; then',
' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_ID_STDERR" >&2',
' fi',
' exit "${AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE:-0}"',
'fi',
'if [ "$1" = "shell" ] && [ "$2" = "run-as" ] && [ "$4" = "sh" ] && [ "$5" = "-c" ]; then',
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT" ]; then',
' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT"',
' fi',
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR" ]; then',
' printf "%s" "$AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR" >&2',
' fi',
' if [ -n "$AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE" ] && [ "$AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE" != "0" ]; then',
' exit "$AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE"',
' fi',
' printf "%s" "$6" > "$AGENT_DEVICE_TEST_SCRIPT_FILE"',
' exit 0',
'fi',
Expand All @@ -55,6 +76,12 @@ async function withMockedAdb(
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
const previousReadFile = process.env.AGENT_DEVICE_TEST_READ_FILE;
const previousScriptFile = process.env.AGENT_DEVICE_TEST_SCRIPT_FILE;
const previousRunAsIdExitCode = process.env.AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE;
const previousRunAsIdStdout = process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT;
const previousRunAsIdStderr = process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDERR;
const previousRunAsWriteExitCode = process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE;
const previousRunAsWriteStdout = process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT;
const previousRunAsWriteStderr = process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR;
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
process.env.AGENT_DEVICE_TEST_READ_FILE = readFilePath;
Expand All @@ -75,6 +102,12 @@ async function withMockedAdb(
restoreEnv('AGENT_DEVICE_TEST_ARGS_FILE', previousArgsFile);
restoreEnv('AGENT_DEVICE_TEST_READ_FILE', previousReadFile);
restoreEnv('AGENT_DEVICE_TEST_SCRIPT_FILE', previousScriptFile);
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE', previousRunAsIdExitCode);
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_ID_STDOUT', previousRunAsIdStdout);
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_ID_STDERR', previousRunAsIdStderr);
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE', previousRunAsWriteExitCode);
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_WRITE_STDOUT', previousRunAsWriteStdout);
restoreEnv('AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR', previousRunAsWriteStderr);
await fs.rm(tmpDir, { recursive: true, force: true });
}
}
Expand Down Expand Up @@ -174,6 +207,76 @@ test('applyRuntimeHintsToApp writes React Native Android dev prefs', async () =>
});
});

test('applyRuntimeHintsToApp distinguishes run-as denial from general write failures', async () => {
await withMockedAdb(async ({ device }) => {
process.env.AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE = '1';
process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDERR = 'run-as: package not debuggable: com.example.demo';
try {
await assert.rejects(
applyRuntimeHintsToApp({
device,
appId: 'com.example.demo',
runtime: {
platform: 'android',
metroHost: '10.0.0.10',
metroPort: 8081,
},
}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.message, 'Failed to access Android app sandbox for com.example.demo');
assert.equal(
error.details?.hint,
'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.',
);
assert.equal(error.details?.exitCode, 1);
assert.match(String(error.details?.stderr), /not debuggable/);
return true;
},
);
} finally {
delete process.env.AGENT_DEVICE_TEST_RUN_AS_ID_EXIT_CODE;
delete process.env.AGENT_DEVICE_TEST_RUN_AS_ID_STDERR;
}
});
});

test('applyRuntimeHintsToApp preserves write failures after a successful run-as probe', async () => {
await withMockedAdb(async ({ device }) => {
process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE = '1';
process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR =
"sh: can't create shared_prefs/ReactNativeDevPrefs.xml: Permission denied";
try {
await assert.rejects(
applyRuntimeHintsToApp({
device,
appId: 'com.example.demo',
runtime: {
platform: 'android',
metroHost: '10.0.0.10',
metroPort: 8081,
},
}),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.message, 'Failed to write Android runtime hints for com.example.demo');
assert.equal(
error.details?.hint,
'adb run-as succeeded, but writing ReactNativeDevPrefs.xml failed. Inspect stderr/details for the failing shell command.',
);
assert.equal(error.details?.phase, 'write-runtime-hints');
assert.equal(error.details?.exitCode, 1);
assert.match(String(error.details?.stderr), /permission denied/i);
return true;
},
);
} finally {
delete process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_EXIT_CODE;
delete process.env.AGENT_DEVICE_TEST_RUN_AS_WRITE_STDERR;
}
});
});

test('clearRuntimeHintsFromApp removes managed Android runtime prefs but preserves unrelated entries', async () => {
await withMockedAdb(async ({ device, readFilePath, scriptFilePath }) => {
await fs.writeFile(
Expand Down
60 changes: 52 additions & 8 deletions src/daemon/runtime-hints.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { URL } from 'node:url';
import type { DeviceInfo } from '../utils/device.ts';
import { AppError } from '../utils/errors.ts';
import { AppError, asAppError } from '../utils/errors.ts';
import { runCmd } from '../utils/exec.ts';
import type { SessionRuntimeHints } from './types.ts';
import { adbArgs } from '../platforms/android/adb.ts';
Expand All @@ -11,6 +11,10 @@ const ANDROID_DEBUG_HOST_KEY = 'debug_http_host';
const ANDROID_HTTPS_KEY = 'dev_server_https';
const IOS_JS_LOCATION_KEY = 'RCT_jsLocation';
const IOS_PACKAGER_SCHEME_KEY = 'RCT_packager_scheme';
const ANDROID_RUN_AS_HINT =
'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.';
const ANDROID_WRITE_HINT =
'adb run-as succeeded, but writing ReactNativeDevPrefs.xml failed. Inspect stderr/details for the failing shell command.';
const DEFAULT_ANDROID_PREFS_XML = [
'<?xml version="1.0" encoding="utf-8" standalone="yes" ?>',
'<map>',
Expand Down Expand Up @@ -122,26 +126,53 @@ async function readAndroidDevPrefs(device: DeviceInfo, packageName: string): Pro
}

async function writeAndroidDevPrefs(device: DeviceInfo, packageName: string, xml: string): Promise<void> {
const probeArgs = adbArgs(device, ['shell', 'run-as', packageName, 'id']);
const probeResult = await runCmd('adb', probeArgs, { allowFailure: true });
if (probeResult.exitCode !== 0) {
throw new AppError(
'COMMAND_FAILED',
`Failed to access Android app sandbox for ${packageName}`,
{
package: packageName,
cmd: 'adb',
args: probeArgs,
stdout: probeResult.stdout,
stderr: probeResult.stderr,
exitCode: probeResult.exitCode,
hint: ANDROID_RUN_AS_HINT,
},
);
}

const script = [
'mkdir -p shared_prefs',
`cat > ${ANDROID_DEV_PREFS_PATH} <<'EOF'`,
xml.trimEnd(),
'EOF',
].join('\n');
const writeArgs = adbArgs(device, ['shell', 'run-as', packageName, 'sh', '-c', script]);
try {
await runCmd(
'adb',
adbArgs(device, ['shell', 'run-as', packageName, 'sh', '-c', script]),
);
await runCmd('adb', writeArgs);
} catch (error) {
const appErr = asAppError(error);
if (appErr.code === 'TOOL_MISSING') throw appErr;
const stdout = typeof appErr.details?.stdout === 'string' ? appErr.details.stdout : '';
const stderr = typeof appErr.details?.stderr === 'string' ? appErr.details.stderr : '';
const runAsDenied = isAndroidRunAsDeniedOutput(stdout, stderr);
throw new AppError(
'COMMAND_FAILED',
`Failed to configure Android runtime hints for ${packageName}`,
runAsDenied
? `Failed to access Android app sandbox for ${packageName}`
: `Failed to write Android runtime hints for ${packageName}`,
{
...(appErr.details ?? {}),
package: packageName,
hint: 'React Native runtime hints require a debuggable Android app so adb run-as can update ReactNativeDevPrefs.xml.',
cmd: 'adb',
args: writeArgs,
phase: 'write-runtime-hints',
hint: runAsDenied ? ANDROID_RUN_AS_HINT : ANDROID_WRITE_HINT,
},
error as Error,
appErr,
);
}
}
Expand Down Expand Up @@ -251,3 +282,16 @@ function escapeXmlText(value: string): string {
.replaceAll('"', '&quot;')
.replaceAll('\'', '&apos;');
}

function isAndroidRunAsDeniedOutput(stdout: string, stderr: string): boolean {
const output = `${stdout}\n${stderr}`.toLowerCase();
return [
'run-as: package not debuggable',
'run-as: permission denied',
'run-as: package is unknown',
'run-as: unknown package',
'is unknown',
'is not an application',
'could not set capabilities',
].some((pattern) => output.includes(pattern));
}
Loading