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
99 changes: 98 additions & 1 deletion packages/platform-android/src/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { afterEach, test, vi } from 'vitest';

const HELPER_SERVICE = 'com.callstack.agentdevice.imehelper/.TestInputMethodService';
Expand Down Expand Up @@ -29,7 +31,8 @@ vi.mock('../ime-helper.ts', async (importOriginal) => {

import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts';
import type { HostDiagnosticsContext } from '@agent-device/contracts/host-diagnostics';
import { androidDeviceChecks } from '../doctor.ts';
import { androidDeviceChecks, androidToolchainCheck } from '../doctor.ts';
import { mkdtempForTest } from './test-utils/tmp-dir.ts';
import {
resetAndroidTestImeActivationCacheForTests,
setAndroidTestImeActiveForTests,
Expand All @@ -39,6 +42,7 @@ import type { DoctorCheck } from '@agent-device/contracts/observability';

afterEach(() => {
resetAndroidTestImeActivationCacheForTests();
vi.unstubAllEnvs();
});

function fakeAdb(currentIme: string, previousIme = 'null'): AndroidAdbExecutor {
Expand Down Expand Up @@ -96,3 +100,96 @@ test('falls back to ime list -s when no previous-IME record was persisted', asyn
assert.equal(check?.status, 'fail');
assert.equal(check?.command, `adb -s ${ANDROID_EMULATOR.id} shell ime list -s`);
});

const WINDOWS_ADB_PATH = String.raw`C:\Users\dev\AppData\Local\Android\Sdk\platform-tools\adb.exe`;
const WINDOWS_ADB_VERSION = [
'Android Debug Bridge version 1.0.41',
'Version 35.0.2-12147458',
`Installed as ${WINDOWS_ADB_PATH}`,
'Running on Windows 10.0.26100',
].join('\n');
const LINUX_ADB_VERSION = [
'Android Debug Bridge version 1.0.41',
'Version 35.0.2-12147458',
'Installed as /home/dev/Android/Sdk/platform-tools/adb',
'Running on Linux 6.18.0-microsoft-standard-WSL2 (x86_64)',
].join('\n');
// adb builds before 1.0.36 and some third-party builds omit the `Installed as` banner, leaving the
// `Running on Windows` line as the only signal that the binary is a Windows one.
const WINDOWS_ADB_VERSION_WITHOUT_INSTALL_PATH = [
'Android Debug Bridge version 1.0.39',
'Running on Windows 10.0.26100',
].join('\n');
const LINUX_ADB_VERSION_WITHOUT_INSTALL_PATH = [
'Android Debug Bridge version 1.0.39',
'Running on Linux 6.18.0 (x86_64)',
].join('\n');

async function toolchainCheckWithAdbVersion(
versionOutput: string,
hostPlatform: NodeJS.Platform = 'linux',
): Promise<DoctorCheck> {
const binDir = await mkdtempForTest('agent-device-android-doctor-');
const adbPath = path.join(binDir, 'adb');
await fs.writeFile(adbPath, `#!/bin/sh\ncat <<'EOF'\n${versionOutput}\nEOF\n`, 'utf8');
Comment thread
thymikee marked this conversation as resolved.
await fs.chmod(adbPath, 0o755);
vi.stubEnv('PATH', `${binDir}${path.delimiter}${process.env.PATH ?? ''}`);
return await androidToolchainCheck(
{ ANDROID_HOME: '/mnt/c/Users/dev/AppData/Local/Android/Sdk' },
hostPlatform,
{ access: async () => {} },
);
}

test('fails the toolchain when adb on a POSIX host is the Windows binary', async () => {
const check = await toolchainCheckWithAdbVersion(WINDOWS_ADB_VERSION);
assert.equal(check.status, 'fail');
assert.equal(check.evidence?.reason, 'android_adb_windows_binary_on_posix_host');
assert.equal(check.evidence?.detectedVia, 'installed-as-path');
assert.equal(check.evidence?.adbPath, WINDOWS_ADB_PATH);
assert.match(check.summary ?? '', /Windows binary/);
assert.match(check.hint ?? '', /native binary for this host/);
assert.match(check.hint ?? '', /Under WSL/);
});

test('names the invariant in the hint for a non-WSL host reaching a Windows adb', async () => {
const check = await toolchainCheckWithAdbVersion(WINDOWS_ADB_VERSION, 'darwin');
assert.equal(check.status, 'fail');
assert.equal(check.evidence?.detectedVia, 'installed-as-path');
assert.match(check.hint ?? '', /^adb must be a native binary for this host/);
});

test('fails the toolchain from the Running on Windows line when adb omits Installed as', async () => {
const check = await toolchainCheckWithAdbVersion(WINDOWS_ADB_VERSION_WITHOUT_INSTALL_PATH);
assert.equal(check.status, 'fail');
assert.equal(check.evidence?.reason, 'android_adb_windows_binary_on_posix_host');
assert.equal(check.evidence?.detectedVia, 'running-on-line');
assert.equal(check.evidence?.adbPath, null);
});

test('keeps a native binary passing when adb omits Installed as', async () => {
const check = await toolchainCheckWithAdbVersion(LINUX_ADB_VERSION_WITHOUT_INSTALL_PATH);
assert.equal(check.status, 'pass');
assert.equal(check.evidence?.reason, undefined);
});

test('passes the toolchain when adb is the Linux binary, even under WSL', async () => {
const check = await toolchainCheckWithAdbVersion(LINUX_ADB_VERSION);
assert.equal(check.status, 'pass');
assert.equal(check.evidence?.reason, undefined);
});

test('passes the toolchain when the Windows binary runs on a Windows host', async () => {
const check = await toolchainCheckWithAdbVersion(WINDOWS_ADB_VERSION, 'win32');
assert.equal(check.status, 'pass');
assert.equal(check.evidence?.reason, undefined);
});

test('passes the toolchain on a Windows host when adb only reports Running on Windows', async () => {
const check = await toolchainCheckWithAdbVersion(
WINDOWS_ADB_VERSION_WITHOUT_INSTALL_PATH,
'win32',
);
assert.equal(check.status, 'pass');
assert.equal(check.evidence?.reason, undefined);
});
76 changes: 72 additions & 4 deletions packages/platform-android/src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device';
import { normalizeError } from '@agent-device/kernel/errors';
import type { DoctorCheck } from '@agent-device/contracts/observability';
import type { HostDiagnosticsContext } from '@agent-device/contracts/host-diagnostics';
import { commandFirstLine } from '@agent-device/provision-kit/toolchain-probe';
import { commandOutput, firstOutputLine } from '@agent-device/provision-kit/toolchain-probe';
import { resolveAndroidAdbExecutor, runAdbShell, type AndroidAdbExecutor } from './adb-executor.ts';
import {
isAndroidTestImeActive,
Expand All @@ -18,6 +18,7 @@ import {
} from './adb-host.ts';

const ANDROID_PROBE_TIMEOUT_MS = 2000;
const WINDOWS_ADB_ON_POSIX_HOST_REASON = 'android_adb_windows_binary_on_posix_host';

type AndroidLicenseState = 'accepted' | 'missing' | 'unknown';
type AndroidToolchainProbe = {
Expand All @@ -27,13 +28,19 @@ type AndroidToolchainProbe = {
};

export async function androidToolchainCheck(
environment: AndroidAdbEnvironment = requireAndroidAdbHost().environment,
environment: AndroidAdbEnvironment,
hostPlatform: NodeJS.Platform,
files: Pick<AndroidAdbFileHost, 'access'> = requireAndroidAdbHost().files,
): Promise<DoctorCheck> {
const sdkRoot = environment.ANDROID_HOME || environment.ANDROID_SDK_ROOT;
const license = await androidLicenseState(sdkRoot, files);
const versionLine = await commandFirstLine('adb', ['version']);
if (!versionLine) return missingAndroidAdbCheck(sdkRoot, license);
const versionOutput = await commandOutput('adb', ['version']);
const versionLine = versionOutput === undefined ? undefined : firstOutputLine(versionOutput);
if (!versionOutput || !versionLine) return missingAndroidAdbCheck(sdkRoot, license);
const windowsAdb = hostPlatform === 'win32' ? undefined : detectWindowsAdb(versionOutput);
if (windowsAdb) {
return windowsAdbOnPosixHostCheck({ windowsAdb, sdkRoot, versionLine });
}

return androidAdbCheck({
license,
Expand Down Expand Up @@ -173,6 +180,67 @@ async function probeAndroidReverse(
}
}

/** The binary path adb reports for itself on its `Installed as` version line. */
function androidAdbInstallPath(versionOutput: string): string | undefined {
return /^Installed as (.+)$/m.exec(versionOutput)?.[1]?.trim();
}

/** A drive-letter or UNC path, which only a Windows binary reports. */
function isWindowsHostPath(candidate: string): boolean {
return /^(?:[A-Za-z]:[\\/]|\\\\)/.test(candidate);
}

/** Banner line naming the OS the running adb was built for; a native host binary never names Windows. */
function reportsWindowsRuntime(versionOutput: string): boolean {
return /^Running on Windows\b/m.test(versionOutput);
}

/** adb's self-report that it is a Windows binary, plus the signal that revealed it. */
type WindowsAdbReport = Readonly<{
detectedVia: 'installed-as-path' | 'running-on-line';
adbPath: string | undefined;
}>;

function detectWindowsAdb(versionOutput: string): WindowsAdbReport | undefined {
const installPath = androidAdbInstallPath(versionOutput);
if (installPath && isWindowsHostPath(installPath)) {
return { detectedVia: 'installed-as-path', adbPath: installPath };
}
if (reportsWindowsRuntime(versionOutput)) {
return { detectedVia: 'running-on-line', adbPath: undefined };
}
return undefined;
}

/**
* A Windows adb.exe reached from a POSIX host through WSL interop or Wine answers
* `adb version`, but it resolves every host path it is handed as a Windows path, so pulls, pushes,
* and installs miss the host's files. Older and third-party adb builds omit the `Installed as`
* banner, so the `Running on Windows` line is the fallback signal for them.
*/
function windowsAdbOnPosixHostCheck(
probe: Readonly<{
windowsAdb: WindowsAdbReport;
sdkRoot: string | undefined;
versionLine: string;
}>,
): DoctorCheck {
const { adbPath, detectedVia } = probe.windowsAdb;
return {
id: 'toolchain',
status: 'fail',
summary: `Android toolchain: adb on PATH${adbPath ? ` (${adbPath})` : ''} is a Windows binary, which cannot use this host's file paths.`,
hint: "adb must be a native binary for this host: install this host's Android platform-tools, put them first on PATH, and point ANDROID_HOME at an SDK on this host's filesystem. Under WSL that means a Linux SDK, not one under /mnt/<drive>.",
evidence: {
reason: WINDOWS_ADB_ON_POSIX_HOST_REASON,
detectedVia,
adbPath: adbPath ?? null,
adbVersion: probe.versionLine,
androidHome: probe.sdkRoot ?? null,
},
};
}

function androidAdbCheck(probe: AndroidToolchainProbe): DoctorCheck {
return {
id: 'toolchain',
Expand Down
9 changes: 7 additions & 2 deletions packages/provision-kit/src/toolchain-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import { runCmd } from '@agent-device/host-kit/command';
export const TOOLCHAIN_TIMEOUT_MS = 3_000;

export async function commandFirstLine(cmd: string, args: string[]): Promise<string | undefined> {
const output = await commandOutput(cmd, args);
return output === undefined ? undefined : firstOutputLine(output);
}

/** Stdout of a toolchain probe that exited 0, or undefined when the tool is missing or failed. */
export async function commandOutput(cmd: string, args: string[]): Promise<string | undefined> {
try {
const result = await runCmd(cmd, args, { allowFailure: true, timeoutMs: TOOLCHAIN_TIMEOUT_MS });
if (result.exitCode !== 0) return undefined;
return firstOutputLine(result.stdout);
return result.exitCode === 0 ? result.stdout : undefined;
} catch {
return undefined;
}
Expand Down
2 changes: 1 addition & 1 deletion src/platform-runtime-host-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function createHostDiagnostics(): HostDiagnostics {
): Promise<DoctorCheck | undefined> => {
if (platform === 'android') {
const { androidToolchainCheck } = await loadAndroidMechanics();
return await androidToolchainCheck(process.env);
return await androidToolchainCheck(process.env, process.platform);
}
if (platform === 'vega') {
const { vegaToolchainCheck } = await import('@agent-device/platform-vega');
Expand Down
4 changes: 4 additions & 0 deletions website/docs/docs/known-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ Some OEM builds gate the first install of a package behind the system package in

An unattended first Android snapshot therefore times out with a helper install failure whose hint says to check the device screen for a pending install confirmation. Confirm the prompts on the device and retry; if no dialog is showing, restart the ADB server as the hint says.

## Android: WSL needs Linux platform-tools

Under WSL, the Windows `adb.exe` (for example from an `ANDROID_HOME` under `/mnt/c`) answers `adb version` but resolves every host path it is given as a Windows path, so recordings, pulls, and installs fail. Install Linux Android platform-tools inside WSL, put them first on `PATH`, and point `ANDROID_HOME` at a Linux SDK. `agent-device doctor` fails the toolchain check with reason `android_adb_windows_binary_on_posix_host` when `adb` reports a Windows install path or reports that it is running on Windows. The same check catches a Windows `adb.exe` reached from macOS or Linux through WSL interop or Wine.

## Android: no clipboard access over adb on Android 16

`agent-device` reaches the Android clipboard through `adb shell cmd clipboard`. That command works only on a build whose clipboard service implements a shell command, and AOSP's `ClipboardService` does not: the class carries no shell command at `android13-release`, `android14-release`, `android15-release` or `android16-release`, nor on current AOSP `main`, and a physical device runs that same class. On Android 16 (API 36) every `cmd clipboard get text` and `cmd clipboard set text <text>` call is answered by the framework's default `Binder.handleShellCommand` — `No shell command implementation.` on stderr, exit status **0** — so the clipboard is never touched even though the call reports success.
Expand Down
Loading