From 452066b8a8c64a2adebbd308cb8cc9bcafee4b79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 18:18:13 +0200 Subject: [PATCH 1/7] fix(settings): resolve a display-name --app for app-scoped settings `settings permission ... --app Maps` (and iOS `location on|off --app`) sent the name to `simctl privacy` / `pm grant` as a literal client id and reported success. App-scoped settings now resolve the named app through the same platform resolution `clear-app-state` uses: an id passes through, a display name resolves to the installed app's id, and an app that resolves to nothing refuses with APP_NOT_INSTALLED, details.reason app_not_installed, and dispatched: no. Closes #3384 Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/client-settings.ts | 10 ++- packages/contracts/src/settings.ts | 25 +++++- packages/kernel/src/errors.test.ts | 8 ++ packages/kernel/src/errors.ts | 16 ++++ .../src/__tests__/settings.test.ts | 79 +++++++++++++++++++ packages/platform-android/src/settings.ts | 46 ++++++++--- .../src/core/__tests__/app-settings.test.ts | 62 +++++++++++++++ .../core/__tests__/simctl-settings.test.ts | 47 +++++++++++ .../platform-apple/src/core/app-settings.ts | 9 ++- .../src/core/simctl-settings.ts | 12 ++- .../provider-limrun/src/ios-settings.test.ts | 6 +- packages/provider-limrun/src/ios-settings.ts | 4 +- src/commands/capture/settings.ts | 4 +- src/sdk/limrun-runtime-dependencies.test.ts | 1 + website/docs/docs/commands.md | 6 +- 15 files changed, 304 insertions(+), 31 deletions(-) diff --git a/packages/contracts/src/client-settings.ts b/packages/contracts/src/client-settings.ts index 1a6f9bd769..74f30cbafe 100644 --- a/packages/contracts/src/client-settings.ts +++ b/packages/contracts/src/client-settings.ts @@ -85,10 +85,12 @@ export type SettingsUpdateOptions = permission: PermissionTarget; mode?: PermissionMode; /** - * The app the permission changes, by bundle id or package name. Without it the app bound to - * the session is used; with it no app has to be running or open, because `simctl privacy` and - * Android's `pm` need only the id. macOS permissions are host-level TCC grants, so naming an - * app there is refused rather than dropped. + * The app the permission changes, by bundle id, package name, or the display name `open` + * accepts, which resolves to the installed app's id; one that resolves to nothing is refused + * with `app_not_installed`. Without it the app bound to the session is used; with it no app + * has to be running or open, because `simctl privacy` and Android's `pm` need only the id. + * macOS permissions are host-level TCC grants, so naming an app there is refused rather than + * dropped. */ app?: string; }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e85144a58e..3db87368e9 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,4 +1,4 @@ -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, appNotInstalledDetails, discloseDispatch } from '@agent-device/kernel/errors'; /** * The `settings permission` vocabulary, declared once. These collections are what the parsers @@ -172,6 +172,8 @@ export type SimctlSettingRequest = { setting: SimctlSetting; state: string; appBundleId?: string; + /** Resolves the app a privacy change names to the bundle id `simctl privacy` writes. */ + resolveApp: (app: string) => Promise; options?: SettingOptions; }; @@ -281,6 +283,27 @@ export function settingsAppNotConsumedRefusal( }; } +/** + * Resolves the app an app-scoped mutation lands on through the owner's own app resolution, ahead of + * anything that reaches the device. A failed resolution therefore dispatched nothing, and an app that + * resolves to no installed app answers `app_not_installed` rather than reaching the device as a + * literal id. + */ +export async function resolveSettingsApp( + app: string, + resolve: (app: string) => Promise, +): Promise { + try { + return await resolve(app); + } catch (error) { + if (!(error instanceof AppError)) throw error; + if (error.code === 'APP_NOT_INSTALLED') { + error.details = { ...error.details, ...appNotInstalledDetails(), app }; + } + throw discloseDispatch(error, 'no'); + } +} + const SETTINGS_WIFI_USAGE = ' '; const SETTINGS_LOCATION_SET_USAGE = 'location set '; const SETTINGS_ANIMATIONS_USAGE = 'animations '; diff --git a/packages/kernel/src/errors.test.ts b/packages/kernel/src/errors.test.ts index 0d47220b90..ce59b2950c 100644 --- a/packages/kernel/src/errors.test.ts +++ b/packages/kernel/src/errors.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError, + appNotInstalledDetails, defaultHintForCode, discloseDispatch, discloseDispatchAfterSteps, @@ -19,6 +20,7 @@ test('the pre-dispatch refusal reasons are the values consumers branch on', () = assert.deepEqual(PRE_DISPATCH_REFUSAL_REASONS, { sessionAppRequired: 'session_app_required', sessionOrDeviceSelectorRequired: 'session_or_device_selector_required', + appNotInstalled: 'app_not_installed', }); }); @@ -35,6 +37,12 @@ for (const [label, details, code, message] of [ 'INVALID_ARGS', 'clipboard requires an active session or an explicit device selector (e.g. --platform ios).', ], + [ + 'appNotInstalled', + appNotInstalledDetails(), + 'APP_NOT_INSTALLED', + 'No app found matching "Maps"', + ], ] as const) { test(`${label} refusal carries its reason and dispatched:no through normalize and the wire`, () => { const normalized = normalizeError(new AppError(code, message, details)); diff --git a/packages/kernel/src/errors.ts b/packages/kernel/src/errors.ts index cf231ef78a..3715a92e74 100644 --- a/packages/kernel/src/errors.ts +++ b/packages/kernel/src/errors.ts @@ -274,6 +274,12 @@ export const PRE_DISPATCH_REFUSAL_REASONS = { * find a device for it; the command it asks for stays in the message. */ sessionOrDeviceSelectorRequired: 'session_or_device_selector_required', + /** + * An app-scoped setting named an app that resolves to no installed app on the target, so nothing + * was granted, revoked, or cleared under that name. Recovery: run `apps` for the exact bundle id + * or package, or install the app, then retry. + */ + appNotInstalled: 'app_not_installed', } as const; export type PreDispatchRefusalReason = @@ -295,6 +301,16 @@ export function sessionAppRequiredDetails(): RefusalDetails< }; } +/** The details of an app-scoped setting refused because its named app resolves to no installed app. */ +export function appNotInstalledDetails(): RefusalDetails< + (typeof PRE_DISPATCH_REFUSAL_REASONS)['appNotInstalled'] +> { + return { + reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled, + dispatched: 'no', + }; +} + /** The details of a command refused for want of a target to route to. */ export function sessionOrDeviceSelectorRequiredDetails(): RefusalDetails< (typeof PRE_DISPATCH_REFUSAL_REASONS)['sessionOrDeviceSelectorRequired'] diff --git a/packages/platform-android/src/__tests__/settings.test.ts b/packages/platform-android/src/__tests__/settings.test.ts index 0a991bbd96..89aaeed187 100644 --- a/packages/platform-android/src/__tests__/settings.test.ts +++ b/packages/platform-android/src/__tests__/settings.test.ts @@ -77,6 +77,85 @@ test('setAndroidSetting clear-app-state refuses an appless session with the publ ); }); +const INSTALLED_PACKAGES = 'package:com.example.app\npackage:com.android.chrome\n'; + +function installedPackagesAdb(args: string[]) { + const flat = args.join(' '); + if (flat === 'shell pm list packages') return INSTALLED_PACKAGES; + if (flat === 'shell am get-current-user') return '0'; + if (flat.startsWith('shell dumpsys package ') || flat.startsWith('shell pm grant ')) return ''; + return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; +} + +test('setAndroidSetting permission grants the package a display name resolves to', async () => { + await withFakeAdb(installedPackagesAdb, async ({ calls, device }) => { + await setAndroidSetting(device, 'permission', 'grant', 'Chrome', { + permissionTarget: 'camera', + }); + assert.deepEqual(calls, [ + ['shell', 'pm', 'list', 'packages'], + ['shell', 'am', 'get-current-user'], + ['shell', 'dumpsys', 'package', 'com.android.chrome'], + ['shell', 'pm', 'grant', '--user', '0', 'com.android.chrome', 'android.permission.CAMERA'], + ]); + }); +}); + +test('setAndroidSetting permission passes a package through without listing packages', async () => { + await withFakeAdb(installedPackagesAdb, async ({ calls, device }) => { + await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + permissionTarget: 'camera', + }); + assert.deepEqual(calls.at(-1), [ + 'shell', + 'pm', + 'grant', + '--user', + '0', + 'com.example.app', + 'android.permission.CAMERA', + ]); + assert.ok(!calls.some((args) => args.join(' ') === 'shell pm list packages')); + }); +}); + +test.for(['permission', 'clear-app-state'] as const)( + 'setAndroidSetting %s refuses an app that resolves to nothing before changing anything', + async (setting) => { + await withFakeAdb(installedPackagesAdb, async ({ calls, device }) => { + await assertRejectsAppError( + () => + setAndroidSetting( + device, + setting, + setting === 'permission' ? 'grant' : 'clear', + 'Nowhere', + { permissionTarget: 'camera' }, + ), + { + code: 'APP_NOT_INSTALLED', + reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled, + dispatched: 'no', + }, + ); + assert.deepEqual(calls, [['shell', 'pm', 'list', 'packages']]); + }); + }, +); + +test('setAndroidSetting permission refuses an app alias that resolves to an intent', async () => { + await withFakeAdb(installedPackagesAdb, async ({ calls, device }) => { + await assertRejectsAppError( + () => + setAndroidSetting(device, 'permission', 'grant', 'settings', { + permissionTarget: 'camera', + }), + { code: 'INVALID_ARGS', dispatched: 'no' }, + ); + assert.deepEqual(calls, []); + }); +}); + test('setAndroidSetting fingerprint retries emulator command when shell cmd fingerprint fails', async () => { await withFakeAdb( (args) => { diff --git a/packages/platform-android/src/settings.ts b/packages/platform-android/src/settings.ts index ecbbcb0f2f..8f9007d552 100644 --- a/packages/platform-android/src/settings.ts +++ b/packages/platform-android/src/settings.ts @@ -11,6 +11,7 @@ import { parseAppearanceAction, parseSettingState, parseTextSizeCategory, + resolveSettingsApp, textSizeSettingPayload, TEXT_SIZE_CATEGORIES, type ReadableSetting, @@ -128,17 +129,11 @@ export async function setAndroidSetting( sessionAppRequiredDetails(), ); } - const resolved = await resolveAndroidApp(device, appPackage); - if (resolved.type === 'intent') { - throw new AppError( - 'INVALID_ARGS', - 'settings clear-app-state requires a package name, not an intent.', - ); - } - await runAndroidShell(device, ['am', 'force-stop', resolved.value], { + const packageName = await resolveAndroidSettingsPackage(device, normalized, appPackage); + await runAndroidShell(device, ['am', 'force-stop', packageName], { allowFailure: true, }); - const result = await runAndroidShell(device, ['pm', 'clear', resolved.value], { + const result = await runAndroidShell(device, ['pm', 'clear', packageName], { allowFailure: true, }); if (result.exitCode !== 0 || !/\bSuccess\b/i.test(result.stdout)) { @@ -146,16 +141,16 @@ export async function setAndroidSetting( // guard also covers that non-exit failure mode. throw new AppError( 'COMMAND_FAILED', - `Failed to clear Android app data for ${resolved.value}`, + `Failed to clear Android app data for ${packageName}`, { - package: resolved.value, + package: packageName, stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, }, ); } - return { package: resolved.value, cleared: true }; + return { package: packageName, cleared: true }; } case 'fingerprint': { const action = parseAndroidFingerprintAction(state); @@ -170,7 +165,12 @@ export async function setAndroidSetting( sessionAppRequiredDetails(), ); } - return await setAndroidPermission(device, appPackage, state, options); + return await setAndroidPermission( + device, + await resolveAndroidSettingsPackage(device, normalized, appPackage), + state, + options, + ); } case 'text-size': { return await setAndroidTextSize(device, state); @@ -180,6 +180,26 @@ export async function setAndroidSetting( } } +/** The package an app-scoped setting lands on: a package passes through, a display name resolves. */ +async function resolveAndroidSettingsPackage( + device: DeviceInfo, + setting: string, + app: string, +): Promise { + const resolved = await resolveSettingsApp(app, (target) => resolveAndroidApp(device, target)); + if (resolved.type === 'intent') { + throw new AppError( + 'INVALID_ARGS', + `settings ${setting} requires a package name, not an intent.`, + { + app, + dispatched: 'no', + }, + ); + } + return resolved.value; +} + /** * The ONE place an Android target answers `settings ` with the value it holds. The command * surface only sends a setting its own vocabulary declares readable, so a name outside this switch diff --git a/packages/platform-apple/src/core/__tests__/app-settings.test.ts b/packages/platform-apple/src/core/__tests__/app-settings.test.ts index f5e6c552ba..88487763c1 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -553,3 +553,65 @@ test('setIosSetting permission runs the simctl plan on the simulator udid and su }, ); }); + +const INSTALLED_APPS_JSON = JSON.stringify({ + 'com.apple.Maps': { CFBundleDisplayName: 'Maps' }, + 'com.example.app': { CFBundleDisplayName: 'Example' }, +}); + +function installedAppsTool(args: string[]): FakeAppleToolResponse { + if (args.join(' ') === 'simctl listapps sim-1') return INSTALLED_APPS_JSON; + if (args[0] === 'simctl' && args[1] === 'privacy') return ''; + return unexpectedArgs(args); +} + +test('setIosSetting permission grants the bundle id a display name resolves to', async () => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); + + await withFakeAppleTool(installedAppsTool, async ({ calls }) => { + await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'Maps', { + permissionTarget: 'photos', + }); + assert.deepEqual(calls, [ + ['simctl', 'listapps', 'sim-1'], + ['simctl', 'privacy', 'sim-1', 'grant', 'photos', 'com.apple.Maps'], + ]); + }); +}); + +test('setIosSetting location passes a bundle id through without listing apps', async () => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); + + await withFakeAppleTool(installedAppsTool, async ({ calls }) => { + await setIosSetting(IOS_TEST_SIMULATOR, 'location', 'on', 'com.example.app'); + assert.deepEqual(calls, [ + ['simctl', 'privacy', 'sim-1', 'grant', 'location', 'com.example.app'], + ]); + }); +}); + +test.for([ + { setting: 'permission', state: 'grant' }, + { setting: 'location', state: 'off' }, + { setting: 'clear-app-state', state: 'clear' }, +] as const)( + 'setIosSetting $setting refuses an app that resolves to nothing before changing anything', + async ({ setting, state }) => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); + + await withFakeAppleTool(installedAppsTool, async ({ calls }) => { + await assertRejectsAppError( + () => + setIosSetting(IOS_TEST_SIMULATOR, setting, state, 'Nowhere', { + permissionTarget: 'photos', + }), + { + code: 'APP_NOT_INSTALLED', + reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled, + dispatched: 'no', + }, + ); + assert.deepEqual(calls, [['simctl', 'listapps', 'sim-1']]); + }); + }, +); diff --git a/packages/platform-apple/src/core/__tests__/simctl-settings.test.ts b/packages/platform-apple/src/core/__tests__/simctl-settings.test.ts index e414f5af5b..bb129ad3da 100644 --- a/packages/platform-apple/src/core/__tests__/simctl-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/simctl-settings.test.ts @@ -22,6 +22,7 @@ function request( deviceId: 'SIM-1', setting: 'appearance', state: 'dark', + resolveApp: async (app) => app, ...overrides, }; } @@ -288,3 +289,49 @@ test('a permission mode on a target other than photos refuses before running sim }); expect(runSimctl).not.toHaveBeenCalled(); }); + +test.for([ + { setting: 'permission', state: 'grant', service: 'camera' }, + { setting: 'location', state: 'on', service: 'location' }, +] as const)( + '$setting $state writes the bundle id the runner resolves the named app to', + async ({ setting, state, service }) => { + const runSimctl = recordingRunner(); + const resolveApp = vi.fn(async (app: string) => (app === 'Maps' ? 'com.apple.Maps' : app)); + + await applySimctlSetting( + request(runSimctl, { + setting, + state, + appBundleId: 'Maps', + resolveApp, + options: { permissionTarget: 'camera' }, + }), + ); + + expect(resolveApp).toHaveBeenCalledWith('Maps'); + expect(argvs(runSimctl)).toEqual([['privacy', 'SIM-1', 'grant', service, 'com.apple.Maps']]); + }, +); + +test('an app the runner cannot resolve refuses before running simctl', async () => { + const runSimctl = recordingRunner(); + const refusal = new AppError('APP_NOT_INSTALLED', 'No app found matching "Nope"'); + + const error = await appErrorFrom( + applySimctlSetting( + request(runSimctl, { + setting: 'permission', + state: 'grant', + appBundleId: 'Nope', + resolveApp: async () => { + throw refusal; + }, + options: { permissionTarget: 'photos' }, + }), + ), + ); + + expect(error).toBe(refusal); + expect(runSimctl).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index 57aadeb7b9..84638a4796 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -3,6 +3,7 @@ import { getUnsupportedMacOsSettingMessage, parsePermissionAction, parseSettingState, + resolveSettingsApp, type ReadableSetting, type ReadSettingResult, type SettingOptions, @@ -150,6 +151,7 @@ export async function setIosSetting( setting: normalized, state, appBundleId, + resolveApp: (app) => resolveIosSettingsApp(device, app), options, }); default: @@ -211,6 +213,11 @@ function iosNoDataContainerMessage(bundleId: string): string { return `${bundleId} has no data container to clear. Apps shipped in the simulator runtime, such as system apps, own no data container, so there is no app state here to remove.`; } +/** The bundle id an app-scoped setting lands on: an id passes through, a display name resolves. */ +async function resolveIosSettingsApp(device: DeviceInfo, app: string): Promise { + return await resolveSettingsApp(app, (target) => resolveIosApp(device, target)); +} + async function clearIosSimulatorAppState( device: DeviceInfo, app: string, @@ -222,7 +229,7 @@ async function clearIosSimulatorAppState( ); } - const bundleId = await resolveIosApp(device, app); + const bundleId = await resolveIosSettingsApp(device, app); await ensureBootedSimulator(device); await closeIosApp(device, bundleId); diff --git a/packages/platform-apple/src/core/simctl-settings.ts b/packages/platform-apple/src/core/simctl-settings.ts index 98f358a37a..548a8db986 100644 --- a/packages/platform-apple/src/core/simctl-settings.ts +++ b/packages/platform-apple/src/core/simctl-settings.ts @@ -48,10 +48,10 @@ async function setAppearance({ runSimctl, udid, state }: SimctlSettingRequest): async function setPermission(request: SimctlSettingRequest): Promise { const { runSimctl, udid, deviceId, state, options } = request; - const appBundleId = requireAppBundleId(request); const permissionAction = parsePermissionAction(state); const action = permissionAction === 'deny' ? 'revoke' : permissionAction; const target = parseIosPrivacyService(options?.permissionTarget, options?.permissionMode); + const appBundleId = await requireAppBundleId(request); try { await runSimctl(['privacy', udid, action, target, appBundleId]); } catch (error) { @@ -68,11 +68,15 @@ async function setLocation(request: SimctlSettingRequest): Promise { + if (appBundleId) return await resolveApp(appBundleId); throw new AppError( 'INVALID_ARGS', `${setting} setting requires an active app in session`, diff --git a/packages/provider-limrun/src/ios-settings.test.ts b/packages/provider-limrun/src/ios-settings.test.ts index 86515db4c7..b9db8d73f8 100644 --- a/packages/provider-limrun/src/ios-settings.test.ts +++ b/packages/provider-limrun/src/ios-settings.test.ts @@ -41,9 +41,13 @@ test('simctl settings run the Apple plan on the booted simulator, reporting the deviceId: 'limrun:ios:settings', setting: 'permission', state: 'grant', - appBundleId: 'resolved.camera-app', + appBundleId: 'camera-app', + resolveApp: expect.any(Function), options, }); + await expect(applySimctlSetting.mock.calls[0]?.[0].resolveApp('camera-app')).resolves.toBe( + 'resolved.camera-app', + ); expect(client.simctl.mock.calls).toEqual([[['ui', 'booted', 'appearance']]]); expect(result).toEqual({ output: { code: 0, stdout: 'dark\n', stderr: '' } }); }); diff --git a/packages/provider-limrun/src/ios-settings.ts b/packages/provider-limrun/src/ios-settings.ts index 7f72d1642f..049fc6385e 100644 --- a/packages/provider-limrun/src/ios-settings.ts +++ b/packages/provider-limrun/src/ios-settings.ts @@ -25,8 +25,8 @@ export async function setLimrunIosSetting( deviceId: session.device.id, setting: normalized, state, - appBundleId: - appId === undefined ? undefined : await session.dependencies.ios.resolveAppAlias(appId), + appBundleId: appId, + resolveApp: (app) => session.dependencies.ios.resolveAppAlias(app), options, }); case 'clear-app-state': diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 2973390163..9b63446c97 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -43,7 +43,7 @@ const settingsCommandMetadata = defineFieldCommandMetadata( // and the daemon enforce per setting rather than the shared field map. state: stringField(), app: stringField( - 'App the change targets, by bundle id or package name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a change that is device-wide refuses one with `setting_app_not_consumed` (`location set` on any target, the Android `location on|off` toggle, macOS permissions). The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.', + "App the change targets, by bundle id or package name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. A display name resolves to the installed app's id as it does for `open`; an app that resolves to nothing is refused with `app_not_installed` before anything changes. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a change that is device-wide refuses one with `setting_app_not_consumed` (`location set` on any target, the Android `location on|off` toggle, macOS permissions). The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.", ), latitude: numberField(), longitude: numberField(), @@ -105,7 +105,7 @@ export const settingsCommandFacet = defineCommandFacet({ name: SETTINGS_COMMAND_NAME, text: { summary: 'Change OS settings and app permissions', - cliDetail: `macOS supports only settings appearance and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. clear-app-state takes its app positionally or with --app, and refuses two different apps. An installed app with no data container (a simulator-runtime system app such as com.apple.Preferences) refuses with app-no-data-container instead of scanning a path simctl never gave. A device-wide change refuses an app with setting_app_not_consumed: location set moves the device's own coordinates on every target, the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`, + cliDetail: `macOS supports only settings appearance and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; a display name resolves to the installed app's id as it does for open, and an app that resolves to nothing refuses with app_not_installed before anything changes; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. clear-app-state takes its app positionally or with --app, and refuses two different apps. An installed app with no data container (a simulator-runtime system app such as com.apple.Preferences) refuses with app-no-data-container instead of scanning a path simctl never gave. A device-wide change refuses an app with setting_app_not_consumed: location set moves the device's own coordinates on every target, the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`, }, metadata: settingsCommandMetadata, run: (client, input) => client.settings.update(input as SettingsUpdateOptions), diff --git a/src/sdk/limrun-runtime-dependencies.test.ts b/src/sdk/limrun-runtime-dependencies.test.ts index 56740b7ba2..27df5b34eb 100644 --- a/src/sdk/limrun-runtime-dependencies.test.ts +++ b/src/sdk/limrun-runtime-dependencies.test.ts @@ -258,6 +258,7 @@ test('Limrun iOS settings evaluate the Apple simctl plan module on the first set setting: 'permission', state: 'grant', appBundleId: 'com.example.app', + resolveApp: async (app: string) => app, options: { permissionTarget: 'photos', permissionMode: 'limited' }, } as const; diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index bd478f9a4b..cbb5b224c9 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -534,7 +534,7 @@ A read-only command such as `get`, `snapshot`, or `wait` reports `no`: a retry r On Android, a command that first dismissed a blocking system dialog or an ANR prompt still reports as if that dismissal had not happened: a read stays `no`, and a mutation keeps its own verdict. The dismissal is not counted as a dispatched step. Once any step of a request reached the device, its failure is `unknown`, and `error.details.dispatchedSteps` counts those steps. A `batch` or a replay reports `no` only when none of its executed steps changed the app. A failure without `dispatched` gives no such guarantee. Treat it as `unknown`. -These refusals carry a typed reason alongside `dispatched: no`, so a driver can branch instead of matching the message: `error.details.reason: session_or_device_selector_required` means the daemon refused before routing anywhere because the request had neither an active session nor a device selector to resolve, so opening a session or passing `--platform` is the fix; `session_app_required` means a per-app setting was asked for while the request carried no app — the session binds none and none was named for the request — so `open ` is the fix, or for a setting that takes an app id (like `settings clear-app-state com.example.app`), name it on the request without opening anything. +These refusals carry a typed reason alongside `dispatched: no`, so a driver can branch instead of matching the message: `error.details.reason: session_or_device_selector_required` means the daemon refused before routing anywhere because the request had neither an active session nor a device selector to resolve, so opening a session or passing `--platform` is the fix; `session_app_required` means a per-app setting was asked for while the request carried no app — the session binds none and none was named for the request — so `open ` is the fix, or for a setting that takes an app id (like `settings clear-app-state com.example.app`), name it on the request without opening anything; `app_not_installed` means a per-app setting named an app that resolves to no installed app on the target, so run `apps` for the exact bundle id or package, or install the app, then retry. On a WebDriver-backed device cloud, a request whose driver does not implement its route fails with `error.details.reason: webdriver_route_unsupported` and `dispatched: no`; the driver answered before it ran anything, so the failure is about the cloud's driver, not the device. A command with a sibling route (`orientation` tries two) consumes that refusal and tries the other; only when every route is refused does the command fail. After an ambiguous failure on that transport, only a request that reads and changes nothing is resent; a request that can change the app is sent once. ## Find (semantic) @@ -744,13 +744,13 @@ agent-device settings permission reset screen-recording --platform macos - Android has no category ladder, only a `font_scale` multiplier, so the two are mapped: `large` is `1.0` up through `accessibility-extra-extra-extra-large` at `3.2`. A device holding a multiplier off that ladder reads back as the nearest rung with the exact multiplier as `platformValue`, and an unset `font_scale` reads as `large`/`1.0`. - A text size change is a configuration change: an app already running adopts it on its next configuration change, so relaunch the app under test (`open --relaunch`) when asserting rendered sizes. - `settings location set ` sets precise coordinates on iOS simulators and Android emulators. -- `settings clear-app-state [app-id]` clears the active session app data, or the provided app id. The app id can also be named with `--app` (`settings clear-app-state --app com.example.app`); naming two different apps, one positionally and one with `--app`, is refused with `INVALID_ARGS`. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents and leaves the fresh-install directory layout (empty `Documents`, `Library/Caches`, `Library/Preferences`, `SystemData`, and `tmp`, as on iOS 26.5; other runtimes may differ). With no app bound to the session and no app id named, it refuses with `error.details.reason: session_app_required` and `dispatched: no`. An installed app that owns no data container — the apps shipped inside the simulator runtime, such as `com.apple.Preferences` — refuses with `UNSUPPORTED_OPERATION` and `error.details.reason: app-no-data-container`: `simctl` answers its container lookup with "no container", so there is no app state to remove. Runtime-shipped apps cannot be uninstalled, so resetting one means erasing or recreating the simulator device; an app you installed yourself can be uninstalled and reinstalled. iOS physical devices and macOS are unsupported. It does not touch the keychain, so keychain-backed credentials (e.g. Firebase auth) survive it. +- `settings clear-app-state [app-id]` clears the active session app data, or the provided app id. The app id can also be named with `--app` (`settings clear-app-state --app com.example.app`); naming two different apps, one positionally and one with `--app`, is refused with `INVALID_ARGS`. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents and leaves the fresh-install directory layout (empty `Documents`, `Library/Caches`, `Library/Preferences`, `SystemData`, and `tmp`, as on iOS 26.5; other runtimes may differ). With no app bound to the session and no app id named, it refuses with `error.details.reason: session_app_required` and `dispatched: no`. A display name resolves to the installed app's id; one that matches no installed app refuses with `APP_NOT_INSTALLED`, `error.details.reason: app_not_installed`, and `dispatched: no`. An installed app that owns no data container — the apps shipped inside the simulator runtime, such as `com.apple.Preferences` — refuses with `UNSUPPORTED_OPERATION` and `error.details.reason: app-no-data-container`: `simctl` answers its container lookup with "no container", so there is no app state to remove. Runtime-shipped apps cannot be uninstalled, so resetting one means erasing or recreating the simulator device; an app you installed yourself can be uninstalled and reinstalled. iOS physical devices and macOS are unsupported. It does not touch the keychain, so keychain-backed credentials (e.g. Firebase auth) survive it. - `settings reset-keychain clear` resets the iOS simulator's keychain (`xcrun simctl keychain reset`), removing keychain-backed credentials such as Firebase auth tokens that `clear-app-state` leaves behind. simctl has no per-app keychain reset, so this clears the keychain for every app installed on that simulator, not only the app under test — treat it as a whole-simulator, opt-in operation and pair it with `clear-app-state` for a full fresh-install reset. iOS physical devices, Android, and macOS are unsupported. - Face ID and Touch ID controls are iPhone and iPad simulator-only; Apple TV and Vision Pro simulators refuse them. They post the same Darwin notifications the Simulator.app Features menu posts (`xcrun simctl spawn notifyutil`): `com.apple.BiometricKit_Sim..` for match/nonmatch, and `com.apple.BiometricKit.enrollmentChanged` (set to `1`/`0`, then posted, then read back) for enroll/unenroll. No `simctl biometric` subcommand exists, so this route works on any Xcode. - Android `settings airplane on|off` is applied by the connectivity service (`cmd connectivity airplane-mode`, Android 11+), which drives the radios rather than only writing the `airplane_mode_on` setting. The response reports the `airplaneMode` that service holds after the change, and Android builds without that command fail without changing device state. Connectivity takes a moment to settle after the switch, so poll the app under test rather than asserting offline behavior immediately. - Fingerprint simulation is supported on Android targets where `cmd fingerprint` or `adb emu finger` is available. On physical Android devices, only `cmd fingerprint` is attempted. -- On iOS and Android, permission actions default to the active session app. Pass `--app ` (or the client `app` option) to aim a `permission` change — grant, deny, or reset — or an iOS simulator `location on|off` privacy change, at an installed app without opening it or binding a session — for example, grant a permission before the app's first launch. The app must be installed on the target; no app has to be running. The CLI reads `--app` for `settings` only when you pass it on the command line: an `AGENT_DEVICE_TARGET_APP` environment variable or a `targetApp` in `~/.agent-device/config.json` (both useful for `doctor`) never retargets a settings mutation, which falls back to the session app. With no app bound to the session and none named, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry, or name the app with `--app`. On iOS, an on/off `settings location` behaves the same. A change that is device-wide refuses a named app instead of dropping it, with `error.details.reason: setting_app_not_consumed`: `location set` moves the device's own coordinates on every target, the Android `location on|off` toggle writes the device's `location_mode`, and a macOS permission is a host-level TCC grant that needs no session app. +- On iOS and Android, permission actions default to the active session app. Pass `--app ` (or the client `app` option) to aim a `permission` change — grant, deny, or reset — or an iOS simulator `location on|off` privacy change, at an installed app without opening it or binding a session — for example, grant a permission before the app's first launch. The app must be installed on the target; no app has to be running. `--app` also accepts the display name `open` accepts, such as `--app Maps`, and resolves it to the installed app's bundle id or package, as `clear-app-state` does. A name that matches no installed app refuses with `APP_NOT_INSTALLED`, `error.details.reason: app_not_installed`, and `dispatched: no`, and nothing is granted or revoked. The CLI reads `--app` for `settings` only when you pass it on the command line: an `AGENT_DEVICE_TARGET_APP` environment variable or a `targetApp` in `~/.agent-device/config.json` (both useful for `doctor`) never retargets a settings mutation, which falls back to the session app. With no app bound to the session and none named, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry, or name the app with `--app`. On iOS, an on/off `settings location` behaves the same. A change that is device-wide refuses a named app instead of dropping it, with `error.details.reason: setting_app_not_consumed`: `location set` moves the device's own coordinates on every target, the Android `location on|off` toggle writes the device's `location_mode`, and a macOS permission is a host-level TCC grant that needs no session app. - iOS permission targets: `all`, `camera`, `microphone`, `photos` (`full` or `limited`), `contacts`, `contacts-limited`, `notifications`, `calendar`, `location`, `location-always`, `media-library`, `motion`, `reminders`, `siri`. `all` travels as one `simctl privacy … all` call. - On iOS, which of those services a runtime actually changes is `simctl privacy`'s own verdict, not its help text: Xcode 26 omits `camera` from the list while granting it. A service the runtime refuses fails with `UNSUPPORTED_OPERATION` naming the service; on current runtimes that is `notifications`, which a targeted change cannot reach and `all` leaves untouched. - Android permission targets: `all`, `calendar`, `camera`, `contacts`, `location`, `media-library`, `microphone`, `notifications`, `photos`. `contacts` fans out to `READ_CONTACTS`+`WRITE_CONTACTS`, `location` to `FINE`+`COARSE`, `calendar` to `READ`+`WRITE`; named multi-id targets intersect the package's declared permissions so a coarse-only or read-only app still succeeds, while a target declaring none of its ids fails loudly. `all` resolves against the package's declared permissions instead. Every response reports `permission` as the requested target and `permissions: string[]` as the ids actually mutated, in the order applied (`all` folds its applied ids into `permissions` the same way). From 1f7c48259804bb95e2c811b223ccf3b181ed2afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 18:36:30 +0200 Subject: [PATCH 2/7] fix(settings): verify installed apps and type every settings app refusal Address review on #3386: - iOS settings resolve the app with resolveIosApp's new installedOnly mode: a dotted name is matched against installed bundle ids, then display names (Booking.com resolves), and an uninstalled id is refused with app_not_installed instead of written to TCC. open/close keep the default pass-through. - app_not_installed carries a settings hint pointing at `apps --all`. - Android refuses an intent alias with reason setting_app_not_package, and validates the permission request before resolving the app. - Help, client docs and commands.md scope resolution per target, including Limrun iOS alias-only resolution. Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/client-settings.ts | 7 +- packages/contracts/src/settings.ts | 16 ++++- .../src/__tests__/settings.test.ts | 21 +++++- .../src/settings-permission.ts | 22 ++++-- packages/platform-android/src/settings.ts | 11 +-- .../src/core/__tests__/app-resolution.test.ts | 24 +++++++ .../src/core/__tests__/app-settings.test.ts | 71 ++++++++++++++----- .../platform-apple/src/core/app-resolution.ts | 21 +++++- .../platform-apple/src/core/app-settings.ts | 6 +- src/commands/capture/settings.ts | 4 +- .../ios-alert-settings.test.ts | 4 ++ website/docs/docs/commands.md | 6 +- 12 files changed, 173 insertions(+), 40 deletions(-) diff --git a/packages/contracts/src/client-settings.ts b/packages/contracts/src/client-settings.ts index 74f30cbafe..b21701ac63 100644 --- a/packages/contracts/src/client-settings.ts +++ b/packages/contracts/src/client-settings.ts @@ -85,9 +85,10 @@ export type SettingsUpdateOptions = permission: PermissionTarget; mode?: PermissionMode; /** - * The app the permission changes, by bundle id, package name, or the display name `open` - * accepts, which resolves to the installed app's id; one that resolves to nothing is refused - * with `app_not_installed`. Without it the app bound to the session is used; with it no app + * The app the permission changes, by bundle id, package name, or app name, resolved as `open` + * resolves it: an iOS simulator matches display names and accepts only an installed bundle + * id, Android matches installed package names, and a Limrun iOS session resolves only the + * `settings` alias. One that resolves to nothing is refused with `app_not_installed`. Without it the app bound to the session is used; with it no app * has to be running or open, because `simctl privacy` and Android's `pm` need only the id. * macOS permissions are host-level TCC grants, so naming an app there is refused rather than * dropped. diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3db87368e9..072f1609cd 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -283,6 +283,15 @@ export function settingsAppNotConsumedRefusal( }; } +const SETTINGS_APP_NOT_INSTALLED_HINT = + 'Run apps --all for the exact bundle id or package name, or install the app, then retry with that id in --app.'; + +/** + * The reason an Android app-scoped mutation refuses an app that resolves to an intent rather than a + * package: a permission or app-data change needs a package, so the recovery is naming one. + */ +export const SETTINGS_APP_NOT_PACKAGE_REASON = 'setting_app_not_package'; + /** * Resolves the app an app-scoped mutation lands on through the owner's own app resolution, ahead of * anything that reaches the device. A failed resolution therefore dispatched nothing, and an app that @@ -298,7 +307,12 @@ export async function resolveSettingsApp( } catch (error) { if (!(error instanceof AppError)) throw error; if (error.code === 'APP_NOT_INSTALLED') { - error.details = { ...error.details, ...appNotInstalledDetails(), app }; + error.details = { + ...error.details, + ...appNotInstalledDetails(), + app, + hint: SETTINGS_APP_NOT_INSTALLED_HINT, + }; } throw discloseDispatch(error, 'no'); } diff --git a/packages/platform-android/src/__tests__/settings.test.ts b/packages/platform-android/src/__tests__/settings.test.ts index 89aaeed187..c3304b35e7 100644 --- a/packages/platform-android/src/__tests__/settings.test.ts +++ b/packages/platform-android/src/__tests__/settings.test.ts @@ -1,6 +1,9 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { TEXT_SIZE_CATEGORIES } from '@agent-device/contracts/settings'; +import { + SETTINGS_APP_NOT_PACKAGE_REASON, + TEXT_SIZE_CATEGORIES, +} from '@agent-device/contracts/settings'; import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; import { readAndroidSetting, setAndroidSetting } from '../settings.ts'; import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts'; @@ -136,6 +139,7 @@ test.for(['permission', 'clear-app-state'] as const)( code: 'APP_NOT_INSTALLED', reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled, dispatched: 'no', + hint: /apps --all/, }, ); assert.deepEqual(calls, [['shell', 'pm', 'list', 'packages']]); @@ -150,7 +154,20 @@ test('setAndroidSetting permission refuses an app alias that resolves to an inte setAndroidSetting(device, 'permission', 'grant', 'settings', { permissionTarget: 'camera', }), - { code: 'INVALID_ARGS', dispatched: 'no' }, + { code: 'INVALID_ARGS', reason: SETTINGS_APP_NOT_PACKAGE_REASON, dispatched: 'no' }, + ); + assert.deepEqual(calls, []); + }); +}); + +test('setAndroidSetting permission validates the request before resolving the app', async () => { + await withFakeAdb(installedPackagesAdb, async ({ calls, device }) => { + await assertRejectsAppError( + () => + setAndroidSetting(device, 'permission', 'grant', 'Nowhere', { + permissionTarget: 'siri', + }), + { code: 'INVALID_ARGS' }, ); assert.deepEqual(calls, []); }); diff --git a/packages/platform-android/src/settings-permission.ts b/packages/platform-android/src/settings-permission.ts index 0bc8120917..5b5f448c78 100644 --- a/packages/platform-android/src/settings-permission.ts +++ b/packages/platform-android/src/settings-permission.ts @@ -4,6 +4,7 @@ import { type MobilePermissionTarget, parsePermissionAction, parsePermissionTarget, + type PermissionAction, type SettingOptions, } from '@agent-device/contracts/settings'; import { runAndroidShell } from './adb.ts'; @@ -113,14 +114,27 @@ async function requireAndroidPermissionUser(device: DeviceInfo): Promise ); } +type AndroidPermissionRequest = Readonly<{ + action: PermissionAction; + target: AndroidPermissionTarget; +}>; + +/** Validates a permission change before anything about the app or the device is read. */ +export function parseAndroidPermissionRequest( + state: string, + options: SettingOptions | undefined, +): AndroidPermissionRequest { + return { + action: parsePermissionAction(state), + target: parseAndroidPermissionTarget(options?.permissionTarget, options?.permissionMode), + }; +} + export async function setAndroidPermission( device: DeviceInfo, appPackage: string, - state: string, - options: SettingOptions | undefined, + { action, target }: AndroidPermissionRequest, ): Promise | void> { - const action = parsePermissionAction(state); - const target = parseAndroidPermissionTarget(options?.permissionTarget, options?.permissionMode); const userId = await requireAndroidPermissionUser(device); const userArgs: AndroidUserArgs = ['--user', String(userId)]; if (target.kind === 'all') { diff --git a/packages/platform-android/src/settings.ts b/packages/platform-android/src/settings.ts index 8f9007d552..8747ff13e5 100644 --- a/packages/platform-android/src/settings.ts +++ b/packages/platform-android/src/settings.ts @@ -12,6 +12,7 @@ import { parseSettingState, parseTextSizeCategory, resolveSettingsApp, + SETTINGS_APP_NOT_PACKAGE_REASON, textSizeSettingPayload, TEXT_SIZE_CATEGORIES, type ReadableSetting, @@ -23,7 +24,7 @@ import { runAndroidAdb, runAndroidShell } from './adb.ts'; import { setAndroidAirplaneMode } from './settings-airplane.ts'; import { androidAdbResultError } from './adb-executor.ts'; import { resolveAndroidApp } from './app-deployment-resolution.ts'; -import { setAndroidPermission } from './settings-permission.ts'; +import { parseAndroidPermissionRequest, setAndroidPermission } from './settings-permission.ts'; const ANDROID_ANIMATION_SCALE_SETTINGS = [ 'window_animation_scale', @@ -165,11 +166,11 @@ export async function setAndroidSetting( sessionAppRequiredDetails(), ); } + const request = parseAndroidPermissionRequest(state, options); return await setAndroidPermission( device, await resolveAndroidSettingsPackage(device, normalized, appPackage), - state, - options, + request, ); } case 'text-size': { @@ -192,8 +193,10 @@ async function resolveAndroidSettingsPackage( 'INVALID_ARGS', `settings ${setting} requires a package name, not an intent.`, { - app, + reason: SETTINGS_APP_NOT_PACKAGE_REASON, dispatched: 'no', + app, + hint: 'Run apps --all for the exact package name, then retry with it in --app.', }, ); } diff --git a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts index 8567df0f36..c19251d424 100644 --- a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts @@ -11,8 +11,10 @@ vi.mock('../simctl.ts', async (importOriginal) => ({ import { detectSoleRunningIosSimulatorApp, findIosSimulatorInstalledApp, + resolveIosApp, } from '../app-resolution.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; const bootedSimulator: DeviceInfo = { platform: 'apple', @@ -28,10 +30,32 @@ beforeEach(() => { stdout: JSON.stringify({ 'com.example.demo': { CFBundleDisplayName: 'Demo' }, 'com.apple.Preferences': { CFBundleDisplayName: 'Settings' }, + 'com.booking.app': { CFBundleDisplayName: 'Booking.com' }, }), }); }); +test('resolveIosApp passes a dotted target through without listing apps by default', async () => { + assert.equal(await resolveIosApp(bootedSimulator, 'Booking.com'), 'Booking.com'); + assert.equal(mockRunSimctl.mock.calls.length, 0); +}); + +test('resolveIosApp installedOnly matches installed bundle ids, then display names, else refuses', async () => { + const installedOnly = { installedOnly: true } as const; + assert.equal( + await resolveIosApp(bootedSimulator, 'com.example.demo', installedOnly), + 'com.example.demo', + ); + assert.equal( + await resolveIosApp(bootedSimulator, 'Booking.com', installedOnly), + 'com.booking.app', + ); + await assert.rejects( + () => resolveIosApp(bootedSimulator, 'com.example.missing', installedOnly), + (error: unknown) => error instanceof AppError && error.code === 'APP_NOT_INSTALLED', + ); +}); + test('findIosSimulatorInstalledApp verifies exact bundle ids and app-name aliases', async () => { assert.equal( await findIosSimulatorInstalledApp(bootedSimulator, 'com.example.demo'), diff --git a/packages/platform-apple/src/core/__tests__/app-settings.test.ts b/packages/platform-apple/src/core/__tests__/app-settings.test.ts index 88487763c1..dbd7d7f803 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -61,6 +61,18 @@ const BOOTED_SIM_LIST_JSON = JSON.stringify({ devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [{ udid: 'sim-1', state: 'Booted' }] }, }); +const INSTALLED_APPS_JSON = JSON.stringify({ + 'com.apple.Maps': { CFBundleDisplayName: 'Maps' }, + 'com.apple.Preferences': { CFBundleDisplayName: 'Settings' }, + 'com.booking.app': { CFBundleDisplayName: 'Booking.com' }, + 'com.example.app': { CFBundleDisplayName: 'Example' }, + 'com.example.located': { CFBundleDisplayName: 'Located' }, +}); + +function isSimctlListApps(args: string[]): boolean { + return args.join(' ') === 'simctl listapps sim-1'; +} + function isSimctlListDevices(args: string[]): boolean { return ( args[0] === 'simctl' && args.includes('list') && args.includes('devices') && args.includes('-j') @@ -367,6 +379,7 @@ test('setIosSetting clear-app-state refuses a system app whose simctl answer is await withFakeAppleTool( (args) => { if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (isSimctlListApps(args)) return INSTALLED_APPS_JSON; if (args.join(' ') === 'simctl terminate sim-1 com.apple.Preferences') return ''; if (args.join(' ') === 'simctl get_app_container sim-1 com.apple.Preferences data') { return '(null)\n'; @@ -413,6 +426,7 @@ test('setIosSetting clear-app-state refuses an empty container answer with the s await withFakeAppleTool( (args) => { if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (isSimctlListApps(args)) return INSTALLED_APPS_JSON; if (args.join(' ') === 'simctl terminate sim-1 com.apple.Preferences') return ''; if (args.join(' ') === 'simctl get_app_container sim-1 com.apple.Preferences data') { return ''; @@ -453,6 +467,7 @@ test('setIosSetting clear-app-state leaves a fresh-install data container layout await withFakeAppleTool( (args) => { if (isSimctlListDevices(args)) return BOOTED_SIM_LIST_JSON; + if (isSimctlListApps(args)) return INSTALLED_APPS_JSON; if (args.join(' ') === 'simctl terminate sim-1 com.example.app') return ''; if (args.join(' ') === 'simctl get_app_container sim-1 com.example.app data') { return `${containerPath}\n`; @@ -531,6 +546,7 @@ test('setIosSetting permission runs the simctl plan on the simulator udid and su await withFakeAppleTool( (args) => { + if (isSimctlListApps(args)) return INSTALLED_APPS_JSON; if (args.join(' ') === 'simctl privacy sim-1 revoke notifications com.example.app') { return { stderr: 'Failed to revoke access\nOperation not permitted', exitCode: 1 }; } @@ -547,20 +563,20 @@ test('setIosSetting permission runs the simctl plan on the simulator udid and su message: /does not support setting notifications permission/i, }, ); - assert.deepEqual(calls, [ - ['simctl', 'privacy', 'sim-1', 'revoke', 'notifications', 'com.example.app'], + assert.deepEqual(calls.at(-1), [ + 'simctl', + 'privacy', + 'sim-1', + 'revoke', + 'notifications', + 'com.example.app', ]); }, ); }); -const INSTALLED_APPS_JSON = JSON.stringify({ - 'com.apple.Maps': { CFBundleDisplayName: 'Maps' }, - 'com.example.app': { CFBundleDisplayName: 'Example' }, -}); - function installedAppsTool(args: string[]): FakeAppleToolResponse { - if (args.join(' ') === 'simctl listapps sim-1') return INSTALLED_APPS_JSON; + if (isSimctlListApps(args)) return INSTALLED_APPS_JSON; if (args[0] === 'simctl' && args[1] === 'privacy') return ''; return unexpectedArgs(args); } @@ -579,36 +595,57 @@ test('setIosSetting permission grants the bundle id a display name resolves to', }); }); -test('setIosSetting location passes a bundle id through without listing apps', async () => { +test('setIosSetting location grants an installed bundle id as named', async () => { mockEnsureBootedSimulator.mockResolvedValue(undefined); await withFakeAppleTool(installedAppsTool, async ({ calls }) => { - await setIosSetting(IOS_TEST_SIMULATOR, 'location', 'on', 'com.example.app'); + await setIosSetting(IOS_TEST_SIMULATOR, 'location', 'on', 'com.example.located'); assert.deepEqual(calls, [ - ['simctl', 'privacy', 'sim-1', 'grant', 'location', 'com.example.app'], + ['simctl', 'listapps', 'sim-1'], + ['simctl', 'privacy', 'sim-1', 'grant', 'location', 'com.example.located'], + ]); + }); +}); + +test('setIosSetting permission resolves a dotted display name instead of passing it through', async () => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); + + await withFakeAppleTool(installedAppsTool, async ({ calls }) => { + await setIosSetting(IOS_TEST_SIMULATOR, 'permission', 'grant', 'Booking.com', { + permissionTarget: 'camera', + }); + assert.deepEqual(calls.at(-1), [ + 'simctl', + 'privacy', + 'sim-1', + 'grant', + 'camera', + 'com.booking.app', ]); }); }); test.for([ - { setting: 'permission', state: 'grant' }, - { setting: 'location', state: 'off' }, - { setting: 'clear-app-state', state: 'clear' }, + { setting: 'permission', state: 'grant', app: 'Nowhere' }, + { setting: 'location', state: 'off', app: 'Nowhere' }, + { setting: 'clear-app-state', state: 'clear', app: 'Nowhere' }, + { setting: 'permission', state: 'grant', app: 'com.example.uninstalled' }, ] as const)( - 'setIosSetting $setting refuses an app that resolves to nothing before changing anything', - async ({ setting, state }) => { + 'setIosSetting $setting refuses $app, which resolves to no installed app, before changing anything', + async ({ setting, state, app }) => { mockEnsureBootedSimulator.mockResolvedValue(undefined); await withFakeAppleTool(installedAppsTool, async ({ calls }) => { await assertRejectsAppError( () => - setIosSetting(IOS_TEST_SIMULATOR, setting, state, 'Nowhere', { + setIosSetting(IOS_TEST_SIMULATOR, setting, state, app, { permissionTarget: 'photos', }), { code: 'APP_NOT_INSTALLED', reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled, dispatched: 'no', + hint: /apps --all/, }, ); assert.deepEqual(calls, [['simctl', 'listapps', 'sim-1']]); diff --git a/packages/platform-apple/src/core/app-resolution.ts b/packages/platform-apple/src/core/app-resolution.ts index ba0c7b3c83..d25948144c 100644 --- a/packages/platform-apple/src/core/app-resolution.ts +++ b/packages/platform-apple/src/core/app-resolution.ts @@ -37,12 +37,26 @@ export async function invalidateIosAppResolutionCache( return await iosAppResolutionCache.invalidateWhile(iosAppResolutionScope(device), fn); } -export async function resolveIosApp(device: DeviceInfo, app: string): Promise { +type IosAppResolutionOptions = Readonly<{ + /** + * Answer only with an app installed on the device. A dotted target is then matched against the + * installed bundle ids before display names, so `Booking.com` resolves instead of passing through, + * and a target nothing installed matches is refused rather than returned as a literal id. + */ + installedOnly?: boolean; +}>; + +export async function resolveIosApp( + device: DeviceInfo, + app: string, + options: IosAppResolutionOptions = {}, +): Promise { if (isMacOs(device)) { return await resolveMacOsApp(app); } const trimmed = app.trim(); - if (trimmed.includes('.')) return trimmed; + const dotted = trimmed.includes('.'); + if (dotted && !options.installedOnly) return trimmed; const alias = resolveIosAppAlias(trimmed); if (alias !== trimmed) return alias; @@ -54,6 +68,9 @@ export async function resolveIosApp(device: DeviceInfo, app: string): Promise entry.bundleId === trimmed)) { + return iosAppResolutionCache.set(cacheScope, trimmed, trimmed); + } const matches = list.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase()); const match = matches[0]; if (match !== undefined && matches.length === 1) { diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index 84638a4796..90ae4764f3 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -213,9 +213,11 @@ function iosNoDataContainerMessage(bundleId: string): string { return `${bundleId} has no data container to clear. Apps shipped in the simulator runtime, such as system apps, own no data container, so there is no app state here to remove.`; } -/** The bundle id an app-scoped setting lands on: an id passes through, a display name resolves. */ +/** The installed app an app-scoped setting lands on, by bundle id or display name. */ async function resolveIosSettingsApp(device: DeviceInfo, app: string): Promise { - return await resolveSettingsApp(app, (target) => resolveIosApp(device, target)); + return await resolveSettingsApp(app, (target) => + resolveIosApp(device, target, { installedOnly: true }), + ); } async function clearIosSimulatorAppState( diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index 9b63446c97..b6c125c138 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -43,7 +43,7 @@ const settingsCommandMetadata = defineFieldCommandMetadata( // and the daemon enforce per setting rather than the shared field map. state: stringField(), app: stringField( - "App the change targets, by bundle id or package name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. A display name resolves to the installed app's id as it does for `open`; an app that resolves to nothing is refused with `app_not_installed` before anything changes. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a change that is device-wide refuses one with `setting_app_not_consumed` (`location set` on any target, the Android `location on|off` toggle, macOS permissions). The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.", + 'App the change targets, by bundle id, package name, or app name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. A name resolves to the installed app as `open` resolves it (an iOS simulator matches display names and accepts only an installed bundle id, Android matches installed package names, and a Limrun iOS session resolves only the `settings` alias); an app that resolves to nothing is refused with `app_not_installed` before anything changes. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a change that is device-wide refuses one with `setting_app_not_consumed` (`location set` on any target, the Android `location on|off` toggle, macOS permissions). The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.', ), latitude: numberField(), longitude: numberField(), @@ -105,7 +105,7 @@ export const settingsCommandFacet = defineCommandFacet({ name: SETTINGS_COMMAND_NAME, text: { summary: 'Change OS settings and app permissions', - cliDetail: `macOS supports only settings appearance and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; a display name resolves to the installed app's id as it does for open, and an app that resolves to nothing refuses with app_not_installed before anything changes; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. clear-app-state takes its app positionally or with --app, and refuses two different apps. An installed app with no data container (a simulator-runtime system app such as com.apple.Preferences) refuses with app-no-data-container instead of scanning a path simctl never gave. A device-wide change refuses an app with setting_app_not_consumed: location set moves the device's own coordinates on every target, the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`, + cliDetail: `macOS supports only settings appearance and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; a name resolves to the installed app as open resolves it (a Limrun iOS session resolves only the settings alias), and an app that resolves to nothing refuses with app_not_installed before anything changes; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. clear-app-state takes its app positionally or with --app, and refuses two different apps. An installed app with no data container (a simulator-runtime system app such as com.apple.Preferences) refuses with app-no-data-container instead of scanning a path simctl never gave. A device-wide change refuses an app with setting_app_not_consumed: location set moves the device's own coordinates on every target, the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`, }, metadata: settingsCommandMetadata, run: (client, input) => client.settings.update(input as SettingsUpdateOptions), diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index 52bc61fb5b..fde61c5de1 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -81,6 +81,10 @@ test('Provider-backed integration iOS Settings permission and alert flow uses pr if (args.join(' ') === 'help') { return { stdout: 'simctl help\n', stderr: '', exitCode: 0 }; } + if (args.join(' ') === 'listapps sim-1') { + const installed = { 'com.apple.Preferences': { CFBundleDisplayName: 'Settings' } }; + return { stdout: JSON.stringify(installed), stderr: '', exitCode: 0 }; + } return { stdout: '', stderr: '', exitCode: 0 }; }, }); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index cbb5b224c9..156552f029 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -534,7 +534,7 @@ A read-only command such as `get`, `snapshot`, or `wait` reports `no`: a retry r On Android, a command that first dismissed a blocking system dialog or an ANR prompt still reports as if that dismissal had not happened: a read stays `no`, and a mutation keeps its own verdict. The dismissal is not counted as a dispatched step. Once any step of a request reached the device, its failure is `unknown`, and `error.details.dispatchedSteps` counts those steps. A `batch` or a replay reports `no` only when none of its executed steps changed the app. A failure without `dispatched` gives no such guarantee. Treat it as `unknown`. -These refusals carry a typed reason alongside `dispatched: no`, so a driver can branch instead of matching the message: `error.details.reason: session_or_device_selector_required` means the daemon refused before routing anywhere because the request had neither an active session nor a device selector to resolve, so opening a session or passing `--platform` is the fix; `session_app_required` means a per-app setting was asked for while the request carried no app — the session binds none and none was named for the request — so `open ` is the fix, or for a setting that takes an app id (like `settings clear-app-state com.example.app`), name it on the request without opening anything; `app_not_installed` means a per-app setting named an app that resolves to no installed app on the target, so run `apps` for the exact bundle id or package, or install the app, then retry. +These refusals carry a typed reason alongside `dispatched: no`, so a driver can branch instead of matching the message: `error.details.reason: session_or_device_selector_required` means the daemon refused before routing anywhere because the request had neither an active session nor a device selector to resolve, so opening a session or passing `--platform` is the fix; `session_app_required` means a per-app setting was asked for while the request carried no app — the session binds none and none was named for the request — so `open ` is the fix, or for a setting that takes an app id (like `settings clear-app-state com.example.app`), name it on the request without opening anything; `app_not_installed` means a per-app setting named an app that resolves to no installed app on the target, so run `apps --all` for the exact bundle id or package, or install the app, then retry. On a WebDriver-backed device cloud, a request whose driver does not implement its route fails with `error.details.reason: webdriver_route_unsupported` and `dispatched: no`; the driver answered before it ran anything, so the failure is about the cloud's driver, not the device. A command with a sibling route (`orientation` tries two) consumes that refusal and tries the other; only when every route is refused does the command fail. After an ambiguous failure on that transport, only a request that reads and changes nothing is resent; a request that can change the app is sent once. ## Find (semantic) @@ -744,13 +744,13 @@ agent-device settings permission reset screen-recording --platform macos - Android has no category ladder, only a `font_scale` multiplier, so the two are mapped: `large` is `1.0` up through `accessibility-extra-extra-extra-large` at `3.2`. A device holding a multiplier off that ladder reads back as the nearest rung with the exact multiplier as `platformValue`, and an unset `font_scale` reads as `large`/`1.0`. - A text size change is a configuration change: an app already running adopts it on its next configuration change, so relaunch the app under test (`open --relaunch`) when asserting rendered sizes. - `settings location set ` sets precise coordinates on iOS simulators and Android emulators. -- `settings clear-app-state [app-id]` clears the active session app data, or the provided app id. The app id can also be named with `--app` (`settings clear-app-state --app com.example.app`); naming two different apps, one positionally and one with `--app`, is refused with `INVALID_ARGS`. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents and leaves the fresh-install directory layout (empty `Documents`, `Library/Caches`, `Library/Preferences`, `SystemData`, and `tmp`, as on iOS 26.5; other runtimes may differ). With no app bound to the session and no app id named, it refuses with `error.details.reason: session_app_required` and `dispatched: no`. A display name resolves to the installed app's id; one that matches no installed app refuses with `APP_NOT_INSTALLED`, `error.details.reason: app_not_installed`, and `dispatched: no`. An installed app that owns no data container — the apps shipped inside the simulator runtime, such as `com.apple.Preferences` — refuses with `UNSUPPORTED_OPERATION` and `error.details.reason: app-no-data-container`: `simctl` answers its container lookup with "no container", so there is no app state to remove. Runtime-shipped apps cannot be uninstalled, so resetting one means erasing or recreating the simulator device; an app you installed yourself can be uninstalled and reinstalled. iOS physical devices and macOS are unsupported. It does not touch the keychain, so keychain-backed credentials (e.g. Firebase auth) survive it. +- `settings clear-app-state [app-id]` clears the active session app data, or the provided app id. The app id can also be named with `--app` (`settings clear-app-state --app com.example.app`); naming two different apps, one positionally and one with `--app`, is refused with `INVALID_ARGS`. Android uses `pm clear`, which removes SharedPreferences, databases, files, and cache. iOS simulator removes the app data container contents and leaves the fresh-install directory layout (empty `Documents`, `Library/Caches`, `Library/Preferences`, `SystemData`, and `tmp`, as on iOS 26.5; other runtimes may differ). With no app bound to the session and no app id named, it refuses with `error.details.reason: session_app_required` and `dispatched: no`. An app name resolves to the installed app as it does for `settings permission --app`; one that matches no installed app refuses with `APP_NOT_INSTALLED`, `error.details.reason: app_not_installed`, and `dispatched: no`. An installed app that owns no data container — the apps shipped inside the simulator runtime, such as `com.apple.Preferences` — refuses with `UNSUPPORTED_OPERATION` and `error.details.reason: app-no-data-container`: `simctl` answers its container lookup with "no container", so there is no app state to remove. Runtime-shipped apps cannot be uninstalled, so resetting one means erasing or recreating the simulator device; an app you installed yourself can be uninstalled and reinstalled. iOS physical devices and macOS are unsupported. It does not touch the keychain, so keychain-backed credentials (e.g. Firebase auth) survive it. - `settings reset-keychain clear` resets the iOS simulator's keychain (`xcrun simctl keychain reset`), removing keychain-backed credentials such as Firebase auth tokens that `clear-app-state` leaves behind. simctl has no per-app keychain reset, so this clears the keychain for every app installed on that simulator, not only the app under test — treat it as a whole-simulator, opt-in operation and pair it with `clear-app-state` for a full fresh-install reset. iOS physical devices, Android, and macOS are unsupported. - Face ID and Touch ID controls are iPhone and iPad simulator-only; Apple TV and Vision Pro simulators refuse them. They post the same Darwin notifications the Simulator.app Features menu posts (`xcrun simctl spawn notifyutil`): `com.apple.BiometricKit_Sim..` for match/nonmatch, and `com.apple.BiometricKit.enrollmentChanged` (set to `1`/`0`, then posted, then read back) for enroll/unenroll. No `simctl biometric` subcommand exists, so this route works on any Xcode. - Android `settings airplane on|off` is applied by the connectivity service (`cmd connectivity airplane-mode`, Android 11+), which drives the radios rather than only writing the `airplane_mode_on` setting. The response reports the `airplaneMode` that service holds after the change, and Android builds without that command fail without changing device state. Connectivity takes a moment to settle after the switch, so poll the app under test rather than asserting offline behavior immediately. - Fingerprint simulation is supported on Android targets where `cmd fingerprint` or `adb emu finger` is available. On physical Android devices, only `cmd fingerprint` is attempted. -- On iOS and Android, permission actions default to the active session app. Pass `--app ` (or the client `app` option) to aim a `permission` change — grant, deny, or reset — or an iOS simulator `location on|off` privacy change, at an installed app without opening it or binding a session — for example, grant a permission before the app's first launch. The app must be installed on the target; no app has to be running. `--app` also accepts the display name `open` accepts, such as `--app Maps`, and resolves it to the installed app's bundle id or package, as `clear-app-state` does. A name that matches no installed app refuses with `APP_NOT_INSTALLED`, `error.details.reason: app_not_installed`, and `dispatched: no`, and nothing is granted or revoked. The CLI reads `--app` for `settings` only when you pass it on the command line: an `AGENT_DEVICE_TARGET_APP` environment variable or a `targetApp` in `~/.agent-device/config.json` (both useful for `doctor`) never retargets a settings mutation, which falls back to the session app. With no app bound to the session and none named, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry, or name the app with `--app`. On iOS, an on/off `settings location` behaves the same. A change that is device-wide refuses a named app instead of dropping it, with `error.details.reason: setting_app_not_consumed`: `location set` moves the device's own coordinates on every target, the Android `location on|off` toggle writes the device's `location_mode`, and a macOS permission is a host-level TCC grant that needs no session app. +- On iOS and Android, permission actions default to the active session app. Pass `--app ` (or the client `app` option) to aim a `permission` change — grant, deny, or reset — or an iOS simulator `location on|off` privacy change, at an installed app without opening it or binding a session — for example, grant a permission before the app's first launch. The app must be installed on the target; no app has to be running. `--app` also accepts an app name and resolves it to the installed app as `open` and `clear-app-state` do. On an iOS simulator, a display name such as `--app Maps` resolves to `com.apple.Maps`, and a bundle id must belong to an installed app. On Android, a name that is not a package is matched against the installed package names. A Limrun iOS session resolves only the `settings` alias. An app that resolves to nothing refuses with `APP_NOT_INSTALLED`, `error.details.reason: app_not_installed`, and `dispatched: no`, and nothing is granted or revoked. On Android, an alias that names an intent rather than a package (`settings`) refuses with `INVALID_ARGS` and `error.details.reason: setting_app_not_package`. The CLI reads `--app` for `settings` only when you pass it on the command line: an `AGENT_DEVICE_TARGET_APP` environment variable or a `targetApp` in `~/.agent-device/config.json` (both useful for `doctor`) never retargets a settings mutation, which falls back to the session app. With no app bound to the session and none named, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry, or name the app with `--app`. On iOS, an on/off `settings location` behaves the same. A change that is device-wide refuses a named app instead of dropping it, with `error.details.reason: setting_app_not_consumed`: `location set` moves the device's own coordinates on every target, the Android `location on|off` toggle writes the device's `location_mode`, and a macOS permission is a host-level TCC grant that needs no session app. - iOS permission targets: `all`, `camera`, `microphone`, `photos` (`full` or `limited`), `contacts`, `contacts-limited`, `notifications`, `calendar`, `location`, `location-always`, `media-library`, `motion`, `reminders`, `siri`. `all` travels as one `simctl privacy … all` call. - On iOS, which of those services a runtime actually changes is `simctl privacy`'s own verdict, not its help text: Xcode 26 omits `camera` from the list while granting it. A service the runtime refuses fails with `UNSUPPORTED_OPERATION` naming the service; on current runtimes that is `notifications`, which a targeted change cannot reach and `all` leaves untouched. - Android permission targets: `all`, `calendar`, `camera`, `contacts`, `location`, `media-library`, `microphone`, `notifications`, `photos`. `contacts` fans out to `READ_CONTACTS`+`WRITE_CONTACTS`, `location` to `FINE`+`COARSE`, `calendar` to `READ`+`WRITE`; named multi-id targets intersect the package's declared permissions so a coarse-only or read-only app still succeeds, while a target declaring none of its ids fails loudly. `all` resolves against the package's declared permissions instead. Every response reports `permission` as the requested target and `permissions: string[]` as the ids actually mutated, in the order applied (`all` folds its applied ids into `permissions` the same way). From 16b3110520b5d400ce317bfac4eba8c1225f7861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 18:54:14 +0200 Subject: [PATCH 3/7] fix(settings): check Android packages are installed and keep listing failures distinct Address review on #3386: - Android settings resolve with resolveAndroidApp's new installedOnly mode: a package-shaped id must be an installed package (exact match, no fuzzy fallback), so an uninstalled package is refused with app_not_installed before pm runs. open/close keep the pass-through. - A simctl listapps that fails or answers unparsably is now a COMMAND_FAILED enumeration failure in resolveIosApp, not APP_NOT_INSTALLED, so an installed app is never reported missing. - Permission tests target setAndroidPermission, the module they cover. Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/client-settings.ts | 4 +- .../src/__tests__/settings-permission.test.ts | 76 ++++++++++--------- .../src/__tests__/settings.test.ts | 41 +++++++--- .../src/app-deployment-resolution.ts | 22 +++++- packages/platform-android/src/settings.ts | 6 +- .../src/core/__tests__/app-resolution.test.ts | 22 ++++++ .../src/core/__tests__/app-settings.test.ts | 25 ++++++ .../platform-apple/src/core/app-resolution.ts | 43 ++++++++++- src/commands/capture/settings.ts | 2 +- website/docs/docs/commands.md | 2 +- 10 files changed, 182 insertions(+), 61 deletions(-) diff --git a/packages/contracts/src/client-settings.ts b/packages/contracts/src/client-settings.ts index b21701ac63..deabccb16b 100644 --- a/packages/contracts/src/client-settings.ts +++ b/packages/contracts/src/client-settings.ts @@ -87,8 +87,8 @@ export type SettingsUpdateOptions = /** * The app the permission changes, by bundle id, package name, or app name, resolved as `open` * resolves it: an iOS simulator matches display names and accepts only an installed bundle - * id, Android matches installed package names, and a Limrun iOS session resolves only the - * `settings` alias. One that resolves to nothing is refused with `app_not_installed`. Without it the app bound to the session is used; with it no app + * id, Android accepts only an installed package and matches other names against installed + * package names, and a Limrun iOS session resolves only the `settings` alias. One that resolves to nothing is refused with `app_not_installed`. Without it the app bound to the session is used; with it no app * has to be running or open, because `simctl privacy` and Android's `pm` need only the id. * macOS permissions are host-level TCC grants, so naming an app there is refused rather than * dropped. diff --git a/packages/platform-android/src/__tests__/settings-permission.test.ts b/packages/platform-android/src/__tests__/settings-permission.test.ts index ace4330fbe..d487f5d1f8 100644 --- a/packages/platform-android/src/__tests__/settings-permission.test.ts +++ b/packages/platform-android/src/__tests__/settings-permission.test.ts @@ -1,11 +1,28 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { setAndroidSetting } from '../settings.ts'; -import { androidRevokedPermissionWarning } from '../settings-permission.ts'; +import { + androidRevokedPermissionWarning, + parseAndroidPermissionRequest, + setAndroidPermission, +} from '../settings-permission.ts'; import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts'; import { assertRejectsAppError } from './test-utils/app-error.ts'; import { withFakeAdb } from './test-utils/fake-adb.ts'; -import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { SettingOptions } from '@agent-device/contracts/settings'; + +async function setPermission( + device: DeviceInfo, + state: string, + appPackage: string, + options?: SettingOptions, +) { + return await setAndroidPermission( + device, + appPackage, + parseAndroidPermissionRequest(state, options), + ); +} // #1796. Two invariants decide every case here: // * `pm` defaults grant/revoke and the permission-flag operations to UserHandle.USER_SYSTEM, @@ -135,7 +152,7 @@ test.each([ 'setAndroidSetting permission %s addresses the foreground user in every adb call', async (_label, action, options, expected) => { await withFakeAdb(foregroundUser('10', false), async ({ calls, device }) => { - await setAndroidSetting(device, 'permission', action, 'com.example.app', options); + await setPermission(device, action, 'com.example.app', options); assert.deepEqual( calls, expected.map((args) => [...args]), @@ -191,7 +208,7 @@ test.each([ 'setAndroidSetting permission deny reports %s', async (_label, script, priorGrantState) => { await withFakeAdb(script, async ({ device }) => { - const result = await setAndroidSetting(device, 'permission', 'deny', 'com.example.app', { + const result = await setPermission(device, 'deny', 'com.example.app', { permissionTarget: 'microphone', }); const warning = androidRevokedPermissionWarning( @@ -236,7 +253,7 @@ test.each(['grant', 'deny', 'reset'] as const)( async ({ calls, device }) => { await assertRejectsAppError( () => - setAndroidSetting(device, 'permission', action, 'com.example.app', { + setPermission(device, action, 'com.example.app', { permissionTarget: 'microphone', }), { @@ -273,7 +290,7 @@ test.each([ return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; }), async ({ calls, device }) => { - await setAndroidSetting(device, 'permission', action, 'com.example.app', { + await setPermission(device, action, 'com.example.app', { permissionTarget: 'photos', }); const flat = calls.map((args) => args.join(' ')); @@ -310,32 +327,17 @@ test.each([ ], ] as const)('setAndroidSetting permission rejects %s', async (_label, options, message) => { await assertRejectsAppError( - () => setAndroidSetting(ANDROID_EMULATOR, 'permission', 'grant', 'com.example.app', options), + () => setPermission(ANDROID_EMULATOR, 'grant', 'com.example.app', options), { code: 'INVALID_ARGS', message }, ); }); -test('setAndroidSetting permission requires an app in session with the published reason', async () => { - await assertRejectsAppError( - () => - setAndroidSetting(ANDROID_EMULATOR, 'permission', 'deny', undefined, { - permissionTarget: 'camera', - }), - { - code: 'INVALID_ARGS', - message: /requires an active app in session/, - reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired, - dispatched: 'no', - }, - ); -}); - // Explicit multi-id names fan out to one pm call per id, in table order. test('setAndroidSetting permission grant contacts grants both contact ids', async () => { await withFakeAdb( fakeAdb((flat) => (flat === CURRENT_USER ? '0' : undefined)), async ({ calls, device }) => { - await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + await setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'contacts', }); const flat = calls.map((args) => args.join(' ')); @@ -383,7 +385,7 @@ test('setAndroidSetting permission grant contacts applies the declared subset', return undefined; }), async ({ calls, device }) => { - await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + await setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'contacts', }); const flat = calls.map((args) => args.join(' ')); @@ -409,7 +411,7 @@ test('setAndroidSetting permission grant location applies the declared subset', return undefined; }), async ({ calls, device }) => { - const result = (await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + const result = (await setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'location', })) as Record; assert.equal(result.permission, 'location'); @@ -445,7 +447,7 @@ test('setAndroidSetting permission deny location reports both location ids in pe return undefined; }), async ({ calls, device }) => { - const result = (await setAndroidSetting(device, 'permission', 'deny', 'com.example.app', { + const result = (await setPermission(device, 'deny', 'com.example.app', { permissionTarget: 'location', })) as Record; assert.equal(result.permission, 'location'); @@ -476,7 +478,7 @@ test('setAndroidSetting permission grant contacts fails when none of its ids are async ({ calls, device }) => { await assertRejectsAppError( () => - setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'contacts', }), { code: 'COMMAND_FAILED', message: /has not requested permission/ }, @@ -510,7 +512,7 @@ test.each(['deny', 'reset'] as const)( return undefined; }), async ({ calls, device }) => { - const result = (await setAndroidSetting(device, 'permission', action, 'com.example.app', { + const result = (await setPermission(device, action, 'com.example.app', { permissionTarget: 'contacts', })) as Record; assert.equal(result.permission, 'contacts'); @@ -578,7 +580,7 @@ test('setAndroidSetting permission grant all applies the declared changeable ids return undefined; }), async ({ calls, device }) => { - const result = await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + const result = await setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'all', }); const flat = calls.map((args) => args.join(' ')); @@ -627,7 +629,7 @@ test('setAndroidSetting permission all skips a role-managed id', async () => { return undefined; }), async ({ device }) => { - const result = await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + const result = await setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'all', }); assert.deepEqual(result, { @@ -673,7 +675,7 @@ test.each([ return undefined; }), async ({ device }) => { - const result = (await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + const result = (await setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'all', })) as Record; assert.deepEqual(result.permissions, ['android.permission.RECORD_AUDIO']); @@ -717,7 +719,7 @@ test('setAndroidSetting permission grant all stops after an operational failure, async ({ calls, device }) => { await assertRejectsAppError( () => - setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'all', }), { code: 'COMMAND_FAILED', message: /Failed to grant Android permission.*CAMERA/ }, @@ -745,7 +747,7 @@ test('setAndroidSetting permission revoke all warns for the held runtime id', as return undefined; }), async ({ device }) => { - const result = (await setAndroidSetting(device, 'permission', 'deny', 'com.example.app', { + const result = (await setPermission(device, 'deny', 'com.example.app', { permissionTarget: 'all', })) as Record; assert.deepEqual(result.permission, 'all'); @@ -773,7 +775,7 @@ test.each([ async ({ calls, device }) => { await assertRejectsAppError( () => - setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'all', }), { code: 'COMMAND_FAILED', message: /declared permissions|requested permissions/i }, @@ -801,7 +803,7 @@ test('setAndroidSetting permission grant all propagates an operational pm failur async ({ device }) => { await assertRejectsAppError( () => - setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'all', }), { code: 'COMMAND_FAILED', message: /Failed to grant Android permission.*RECORD_AUDIO/ }, @@ -843,7 +845,7 @@ test('setAndroidSetting permission grant all propagates an operational photos fa async ({ device }) => { await assertRejectsAppError( () => - setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { + setPermission(device, 'grant', 'com.example.app', { permissionTarget: 'all', }), { code: 'COMMAND_FAILED', message: /Failed to grant Android photos permission/ }, diff --git a/packages/platform-android/src/__tests__/settings.test.ts b/packages/platform-android/src/__tests__/settings.test.ts index c3304b35e7..c61fe289f9 100644 --- a/packages/platform-android/src/__tests__/settings.test.ts +++ b/packages/platform-android/src/__tests__/settings.test.ts @@ -52,6 +52,7 @@ test('setAndroidSetting clear-app-state force stops and clears package data', as await withFakeAdb( (args) => { const flat = args.join(' '); + if (flat === 'shell pm list packages') return 'package:com.example.app\n'; if (flat === 'shell am force-stop com.example.app') return ''; if (flat === 'shell pm clear com.example.app') return 'Success'; return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; @@ -60,6 +61,7 @@ test('setAndroidSetting clear-app-state force stops and clears package data', as const result = await setAndroidSetting(device, 'clear-app-state', 'clear', 'com.example.app'); assert.deepEqual(result, { package: 'com.example.app', cleared: true }); assert.deepEqual(calls, [ + ['shell', 'pm', 'list', 'packages'], ['shell', 'am', 'force-stop', 'com.example.app'], ['shell', 'pm', 'clear', 'com.example.app'], ]); @@ -104,7 +106,7 @@ test('setAndroidSetting permission grants the package a display name resolves to }); }); -test('setAndroidSetting permission passes a package through without listing packages', async () => { +test('setAndroidSetting permission grants an installed package as named', async () => { await withFakeAdb(installedPackagesAdb, async ({ calls, device }) => { await setAndroidSetting(device, 'permission', 'grant', 'com.example.app', { permissionTarget: 'camera', @@ -118,23 +120,38 @@ test('setAndroidSetting permission passes a package through without listing pack 'com.example.app', 'android.permission.CAMERA', ]); - assert.ok(!calls.some((args) => args.join(' ') === 'shell pm list packages')); }); }); -test.for(['permission', 'clear-app-state'] as const)( - 'setAndroidSetting %s refuses an app that resolves to nothing before changing anything', - async (setting) => { +test('setAndroidSetting permission requires an app in session with the published reason', async () => { + await withFakeAdb(installedPackagesAdb, async ({ calls, device }) => { + await assertRejectsAppError( + () => + setAndroidSetting(device, 'permission', 'deny', undefined, { permissionTarget: 'camera' }), + { + code: 'INVALID_ARGS', + reason: PRE_DISPATCH_REFUSAL_REASONS.sessionAppRequired, + dispatched: 'no', + }, + ); + assert.deepEqual(calls, []); + }); +}); + +test.for([ + ['permission', 'Nowhere'], + ['clear-app-state', 'Nowhere'], + ['permission', 'com.example.missing'], + ['permission', 'com.example'], +] as const)( + 'setAndroidSetting %s refuses %s, which is no installed package, before changing anything', + async ([setting, app]) => { await withFakeAdb(installedPackagesAdb, async ({ calls, device }) => { await assertRejectsAppError( () => - setAndroidSetting( - device, - setting, - setting === 'permission' ? 'grant' : 'clear', - 'Nowhere', - { permissionTarget: 'camera' }, - ), + setAndroidSetting(device, setting, setting === 'permission' ? 'grant' : 'clear', app, { + permissionTarget: 'camera', + }), { code: 'APP_NOT_INSTALLED', reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled, diff --git a/packages/platform-android/src/app-deployment-resolution.ts b/packages/platform-android/src/app-deployment-resolution.ts index bd45856ec3..2fdd154a43 100644 --- a/packages/platform-android/src/app-deployment-resolution.ts +++ b/packages/platform-android/src/app-deployment-resolution.ts @@ -34,13 +34,23 @@ export async function withAndroidAppResolutionCacheInvalidated( ); } +type AndroidAppResolutionOptions = Readonly<{ + /** + * Answer only with a package installed on the device: a package-shaped target is checked against + * the installed packages instead of passing through, so an uninstalled one is refused. + */ + installedOnly?: boolean; +}>; + /** Resolves aliases and fuzzy display names to an Android intent or installed package. */ export async function resolveAndroidApp( device: DeviceInfo, app: string, + options: AndroidAppResolutionOptions = {}, ): Promise { const trimmed = app.trim(); - if (classifyAndroidAppTarget(trimmed) === 'package') return { type: 'package', value: trimmed }; + const packageShaped = classifyAndroidAppTarget(trimmed) === 'package'; + if (packageShaped && !options.installedOnly) return { type: 'package', value: trimmed }; const alias = ALIASES[trimmed.toLowerCase()]; if (alias) return alias; @@ -54,6 +64,10 @@ export async function resolveAndroidApp( .split('\n') .map((line: string) => line.replace('package:', '').trim()) .filter(Boolean); + if (packageShaped) { + if (!packages.includes(trimmed)) throw androidAppNotInstalledError(app); + return androidAppResolutionCache.set(cacheScope, trimmed, { type: 'package', value: trimmed }); + } const matches = packages.filter((pkg: string) => pkg.toLowerCase().includes(trimmed.toLowerCase()), @@ -73,7 +87,11 @@ export async function resolveAndroidApp( }); } - throw new AppError('APP_NOT_INSTALLED', `No package found matching "${app}"`, { + throw androidAppNotInstalledError(app); +} + +function androidAppNotInstalledError(app: string): AppError { + return new AppError('APP_NOT_INSTALLED', `No package found matching "${app}"`, { hint: androidAppsDiscoveryHint, }); } diff --git a/packages/platform-android/src/settings.ts b/packages/platform-android/src/settings.ts index 8747ff13e5..3632f72676 100644 --- a/packages/platform-android/src/settings.ts +++ b/packages/platform-android/src/settings.ts @@ -181,13 +181,15 @@ export async function setAndroidSetting( } } -/** The package an app-scoped setting lands on: a package passes through, a display name resolves. */ +/** The installed package an app-scoped setting lands on, by package or app name. */ async function resolveAndroidSettingsPackage( device: DeviceInfo, setting: string, app: string, ): Promise { - const resolved = await resolveSettingsApp(app, (target) => resolveAndroidApp(device, target)); + const resolved = await resolveSettingsApp(app, (target) => + resolveAndroidApp(device, target, { installedOnly: true }), + ); if (resolved.type === 'intent') { throw new AppError( 'INVALID_ARGS', diff --git a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts index c19251d424..7f7aa60da3 100644 --- a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts @@ -40,6 +40,28 @@ test('resolveIosApp passes a dotted target through without listing apps by defau assert.equal(mockRunSimctl.mock.calls.length, 0); }); +test.for([ + { label: 'a failed listapps', reply: { stdout: '', stderr: 'CoreSimulator error', exitCode: 1 } }, + { label: 'an unparsable listapps answer', reply: { stdout: 'garbage', stderr: '', exitCode: 0 } }, +])( + 'resolveIosApp reports $label as an enumeration failure, not a missing app', + async ({ reply }) => { + mockRunSimctl.mockResolvedValue(reply); + for (const [target, options] of [ + ['com.example.unlisted', { installedOnly: true }], + ['Unlisted', {}], + ] as const) { + await assert.rejects( + () => resolveIosApp(bootedSimulator, target, options), + (error: unknown) => + error instanceof AppError && + error.code === 'COMMAND_FAILED' && + error.details?.reason === undefined, + ); + } + }, +); + test('resolveIosApp installedOnly matches installed bundle ids, then display names, else refuses', async () => { const installedOnly = { installedOnly: true } as const; assert.equal( diff --git a/packages/platform-apple/src/core/__tests__/app-settings.test.ts b/packages/platform-apple/src/core/__tests__/app-settings.test.ts index dbd7d7f803..807227be8e 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -652,3 +652,28 @@ test.for([ }); }, ); + +test('setIosSetting permission keeps a failed app listing distinct from a missing app', async () => { + mockEnsureBootedSimulator.mockResolvedValue(undefined); + + await withFakeAppleTool( + (args) => + isSimctlListApps(args) + ? { stderr: 'CoreSimulator error', exitCode: 1 } + : unexpectedArgs(args), + async ({ calls }) => { + const thrown = await setIosSetting( + IOS_TEST_SIMULATOR, + 'permission', + 'grant', + 'com.example.unlisted', + { permissionTarget: 'photos' }, + ).catch((error: unknown) => error); + assert.ok(thrown instanceof AppError); + assert.equal(thrown.code, 'COMMAND_FAILED'); + assert.notEqual(thrown.details?.reason, PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled); + assert.equal(thrown.details?.dispatched, 'no'); + assert.deepEqual(calls, [['simctl', 'listapps', 'sim-1']]); + }, + ); +}); diff --git a/packages/platform-apple/src/core/app-resolution.ts b/packages/platform-apple/src/core/app-resolution.ts index d25948144c..f9e53a8ac7 100644 --- a/packages/platform-apple/src/core/app-resolution.ts +++ b/packages/platform-apple/src/core/app-resolution.ts @@ -66,7 +66,7 @@ export async function resolveIosApp( const list = device.kind === 'simulator' - ? await listSimulatorApps(device) + ? await requireSimulatorApps(device) : await resolveIosPhysicalDeviceControl(device).listApps(device, 'all'); if (dotted && list.some((entry) => entry.bundleId === trimmed)) { return iosAppResolutionCache.set(cacheScope, trimmed, trimmed); @@ -297,6 +297,23 @@ async function listSimulatorApps( })); } +/** + * The installed apps an app resolution matches against. A listing `simctl` could not produce is a + * failure of its own, never an empty list: reading it as one would report an installed app missing. + */ +async function requireSimulatorApps(device: DeviceInfo): Promise { + const read = await readSimulatorAppMetadata(device); + if (read.type === 'unreadable') { + throw new AppError('COMMAND_FAILED', `Could not list the apps installed on ${device.id}`, { + deviceId: device.id, + exitCode: read.exitCode, + stderr: read.stderr, + hint: 'Check that the simulator is booted and that `xcrun simctl listapps ` answers, then retry.', + }); + } + return read.apps.map((app) => ({ bundleId: app.bundleId, name: app.name })); +} + type SimulatorAppListRecord = Record< string, { @@ -312,18 +329,35 @@ async function listSimulatorAppMetadata( device: DeviceInfo, options?: SimulatorAppListOptions, ): Promise { + const read = await readSimulatorAppMetadata(device, options); + return read.type === 'listed' ? read.apps : []; +} + +type SimulatorAppListRead = + | Readonly<{ type: 'listed'; apps: SimulatorAppMetadata[] }> + | Readonly<{ type: 'unreadable'; exitCode: number | null | undefined; stderr: string }>; + +async function readSimulatorAppMetadata( + device: DeviceInfo, + options?: SimulatorAppListOptions, +): Promise { const remaining = remainingLookupBudget(options ?? {}); const result = await runSimctlForDevice(device, ['listapps', device.id], { allowFailure: true, ...remaining(), }); + const unreadable = { + type: 'unreadable', + exitCode: result.exitCode, + stderr: String(result.stderr ?? ''), + } as const; const trimmed = (result.stdout as string).trim(); - if (!trimmed.startsWith('{')) return []; + if (!trimmed.startsWith('{')) return unreadable; const parsed = parseSimulatorAppList(trimmed) ?? (await convertSimulatorAppList(trimmed, remaining())); - if (!parsed) return []; - return Object.entries(parsed).map(([bundleId, info]) => { + if (!parsed) return unreadable; + const apps = Object.entries(parsed).map(([bundleId, info]) => { const appPath = resolveSimulatorAppPath(info); return { bundleId, @@ -332,6 +366,7 @@ async function listSimulatorAppMetadata( ...(info.ApplicationType ? { applicationType: info.ApplicationType } : {}), }; }); + return { type: 'listed', apps }; } function parseSimulatorAppList(text: string): SimulatorAppListRecord | null { diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index b6c125c138..e0652d2bbe 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -43,7 +43,7 @@ const settingsCommandMetadata = defineFieldCommandMetadata( // and the daemon enforce per setting rather than the shared field map. state: stringField(), app: stringField( - 'App the change targets, by bundle id, package name, or app name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. A name resolves to the installed app as `open` resolves it (an iOS simulator matches display names and accepts only an installed bundle id, Android matches installed package names, and a Limrun iOS session resolves only the `settings` alias); an app that resolves to nothing is refused with `app_not_installed` before anything changes. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a change that is device-wide refuses one with `setting_app_not_consumed` (`location set` on any target, the Android `location on|off` toggle, macOS permissions). The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.', + 'App the change targets, by bundle id, package name, or app name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. A name resolves to the installed app as `open` resolves it (an iOS simulator matches display names and accepts only an installed bundle id, Android accepts only an installed package and matches other names against installed package names, and a Limrun iOS session resolves only the `settings` alias); an app that resolves to nothing is refused with `app_not_installed` before anything changes. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a change that is device-wide refuses one with `setting_app_not_consumed` (`location set` on any target, the Android `location on|off` toggle, macOS permissions). The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.', ), latitude: numberField(), longitude: numberField(), diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 156552f029..7947dac14d 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -750,7 +750,7 @@ agent-device settings permission reset screen-recording --platform macos - Android `settings airplane on|off` is applied by the connectivity service (`cmd connectivity airplane-mode`, Android 11+), which drives the radios rather than only writing the `airplane_mode_on` setting. The response reports the `airplaneMode` that service holds after the change, and Android builds without that command fail without changing device state. Connectivity takes a moment to settle after the switch, so poll the app under test rather than asserting offline behavior immediately. - Fingerprint simulation is supported on Android targets where `cmd fingerprint` or `adb emu finger` is available. On physical Android devices, only `cmd fingerprint` is attempted. -- On iOS and Android, permission actions default to the active session app. Pass `--app ` (or the client `app` option) to aim a `permission` change — grant, deny, or reset — or an iOS simulator `location on|off` privacy change, at an installed app without opening it or binding a session — for example, grant a permission before the app's first launch. The app must be installed on the target; no app has to be running. `--app` also accepts an app name and resolves it to the installed app as `open` and `clear-app-state` do. On an iOS simulator, a display name such as `--app Maps` resolves to `com.apple.Maps`, and a bundle id must belong to an installed app. On Android, a name that is not a package is matched against the installed package names. A Limrun iOS session resolves only the `settings` alias. An app that resolves to nothing refuses with `APP_NOT_INSTALLED`, `error.details.reason: app_not_installed`, and `dispatched: no`, and nothing is granted or revoked. On Android, an alias that names an intent rather than a package (`settings`) refuses with `INVALID_ARGS` and `error.details.reason: setting_app_not_package`. The CLI reads `--app` for `settings` only when you pass it on the command line: an `AGENT_DEVICE_TARGET_APP` environment variable or a `targetApp` in `~/.agent-device/config.json` (both useful for `doctor`) never retargets a settings mutation, which falls back to the session app. With no app bound to the session and none named, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry, or name the app with `--app`. On iOS, an on/off `settings location` behaves the same. A change that is device-wide refuses a named app instead of dropping it, with `error.details.reason: setting_app_not_consumed`: `location set` moves the device's own coordinates on every target, the Android `location on|off` toggle writes the device's `location_mode`, and a macOS permission is a host-level TCC grant that needs no session app. +- On iOS and Android, permission actions default to the active session app. Pass `--app ` (or the client `app` option) to aim a `permission` change — grant, deny, or reset — or an iOS simulator `location on|off` privacy change, at an installed app without opening it or binding a session — for example, grant a permission before the app's first launch. The app must be installed on the target; no app has to be running. `--app` also accepts an app name and resolves it to the installed app as `open` and `clear-app-state` do. On an iOS simulator, a display name such as `--app Maps` resolves to `com.apple.Maps`, and a bundle id must belong to an installed app. On Android, a package must be installed, and any other name is matched against the installed package names. A Limrun iOS session resolves only the `settings` alias. An app that resolves to nothing refuses with `APP_NOT_INSTALLED`, `error.details.reason: app_not_installed`, and `dispatched: no`, and nothing is granted or revoked. On Android, an alias that names an intent rather than a package (`settings`) refuses with `INVALID_ARGS` and `error.details.reason: setting_app_not_package`. The CLI reads `--app` for `settings` only when you pass it on the command line: an `AGENT_DEVICE_TARGET_APP` environment variable or a `targetApp` in `~/.agent-device/config.json` (both useful for `doctor`) never retargets a settings mutation, which falls back to the session app. With no app bound to the session and none named, `settings permission` refuses with `INVALID_ARGS` and `error.details.reason: session_app_required` and `dispatched: no`: `open ` and retry, or name the app with `--app`. On iOS, an on/off `settings location` behaves the same. A change that is device-wide refuses a named app instead of dropping it, with `error.details.reason: setting_app_not_consumed`: `location set` moves the device's own coordinates on every target, the Android `location on|off` toggle writes the device's `location_mode`, and a macOS permission is a host-level TCC grant that needs no session app. - iOS permission targets: `all`, `camera`, `microphone`, `photos` (`full` or `limited`), `contacts`, `contacts-limited`, `notifications`, `calendar`, `location`, `location-always`, `media-library`, `motion`, `reminders`, `siri`. `all` travels as one `simctl privacy … all` call. - On iOS, which of those services a runtime actually changes is `simctl privacy`'s own verdict, not its help text: Xcode 26 omits `camera` from the list while granting it. A service the runtime refuses fails with `UNSUPPORTED_OPERATION` naming the service; on current runtimes that is `notifications`, which a targeted change cannot reach and `all` leaves untouched. - Android permission targets: `all`, `calendar`, `camera`, `contacts`, `location`, `media-library`, `microphone`, `notifications`, `photos`. `contacts` fans out to `READ_CONTACTS`+`WRITE_CONTACTS`, `location` to `FINE`+`COARSE`, `calendar` to `READ`+`WRITE`; named multi-id targets intersect the package's declared permissions so a coarse-only or read-only app still succeeds, while a target declaring none of its ids fails loudly. `all` resolves against the package's declared permissions instead. Every response reports `permission` as the requested target and `permissions: string[]` as the ids actually mutated, in the order applied (`all` folds its applied ids into `permissions` the same way). From 1b96b7fbfec5f89a05382e407d42030b55dd0753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 19:06:21 +0200 Subject: [PATCH 4/7] fix(settings): re-check installed apps on every settings resolution Address review on #3386: - installedOnly resolution on Android and iOS no longer reuses a cached name resolution, so an app uninstalled within the cache TTL is refused instead of reaching pm or simctl privacy. - Document that Android installedOnly may still answer an alias intent the caller must refuse; rewrap the client app doc. - Name the permission tests after setAndroidPermission, which they call. Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/client-settings.ts | 9 +- .../src/__tests__/settings-permission.test.ts | 140 ++++++++---------- .../src/__tests__/settings.test.ts | 22 +++ .../src/app-deployment-resolution.ts | 10 +- .../src/core/__tests__/app-resolution.test.ts | 11 ++ .../platform-apple/src/core/app-resolution.ts | 9 +- 6 files changed, 115 insertions(+), 86 deletions(-) diff --git a/packages/contracts/src/client-settings.ts b/packages/contracts/src/client-settings.ts index deabccb16b..afe9043484 100644 --- a/packages/contracts/src/client-settings.ts +++ b/packages/contracts/src/client-settings.ts @@ -88,10 +88,11 @@ export type SettingsUpdateOptions = * The app the permission changes, by bundle id, package name, or app name, resolved as `open` * resolves it: an iOS simulator matches display names and accepts only an installed bundle * id, Android accepts only an installed package and matches other names against installed - * package names, and a Limrun iOS session resolves only the `settings` alias. One that resolves to nothing is refused with `app_not_installed`. Without it the app bound to the session is used; with it no app - * has to be running or open, because `simctl privacy` and Android's `pm` need only the id. - * macOS permissions are host-level TCC grants, so naming an app there is refused rather than - * dropped. + * package names, and a Limrun iOS session resolves only the `settings` alias. One that + * resolves to nothing is refused with `app_not_installed`. Without it the app bound to the + * session is used; with it no app has to be running or open, because `simctl privacy` and + * Android's `pm` need only the id. macOS permissions are host-level TCC grants, so naming an + * app there is refused rather than dropped. */ app?: string; }); diff --git a/packages/platform-android/src/__tests__/settings-permission.test.ts b/packages/platform-android/src/__tests__/settings-permission.test.ts index d487f5d1f8..c034395df8 100644 --- a/packages/platform-android/src/__tests__/settings-permission.test.ts +++ b/packages/platform-android/src/__tests__/settings-permission.test.ts @@ -149,7 +149,7 @@ test.each([ ], ], ] as const)( - 'setAndroidSetting permission %s addresses the foreground user in every adb call', + 'setAndroidPermission %s addresses the foreground user in every adb call', async (_label, action, options, expected) => { await withFakeAdb(foregroundUser('10', false), async ({ calls, device }) => { await setPermission(device, action, 'com.example.app', options); @@ -204,30 +204,23 @@ test.each([ fakeAdb((flat) => (flat === DUMPSYS ? 'Packages:' : '0')), 'unknown', ], -] as const)( - 'setAndroidSetting permission deny reports %s', - async (_label, script, priorGrantState) => { - await withFakeAdb(script, async ({ device }) => { - const result = await setPermission(device, 'deny', 'com.example.app', { - permissionTarget: 'microphone', - }); - const warning = androidRevokedPermissionWarning( - 'com.example.app', - MICROPHONE, - priorGrantState, - ); - assert.deepEqual(result, { - permission: 'microphone', - permissions: [MICROPHONE], - priorGrantState, - ...(warning ? { warnings: [warning] } : {}), - }); - // not_granted is the only silent state; the other two hand over the same recovery. - assert.equal(warning === undefined, priorGrantState === 'not_granted'); - if (warning) assert.match(warning, /open com\.example\.app --relaunch/); +] as const)('setAndroidPermission deny reports %s', async (_label, script, priorGrantState) => { + await withFakeAdb(script, async ({ device }) => { + const result = await setPermission(device, 'deny', 'com.example.app', { + permissionTarget: 'microphone', }); - }, -); + const warning = androidRevokedPermissionWarning('com.example.app', MICROPHONE, priorGrantState); + assert.deepEqual(result, { + permission: 'microphone', + permissions: [MICROPHONE], + priorGrantState, + ...(warning ? { warnings: [warning] } : {}), + }); + // not_granted is the only silent state; the other two hand over the same recovery. + assert.equal(warning === undefined, priorGrantState === 'not_granted'); + if (warning) assert.match(warning, /open com\.example\.app --relaunch/); + }); +}); test('the revoke warning states the platform rule and keeps the consequence conditional', () => { // The read proves neither that the app was running nor, for `unknown`, what the state was. @@ -244,7 +237,7 @@ test('the revoke warning states the platform rule and keeps the consequence cond // A mutation that cannot name its user is refused, not issued unscoped: `pm` would apply it to // user 0 and leave a session running as another user untouched, which is the whole defect. test.each(['grant', 'deny', 'reset'] as const)( - 'setAndroidSetting permission %s refuses to mutate when the acting user cannot be resolved', + 'setAndroidPermission %s refuses to mutate when the acting user cannot be resolved', async (action) => { await withFakeAdb( fakeAdb((flat) => @@ -276,7 +269,7 @@ test.each([ ['36', 'reset' as const, 'android.permission.READ_MEDIA_IMAGES'], ['32', 'grant' as const, 'android.permission.READ_EXTERNAL_STORAGE'], ] as const)( - 'setAndroidSetting permission photos on SDK %s resolves %s to %s', + 'setAndroidPermission photos on SDK %s resolves %s to %s', async (sdk, action, permission) => { // `reset` maps to `pm revoke`; only `grant` keeps its verb. const pmAction = action === 'grant' ? 'grant' : 'revoke'; @@ -325,7 +318,7 @@ test.each([ { permissionTarget: 'location-always' }, /Unsupported permission target on Android/i, ], -] as const)('setAndroidSetting permission rejects %s', async (_label, options, message) => { +] as const)('setAndroidPermission rejects %s', async (_label, options, message) => { await assertRejectsAppError( () => setPermission(ANDROID_EMULATOR, 'grant', 'com.example.app', options), { code: 'INVALID_ARGS', message }, @@ -333,7 +326,7 @@ test.each([ }); // Explicit multi-id names fan out to one pm call per id, in table order. -test('setAndroidSetting permission grant contacts grants both contact ids', async () => { +test('setAndroidPermission grant contacts grants both contact ids', async () => { await withFakeAdb( fakeAdb((flat) => (flat === CURRENT_USER ? '0' : undefined)), async ({ calls, device }) => { @@ -369,7 +362,7 @@ function dumpsysWithRequestedIds(ids: readonly string[]): string { // A named multi-id target intersects the declared set like `all` does: an app // declaring only READ_CONTACTS gets only that id, instead of failing on the // missing WRITE_CONTACTS like the strict fan-out did. -test('setAndroidSetting permission grant contacts applies the declared subset', async () => { +test('setAndroidPermission grant contacts applies the declared subset', async () => { const requested = dumpsysWithRequestedIds(['android.permission.READ_CONTACTS']); await withFakeAdb( fakeAdb((flat) => { @@ -402,7 +395,7 @@ test('setAndroidSetting permission grant contacts applies the declared subset', }); // Same for location: a coarse-only app gets COARSE without attempting FINE. -test('setAndroidSetting permission grant location applies the declared subset', async () => { +test('setAndroidPermission grant location applies the declared subset', async () => { const requested = dumpsysWithRequestedIds(['android.permission.ACCESS_COARSE_LOCATION']); await withFakeAdb( fakeAdb((flat) => { @@ -435,7 +428,7 @@ test('setAndroidSetting permission grant location applies the declared subset', // #2701: a multi-id target reports every id it actually changed under `permissions`, // while `permission` keeps naming the requested target. -test('setAndroidSetting permission deny location reports both location ids in permissions', async () => { +test('setAndroidPermission deny location reports both location ids in permissions', async () => { const requested = dumpsysWithRequestedIds([ 'android.permission.ACCESS_FINE_LOCATION', 'android.permission.ACCESS_COARSE_LOCATION', @@ -467,7 +460,7 @@ test('setAndroidSetting permission deny location reports both location ids in pe }); // An explicit target declaring none of its ids fails loudly with no pm call. -test('setAndroidSetting permission grant contacts fails when none of its ids are declared', async () => { +test('setAndroidPermission grant contacts fails when none of its ids are declared', async () => { const requested = dumpsysWithRequestedIds(['android.permission.CAMERA']); await withFakeAdb( fakeAdb((flat) => { @@ -495,7 +488,7 @@ test('setAndroidSetting permission grant contacts fails when none of its ids are // READ is revoked, so a fake that fails WRITE proves it was never attempted — // the path `all: deny` plus a named override depends on. test.each(['deny', 'reset'] as const)( - 'setAndroidSetting permission %s contacts revokes only the declared READ id', + 'setAndroidPermission %s contacts revokes only the declared READ id', async (action) => { const requested = dumpsysWithRequestedIds(['android.permission.READ_CONTACTS']); await withFakeAdb( @@ -558,7 +551,7 @@ function dumpsysWithRequested(): string { // `all` intersects the declared set before issuing anything: INTERNET is declared // but not changeable, so it is skipped with a reason while RECORD_AUDIO lands. -test('setAndroidSetting permission grant all applies the declared changeable ids', async () => { +test('setAndroidPermission grant all applies the declared changeable ids', async () => { await withFakeAdb( fakeAdb((flat) => { if (flat === CURRENT_USER) return '0'; @@ -603,7 +596,7 @@ test('setAndroidSetting permission grant all applies the declared changeable ids // A role-managed id (WRITE_SETTINGS on API 36: "managed by role") is // declared-but-not-changeable like INTERNET, so `all` skips it with a reason // instead of aborting — found live on com.google.android.contacts. -test('setAndroidSetting permission all skips a role-managed id', async () => { +test('setAndroidPermission all skips a role-managed id', async () => { const requested = [ 'Packages:', ' Package [com.example.app] (abc):', @@ -651,48 +644,45 @@ test.each([ ['not a changeable permission type', 'not-changeable'], ['is not a runtime permission', 'not-runtime-permission'], ['Unknown permission android.permission.CUSTOM specified', 'unknown-permission'], -] as const)( - 'setAndroidSetting permission all skips stderr matching "%s"', - async (stderrText, _reason) => { - const requested = [ - 'Packages:', - ' Package [com.example.app] (abc):', - ' requested permissions:', - ' android.permission.CUSTOM', - ' android.permission.RECORD_AUDIO', - ' User 0: ceDataInode=0 installed=true', - ' runtime permissions:', - ' android.permission.RECORD_AUDIO: granted=false', - 'Queries:', - ].join('\n'); - await withFakeAdb( - fakeAdb((flat) => { - if (flat === CURRENT_USER) return '0'; - if (flat === DUMPSYS) return requested; - if (flat === 'shell pm grant --user 0 com.example.app android.permission.CUSTOM') { - return { stderr: stderrText, exitCode: 1 }; - } - return undefined; - }), - async ({ device }) => { - const result = (await setPermission(device, 'grant', 'com.example.app', { - permissionTarget: 'all', - })) as Record; - assert.deepEqual(result.permissions, ['android.permission.RECORD_AUDIO']); - assert.deepEqual(result.warnings, [ - `Skipped android.permission.CUSTOM for com.example.app: ${stderrText}`, - ]); - }, - ); - }, -); +] as const)('setAndroidPermission all skips stderr matching "%s"', async (stderrText, _reason) => { + const requested = [ + 'Packages:', + ' Package [com.example.app] (abc):', + ' requested permissions:', + ' android.permission.CUSTOM', + ' android.permission.RECORD_AUDIO', + ' User 0: ceDataInode=0 installed=true', + ' runtime permissions:', + ' android.permission.RECORD_AUDIO: granted=false', + 'Queries:', + ].join('\n'); + await withFakeAdb( + fakeAdb((flat) => { + if (flat === CURRENT_USER) return '0'; + if (flat === DUMPSYS) return requested; + if (flat === 'shell pm grant --user 0 com.example.app android.permission.CUSTOM') { + return { stderr: stderrText, exitCode: 1 }; + } + return undefined; + }), + async ({ device }) => { + const result = (await setPermission(device, 'grant', 'com.example.app', { + permissionTarget: 'all', + })) as Record; + assert.deepEqual(result.permissions, ['android.permission.RECORD_AUDIO']); + assert.deepEqual(result.warnings, [ + `Skipped android.permission.CUSTOM for com.example.app: ${stderrText}`, + ]); + }, + ); +}); // The abort side, named per the sequence it stops: an operational failure on the second // declared id leaves the first id applied (its pm call already landed) and never attempts the // third — proving the abort halts the fan-out rather than skipping past it. A mutation that // treats an unrecognized stderr as skippable (rather than aborting) makes the third id's pm // call appear in `calls`, failing the assertion below. -test('setAndroidSetting permission grant all stops after an operational failure, keeping the earlier applied id', async () => { +test('setAndroidPermission grant all stops after an operational failure, keeping the earlier applied id', async () => { const requested = [ 'Packages:', ' Package [com.example.app] (abc):', @@ -739,7 +729,7 @@ test('setAndroidSetting permission grant all stops after an operational failure, }); // Revoke under `all` warns per held permission, like the single path. -test('setAndroidSetting permission revoke all warns for the held runtime id', async () => { +test('setAndroidPermission revoke all warns for the held runtime id', async () => { await withFakeAdb( fakeAdb((flat) => { if (flat === CURRENT_USER) return '0'; @@ -765,7 +755,7 @@ test('setAndroidSetting permission revoke all warns for the held runtime id', as test.each([ ['dumpsys fails', { stderr: 'error', exitCode: 1 }], ['no requested section', dumpsys([{ id: 0, runtime: [[MICROPHONE, false]] }])], -] as const)('setAndroidSetting permission all refuses when %s', async (_label, reply) => { +] as const)('setAndroidPermission all refuses when %s', async (_label, reply) => { await withFakeAdb( fakeAdb((flat) => { if (flat === CURRENT_USER) return '0'; @@ -790,7 +780,7 @@ test.each([ // An operational pm failure mid-`all` aborts instead of being skipped: an // offline device must not let launchApp continue with half-applied permissions. -test('setAndroidSetting permission grant all propagates an operational pm failure', async () => { +test('setAndroidPermission grant all propagates an operational pm failure', async () => { await withFakeAdb( fakeAdb((flat) => { if (flat === CURRENT_USER) return '0'; @@ -814,7 +804,7 @@ test('setAndroidSetting permission grant all propagates an operational pm failur // A photos probe that fails operationally (not as non-changeable) aborts `all` // rather than collapsing into a skip warning. -test('setAndroidSetting permission grant all propagates an operational photos failure', async () => { +test('setAndroidPermission grant all propagates an operational photos failure', async () => { const requested = [ 'Packages:', ' Package [com.example.app] (abc):', diff --git a/packages/platform-android/src/__tests__/settings.test.ts b/packages/platform-android/src/__tests__/settings.test.ts index c61fe289f9..b0c13f6f53 100644 --- a/packages/platform-android/src/__tests__/settings.test.ts +++ b/packages/platform-android/src/__tests__/settings.test.ts @@ -123,6 +123,28 @@ test('setAndroidSetting permission grants an installed package as named', async }); }); +test('setAndroidSetting permission refuses a name resolved earlier whose package is gone', async () => { + let packages = INSTALLED_PACKAGES; + await withFakeAdb( + (args) => (args.join(' ') === 'shell pm list packages' ? packages : installedPackagesAdb(args)), + async ({ calls, device }) => { + await setAndroidSetting(device, 'permission', 'grant', 'Chrome', { + permissionTarget: 'camera', + }); + packages = 'package:com.example.app\n'; + calls.length = 0; + await assertRejectsAppError( + () => + setAndroidSetting(device, 'permission', 'grant', 'Chrome', { + permissionTarget: 'camera', + }), + { code: 'APP_NOT_INSTALLED', reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled }, + ); + assert.deepEqual(calls, [['shell', 'pm', 'list', 'packages']]); + }, + ); +}); + test('setAndroidSetting permission requires an app in session with the published reason', async () => { await withFakeAdb(installedPackagesAdb, async ({ calls, device }) => { await assertRejectsAppError( diff --git a/packages/platform-android/src/app-deployment-resolution.ts b/packages/platform-android/src/app-deployment-resolution.ts index 2fdd154a43..6b5eb7b2b9 100644 --- a/packages/platform-android/src/app-deployment-resolution.ts +++ b/packages/platform-android/src/app-deployment-resolution.ts @@ -36,8 +36,10 @@ export async function withAndroidAppResolutionCacheInvalidated( type AndroidAppResolutionOptions = Readonly<{ /** - * Answer only with a package installed on the device: a package-shaped target is checked against - * the installed packages instead of passing through, so an uninstalled one is refused. + * Answer with a package installed on the device now, or with the intent an alias names, which a + * caller that needs a package must refuse. A package-shaped target is checked against the + * installed packages instead of passing through, and no earlier resolution is reused, so an app + * uninstalled since is refused. */ installedOnly?: boolean; }>; @@ -56,7 +58,9 @@ export async function resolveAndroidApp( if (alias) return alias; const cacheScope = androidAppResolutionScope(device); - const cached = androidAppResolutionCache.get(cacheScope, trimmed); + const cached = options.installedOnly + ? undefined + : androidAppResolutionCache.get(cacheScope, trimmed); if (cached) return cached; const result = await runAndroidShell(device, ['pm', 'list', 'packages']); diff --git a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts index 7f7aa60da3..0e6a0fe0f9 100644 --- a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts @@ -62,6 +62,17 @@ test.for([ }, ); +test('resolveIosApp installedOnly re-checks a name an earlier resolution cached', async () => { + assert.equal(await resolveIosApp(bootedSimulator, 'Demo'), 'com.example.demo'); + mockRunSimctl.mockResolvedValue({ stdout: JSON.stringify({}) }); + + assert.equal(await resolveIosApp(bootedSimulator, 'Demo'), 'com.example.demo'); + await assert.rejects( + () => resolveIosApp(bootedSimulator, 'Demo', { installedOnly: true }), + (error: unknown) => error instanceof AppError && error.code === 'APP_NOT_INSTALLED', + ); +}); + test('resolveIosApp installedOnly matches installed bundle ids, then display names, else refuses', async () => { const installedOnly = { installedOnly: true } as const; assert.equal( diff --git a/packages/platform-apple/src/core/app-resolution.ts b/packages/platform-apple/src/core/app-resolution.ts index f9e53a8ac7..563a09d330 100644 --- a/packages/platform-apple/src/core/app-resolution.ts +++ b/packages/platform-apple/src/core/app-resolution.ts @@ -39,9 +39,10 @@ export async function invalidateIosAppResolutionCache( type IosAppResolutionOptions = Readonly<{ /** - * Answer only with an app installed on the device. A dotted target is then matched against the - * installed bundle ids before display names, so `Booking.com` resolves instead of passing through, - * and a target nothing installed matches is refused rather than returned as a literal id. + * Answer only with an app installed on the device now. A dotted target is then matched against + * the installed bundle ids before display names, so `Booking.com` resolves instead of passing + * through; no earlier resolution is reused, and a target nothing installed matches is refused + * rather than returned as a literal id. */ installedOnly?: boolean; }>; @@ -61,7 +62,7 @@ export async function resolveIosApp( const alias = resolveIosAppAlias(trimmed); if (alias !== trimmed) return alias; const cacheScope = iosAppResolutionScope(device); - const cached = iosAppResolutionCache.get(cacheScope, trimmed); + const cached = options.installedOnly ? undefined : iosAppResolutionCache.get(cacheScope, trimmed); if (cached) return cached; const list = From a7e834da7aa467783caff998da096406f8e0d74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 19:17:30 +0200 Subject: [PATCH 5/7] docs(apple): scope installedOnly to simulator and device targets No settings path reaches resolveIosApp with installedOnly on the macOS host, which keeps resolving through resolveMacOsApp; say so. Co-Authored-By: Claude Opus 5.5 --- packages/platform-apple/src/core/app-resolution.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/platform-apple/src/core/app-resolution.ts b/packages/platform-apple/src/core/app-resolution.ts index 563a09d330..630d4ca965 100644 --- a/packages/platform-apple/src/core/app-resolution.ts +++ b/packages/platform-apple/src/core/app-resolution.ts @@ -39,10 +39,11 @@ export async function invalidateIosAppResolutionCache( type IosAppResolutionOptions = Readonly<{ /** - * Answer only with an app installed on the device now. A dotted target is then matched against - * the installed bundle ids before display names, so `Booking.com` resolves instead of passing - * through; no earlier resolution is reused, and a target nothing installed matches is refused - * rather than returned as a literal id. + * On a simulator or device, answer only with an app installed on it now. A dotted target is then + * matched against the installed bundle ids before display names, so `Booking.com` resolves + * instead of passing through; no earlier resolution is reused, and a target nothing installed + * matches is refused rather than returned as a literal id. The macOS host ignores it and resolves + * through `resolveMacOsApp`. */ installedOnly?: boolean; }>; From 4b1aebc99ff29488dc05490fe4fea8d759c85dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 09:05:30 +0200 Subject: [PATCH 6/7] refactor(settings): one installed-only app rule, scoped to the acting Android user Address review on #3386: - findIosSimulatorInstalledApp delegates to resolveIosApp's installedOnly mode. It stays lenient: a missing or ambiguous app and an unreadable simctl app list (now typed app-list-unreadable) answer undefined, and other failures still propagate. installedOnly also verifies an alias (settings -> com.apple.Preferences) is installed, as the probe did. - Android permission settings read the acting user once and list that user's packages (`pm list packages --user `), so a package installed only for a secondary foreground user is not refused. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/settings-permission.test.ts | 5 +- .../src/__tests__/settings.test.ts | 50 +++++++++++-- .../src/app-deployment-resolution.ts | 5 +- .../src/settings-permission.ts | 5 +- packages/platform-android/src/settings.ts | 13 +++- .../src/core/__tests__/app-resolution.test.ts | 16 +++- .../platform-apple/src/core/app-resolution.ts | 75 ++++++++++++------- .../provider-scenarios/android-world.ts | 6 +- 8 files changed, 133 insertions(+), 42 deletions(-) diff --git a/packages/platform-android/src/__tests__/settings-permission.test.ts b/packages/platform-android/src/__tests__/settings-permission.test.ts index c034395df8..9431e9059d 100644 --- a/packages/platform-android/src/__tests__/settings-permission.test.ts +++ b/packages/platform-android/src/__tests__/settings-permission.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { androidRevokedPermissionWarning, parseAndroidPermissionRequest, + requireAndroidPermissionUser, setAndroidPermission, } from '../settings-permission.ts'; import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts'; @@ -17,10 +18,12 @@ async function setPermission( appPackage: string, options?: SettingOptions, ) { + const request = parseAndroidPermissionRequest(state, options); return await setAndroidPermission( device, appPackage, - parseAndroidPermissionRequest(state, options), + request, + await requireAndroidPermissionUser(device), ); } diff --git a/packages/platform-android/src/__tests__/settings.test.ts b/packages/platform-android/src/__tests__/settings.test.ts index b0c13f6f53..a732626e93 100644 --- a/packages/platform-android/src/__tests__/settings.test.ts +++ b/packages/platform-android/src/__tests__/settings.test.ts @@ -84,9 +84,14 @@ test('setAndroidSetting clear-app-state refuses an appless session with the publ const INSTALLED_PACKAGES = 'package:com.example.app\npackage:com.android.chrome\n'; +const LIST_PACKAGES = ['shell', 'pm', 'list', 'packages']; +const CURRENT_USER = ['shell', 'am', 'get-current-user']; + function installedPackagesAdb(args: string[]) { const flat = args.join(' '); - if (flat === 'shell pm list packages') return INSTALLED_PACKAGES; + if (flat === 'shell pm list packages' || flat === 'shell pm list packages --user 0') { + return INSTALLED_PACKAGES; + } if (flat === 'shell am get-current-user') return '0'; if (flat.startsWith('shell dumpsys package ') || flat.startsWith('shell pm grant ')) return ''; return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; @@ -98,8 +103,8 @@ test('setAndroidSetting permission grants the package a display name resolves to permissionTarget: 'camera', }); assert.deepEqual(calls, [ - ['shell', 'pm', 'list', 'packages'], - ['shell', 'am', 'get-current-user'], + CURRENT_USER, + [...LIST_PACKAGES, '--user', '0'], ['shell', 'dumpsys', 'package', 'com.android.chrome'], ['shell', 'pm', 'grant', '--user', '0', 'com.android.chrome', 'android.permission.CAMERA'], ]); @@ -123,10 +128,36 @@ test('setAndroidSetting permission grants an installed package as named', async }); }); +test('setAndroidSetting permission checks the packages of the user it grants for', async () => { + await withFakeAdb( + (args) => { + const flat = args.join(' '); + if (flat === 'shell am get-current-user') return '10'; + if (flat === 'shell pm list packages --user 10') return 'package:com.example.secondary\n'; + if (flat.startsWith('shell dumpsys package ') || flat.startsWith('shell pm grant ')) + return ''; + return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; + }, + async ({ calls, device }) => { + await setAndroidSetting(device, 'permission', 'grant', 'com.example.secondary', { + permissionTarget: 'camera', + }); + assert.deepEqual(calls, [ + CURRENT_USER, + [...LIST_PACKAGES, '--user', '10'], + ['shell', 'dumpsys', 'package', 'com.example.secondary'], + // prettier-ignore + ['shell', 'pm', 'grant', '--user', '10', 'com.example.secondary', 'android.permission.CAMERA'], + ]); + }, + ); +}); + test('setAndroidSetting permission refuses a name resolved earlier whose package is gone', async () => { let packages = INSTALLED_PACKAGES; await withFakeAdb( - (args) => (args.join(' ') === 'shell pm list packages' ? packages : installedPackagesAdb(args)), + (args) => + args.join(' ') === 'shell pm list packages --user 0' ? packages : installedPackagesAdb(args), async ({ calls, device }) => { await setAndroidSetting(device, 'permission', 'grant', 'Chrome', { permissionTarget: 'camera', @@ -140,7 +171,7 @@ test('setAndroidSetting permission refuses a name resolved earlier whose package }), { code: 'APP_NOT_INSTALLED', reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled }, ); - assert.deepEqual(calls, [['shell', 'pm', 'list', 'packages']]); + assert.deepEqual(calls, [CURRENT_USER, [...LIST_PACKAGES, '--user', '0']]); }, ); }); @@ -181,7 +212,12 @@ test.for([ hint: /apps --all/, }, ); - assert.deepEqual(calls, [['shell', 'pm', 'list', 'packages']]); + assert.deepEqual( + calls, + setting === 'permission' + ? [CURRENT_USER, [...LIST_PACKAGES, '--user', '0']] + : [LIST_PACKAGES], + ); }); }, ); @@ -195,7 +231,7 @@ test('setAndroidSetting permission refuses an app alias that resolves to an inte }), { code: 'INVALID_ARGS', reason: SETTINGS_APP_NOT_PACKAGE_REASON, dispatched: 'no' }, ); - assert.deepEqual(calls, []); + assert.deepEqual(calls, [CURRENT_USER]); }); }); diff --git a/packages/platform-android/src/app-deployment-resolution.ts b/packages/platform-android/src/app-deployment-resolution.ts index 6b5eb7b2b9..198211cbce 100644 --- a/packages/platform-android/src/app-deployment-resolution.ts +++ b/packages/platform-android/src/app-deployment-resolution.ts @@ -42,6 +42,8 @@ type AndroidAppResolutionOptions = Readonly<{ * uninstalled since is refused. */ installedOnly?: boolean; + /** The user whose installed packages an `installedOnly` lookup reads; the device default without it. */ + userId?: number; }>; /** Resolves aliases and fuzzy display names to an Android intent or installed package. */ @@ -63,7 +65,8 @@ export async function resolveAndroidApp( : androidAppResolutionCache.get(cacheScope, trimmed); if (cached) return cached; - const result = await runAndroidShell(device, ['pm', 'list', 'packages']); + const userArgs = options.userId === undefined ? [] : ['--user', String(options.userId)]; + const result = await runAndroidShell(device, ['pm', 'list', 'packages', ...userArgs]); const packages = result.stdout .split('\n') .map((line: string) => line.replace('package:', '').trim()) diff --git a/packages/platform-android/src/settings-permission.ts b/packages/platform-android/src/settings-permission.ts index 5b5f448c78..b4cb43199e 100644 --- a/packages/platform-android/src/settings-permission.ts +++ b/packages/platform-android/src/settings-permission.ts @@ -101,7 +101,7 @@ type AndroidUserArgs = readonly string[]; * the path where we already know we are guessing, so the command refuses instead: no permission * state is changed when we cannot name whose state it is. */ -async function requireAndroidPermissionUser(device: DeviceInfo): Promise { +export async function requireAndroidPermissionUser(device: DeviceInfo): Promise { const userId = await readAndroidCurrentUserId(device); if (userId !== undefined) return userId; throw new AppError( @@ -130,12 +130,13 @@ export function parseAndroidPermissionRequest( }; } +/** Applies a validated permission change for `userId`, the user `requireAndroidPermissionUser` named. */ export async function setAndroidPermission( device: DeviceInfo, appPackage: string, { action, target }: AndroidPermissionRequest, + userId: number, ): Promise | void> { - const userId = await requireAndroidPermissionUser(device); const userArgs: AndroidUserArgs = ['--user', String(userId)]; if (target.kind === 'all') { return await setAllAndroidPermissions(device, appPackage, action, userId, userArgs); diff --git a/packages/platform-android/src/settings.ts b/packages/platform-android/src/settings.ts index 3632f72676..960067dff7 100644 --- a/packages/platform-android/src/settings.ts +++ b/packages/platform-android/src/settings.ts @@ -24,7 +24,11 @@ import { runAndroidAdb, runAndroidShell } from './adb.ts'; import { setAndroidAirplaneMode } from './settings-airplane.ts'; import { androidAdbResultError } from './adb-executor.ts'; import { resolveAndroidApp } from './app-deployment-resolution.ts'; -import { parseAndroidPermissionRequest, setAndroidPermission } from './settings-permission.ts'; +import { + parseAndroidPermissionRequest, + requireAndroidPermissionUser, + setAndroidPermission, +} from './settings-permission.ts'; const ANDROID_ANIMATION_SCALE_SETTINGS = [ 'window_animation_scale', @@ -167,10 +171,12 @@ export async function setAndroidSetting( ); } const request = parseAndroidPermissionRequest(state, options); + const userId = await requireAndroidPermissionUser(device); return await setAndroidPermission( device, - await resolveAndroidSettingsPackage(device, normalized, appPackage), + await resolveAndroidSettingsPackage(device, normalized, appPackage, userId), request, + userId, ); } case 'text-size': { @@ -186,9 +192,10 @@ async function resolveAndroidSettingsPackage( device: DeviceInfo, setting: string, app: string, + userId?: number, ): Promise { const resolved = await resolveSettingsApp(app, (target) => - resolveAndroidApp(device, target, { installedOnly: true }), + resolveAndroidApp(device, target, { installedOnly: true, userId }), ); if (resolved.type === 'intent') { throw new AppError( diff --git a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts index 0e6a0fe0f9..f8823304d7 100644 --- a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts @@ -56,7 +56,7 @@ test.for([ (error: unknown) => error instanceof AppError && error.code === 'COMMAND_FAILED' && - error.details?.reason === undefined, + error.details?.reason === 'app-list-unreadable', ); } }, @@ -104,6 +104,20 @@ test('findIosSimulatorInstalledApp verifies exact bundle ids and app-name aliase ); }); +test('findIosSimulatorInstalledApp answers not-here for an ambiguous name or an unreadable app list', async () => { + mockRunSimctl.mockResolvedValue({ + stdout: JSON.stringify({ + 'com.example.one': { CFBundleDisplayName: 'Twin' }, + 'com.example.two': { CFBundleDisplayName: 'Twin' }, + }), + }); + assert.equal(await findIosSimulatorInstalledApp(bootedSimulator, 'Twin'), undefined); + assert.equal(await findIosSimulatorInstalledApp(bootedSimulator, 'settings'), undefined); + + mockRunSimctl.mockResolvedValue({ stdout: '', stderr: 'CoreSimulator error', exitCode: 1 }); + assert.equal(await findIosSimulatorInstalledApp(bootedSimulator, 'com.example.one'), undefined); +}); + test('findIosSimulatorInstalledApp does not probe a stopped simulator', async () => { assert.equal( await findIosSimulatorInstalledApp({ ...bootedSimulator, booted: false }, 'com.example.demo'), diff --git a/packages/platform-apple/src/core/app-resolution.ts b/packages/platform-apple/src/core/app-resolution.ts index 630d4ca965..4cdf547c0f 100644 --- a/packages/platform-apple/src/core/app-resolution.ts +++ b/packages/platform-apple/src/core/app-resolution.ts @@ -57,42 +57,56 @@ export async function resolveIosApp( return await resolveMacOsApp(app); } const trimmed = app.trim(); - const dotted = trimmed.includes('.'); - if (dotted && !options.installedOnly) return trimmed; - const alias = resolveIosAppAlias(trimmed); - if (alias !== trimmed) return alias; const cacheScope = iosAppResolutionScope(device); - const cached = options.installedOnly ? undefined : iosAppResolutionCache.get(cacheScope, trimmed); - if (cached) return cached; + if (!options.installedOnly) { + if (trimmed.includes('.')) return trimmed; + if (alias !== trimmed) return alias; + const cached = iosAppResolutionCache.get(cacheScope, trimmed); + if (cached) return cached; + } const list = device.kind === 'simulator' ? await requireSimulatorApps(device) : await resolveIosPhysicalDeviceControl(device).listApps(device, 'all'); - if (dotted && list.some((entry) => entry.bundleId === trimmed)) { - return iosAppResolutionCache.set(cacheScope, trimmed, trimmed); + return iosAppResolutionCache.set( + cacheScope, + trimmed, + matchInstalledIosApp(list, app, trimmed, alias), + ); +} + +/** + * The installed app a target names: an alias or a dotted target by its bundle id first, and any + * target but an alias by its display name. + */ +function matchInstalledIosApp( + list: readonly IosAppInfo[], + app: string, + trimmed: string, + alias: string, +): string { + const isAlias = alias !== trimmed; + if ((isAlias || trimmed.includes('.')) && list.some((entry) => entry.bundleId === alias)) { + return alias; } + if (isAlias) throw buildAppNotInstalledError(app); const matches = list.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase()); const match = matches[0]; - if (match !== undefined && matches.length === 1) { - return iosAppResolutionCache.set(cacheScope, trimmed, match.bundleId); - } + if (match !== undefined && matches.length === 1) return match.bundleId; if (matches.length > 1) { throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches }); } - throw buildAppNotInstalledError(app); } export { buildAppNotInstalledError }; /** - * Resolves an app only when it is installed on this booted simulator. - * - * Device selection uses this narrower lookup before it has committed to a - * simulator, so an exact bundle id must not take resolveIosApp's normal - * pass-through path. + * Resolves an app only when it is installed on this booted simulator, by the `installedOnly` rule. + * Device selection probes candidates with it, so a missing or ambiguous app and an app list `simctl` + * could not produce all answer "not here" rather than failing the selection. */ export async function findIosSimulatorInstalledApp( device: DeviceInfo, @@ -101,17 +115,22 @@ export async function findIosSimulatorInstalledApp( if (!isIosFamily(device) || device.kind !== 'simulator' || device.booted !== true) { return undefined; } - - const target = resolveIosAppAlias(app.trim()); - if (!target) return undefined; - - const apps = await listSimulatorApps(device); - if (target.includes('.')) { - return apps.find((entry) => entry.bundleId === target)?.bundleId; + if (!app.trim()) return undefined; + try { + return await resolveIosApp(device, app, { installedOnly: true }); + } catch (error) { + if (isUnresolvedInstalledApp(error)) return undefined; + throw error; } +} - const matches = apps.filter((entry) => entry.name.toLowerCase() === target.toLowerCase()); - return matches.length === 1 ? matches[0]?.bundleId : undefined; +function isUnresolvedInstalledApp(error: unknown): boolean { + if (!(error instanceof AppError)) return false; + return ( + error.code === 'APP_NOT_INSTALLED' || + error.details?.reason === IOS_APP_LIST_UNREADABLE_REASON || + Array.isArray(error.details?.matches) + ); } export function resolveIosAppAlias(app: string): string { @@ -303,10 +322,14 @@ async function listSimulatorApps( * The installed apps an app resolution matches against. A listing `simctl` could not produce is a * failure of its own, never an empty list: reading it as one would report an installed app missing. */ +/** `details.reason` of an app resolution that failed because `simctl` produced no app list. */ +const IOS_APP_LIST_UNREADABLE_REASON = 'app-list-unreadable'; + async function requireSimulatorApps(device: DeviceInfo): Promise { const read = await readSimulatorAppMetadata(device); if (read.type === 'unreadable') { throw new AppError('COMMAND_FAILED', `Could not list the apps installed on ${device.id}`, { + reason: IOS_APP_LIST_UNREADABLE_REASON, deviceId: device.id, exitCode: read.exitCode, stderr: read.stderr, diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index ae6f242eb4..1a25d1f4d3 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -663,7 +663,11 @@ function androidLaunchablePackagesAdbResult(args: readonly string[]): AndroidAdb } function androidInstalledPackagesAdbResult(key: string): AndroidAdbResult | undefined { - if (key === 'shell pm list packages -3' || key === 'shell pm list packages') { + if ( + key === 'shell pm list packages -3' || + key === 'shell pm list packages' || + key === 'shell pm list packages --user 0' + ) { return { stdout: 'package:com.example.demo\npackage:com.example.serviceonly\n', stderr: '', From df6807242a0dfb51571c7c6499c814362c58c613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 09:27:13 +0200 Subject: [PATCH 7/7] fix(android): keep user-scoped package listings out of the name cache Address review on #3386: a `pm list packages --user ` resolution was written into the device-wide name cache, so a later default-user lookup could reuse a package absent for that user. Only device-default listings are cached now. Also retitle the alias test and move the app-list-unreadable reason above the requireSimulatorApps doc. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/app-deployment.test.ts | 21 +++++++++++++++++++ .../src/app-deployment-resolution.ts | 12 ++++++----- .../src/core/__tests__/app-resolution.test.ts | 2 +- .../platform-apple/src/core/app-resolution.ts | 6 +++--- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/platform-android/src/__tests__/app-deployment.test.ts b/packages/platform-android/src/__tests__/app-deployment.test.ts index 999888d3b1..69a499e2f1 100644 --- a/packages/platform-android/src/__tests__/app-deployment.test.ts +++ b/packages/platform-android/src/__tests__/app-deployment.test.ts @@ -289,6 +289,27 @@ test('resolveAndroidApp does not treat file paths as package names', async () => ); }); +test('resolveAndroidApp keeps a user-scoped listing out of the device-wide name cache', async () => { + await withFakeAdb( + (args) => { + const flat = args.join(' '); + if (flat === 'shell pm list packages --user 10') return 'package:com.example.secondonly'; + if (flat === 'shell pm list packages') return 'package:com.example.demo'; + return { stderr: `unexpected args: ${flat}`, exitCode: 1 }; + }, + async ({ calls, device }) => { + assert.deepEqual( + await resolveAndroidApp(device, 'secondonly', { installedOnly: true, userId: 10 }), + { type: 'package', value: 'com.example.secondonly' }, + ); + await assertRejectsAppError(() => resolveAndroidApp(device, 'secondonly'), { + code: 'APP_NOT_INSTALLED', + }); + assert.deepEqual(calls.at(-1), ['shell', 'pm', 'list', 'packages']); + }, + ); +}); + test('resolveAndroidApp caches display-name package matches but bypasses exact package ids', async () => { await withFakeAdb( (args) => { diff --git a/packages/platform-android/src/app-deployment-resolution.ts b/packages/platform-android/src/app-deployment-resolution.ts index 198211cbce..668c0b7461 100644 --- a/packages/platform-android/src/app-deployment-resolution.ts +++ b/packages/platform-android/src/app-deployment-resolution.ts @@ -71,9 +71,14 @@ export async function resolveAndroidApp( .split('\n') .map((line: string) => line.replace('package:', '').trim()) .filter(Boolean); + // The cache is device-wide, so only a device-default listing may answer a later lookup. + const remember = (resolution: AndroidAppResolution): AndroidAppResolution => + options.userId === undefined + ? androidAppResolutionCache.set(cacheScope, trimmed, resolution) + : resolution; if (packageShaped) { if (!packages.includes(trimmed)) throw androidAppNotInstalledError(app); - return androidAppResolutionCache.set(cacheScope, trimmed, { type: 'package', value: trimmed }); + return remember({ type: 'package', value: trimmed }); } const matches = packages.filter((pkg: string) => @@ -81,10 +86,7 @@ export async function resolveAndroidApp( ); const match = matches[0]; if (match !== undefined && matches.length === 1) { - return androidAppResolutionCache.set(cacheScope, trimmed, { - type: 'package', - value: match, - }); + return remember({ type: 'package', value: match }); } if (matches.length > 1) { diff --git a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts index f8823304d7..1e093f1079 100644 --- a/packages/platform-apple/src/core/__tests__/app-resolution.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-resolution.test.ts @@ -104,7 +104,7 @@ test('findIosSimulatorInstalledApp verifies exact bundle ids and app-name aliase ); }); -test('findIosSimulatorInstalledApp answers not-here for an ambiguous name or an unreadable app list', async () => { +test('findIosSimulatorInstalledApp answers not-here for an ambiguous name, an uninstalled alias target, or an unreadable app list', async () => { mockRunSimctl.mockResolvedValue({ stdout: JSON.stringify({ 'com.example.one': { CFBundleDisplayName: 'Twin' }, diff --git a/packages/platform-apple/src/core/app-resolution.ts b/packages/platform-apple/src/core/app-resolution.ts index 4cdf547c0f..79e1779bef 100644 --- a/packages/platform-apple/src/core/app-resolution.ts +++ b/packages/platform-apple/src/core/app-resolution.ts @@ -318,13 +318,13 @@ async function listSimulatorApps( })); } +/** `details.reason` of an app resolution that failed because `simctl` produced no app list. */ +const IOS_APP_LIST_UNREADABLE_REASON = 'app-list-unreadable'; + /** * The installed apps an app resolution matches against. A listing `simctl` could not produce is a * failure of its own, never an empty list: reading it as one would report an installed app missing. */ -/** `details.reason` of an app resolution that failed because `simctl` produced no app list. */ -const IOS_APP_LIST_UNREADABLE_REASON = 'app-list-unreadable'; - async function requireSimulatorApps(device: DeviceInfo): Promise { const read = await readSimulatorAppMetadata(device); if (read.type === 'unreadable') {