diff --git a/packages/registry/src/failure-codes.ts b/packages/registry/src/failure-codes.ts index 5e166c9e8..f56e929d6 100644 --- a/packages/registry/src/failure-codes.ts +++ b/packages/registry/src/failure-codes.ts @@ -82,10 +82,13 @@ export const FAILURE_CODES = { ANDROID_OPEN_URL_FAILED: "ANDROID_OPEN_URL_FAILED", ANDROID_REINSTALL_INSTALL_FAILED: "ANDROID_REINSTALL_INSTALL_FAILED", ANDROID_RESTART_FAILED: "ANDROID_RESTART_FAILED", + ANDROID_SETTINGS_PERMISSION_FAILED: "ANDROID_SETTINGS_PERMISSION_FAILED", IOS_LAUNCH_SIMCTL_FAILED: "IOS_LAUNCH_SIMCTL_FAILED", IOS_OPEN_URL_FAILED: "IOS_OPEN_URL_FAILED", IOS_REINSTALL_INSTALL_FAILED: "IOS_REINSTALL_INSTALL_FAILED", IOS_RESTART_LAUNCH_FAILED: "IOS_RESTART_LAUNCH_FAILED", + IOS_SETTINGS_PERMISSION_FAILED: "IOS_SETTINGS_PERMISSION_FAILED", + SETTINGS_PERMISSION_UNSUPPORTED: "SETTINGS_PERMISSION_UNSUPPORTED", NATIVE_DEVTOOLS_DESCRIBE_ERROR: "NATIVE_DEVTOOLS_DESCRIBE_ERROR", NATIVE_DEVTOOLS_VIEW_AT_POINT_ERROR: "NATIVE_DEVTOOLS_VIEW_AT_POINT_ERROR", NATIVE_DEVTOOLS_USER_INTERACTABLE_VIEW_AT_POINT_ERROR: diff --git a/packages/skills/rules/argent.md b/packages/skills/rules/argent.md index d4960a3d3..f7f347a98 100644 --- a/packages/skills/rules/argent.md +++ b/packages/skills/rules/argent.md @@ -112,6 +112,11 @@ TAPPING, SWIPING, TYPING, GESTURES, SCREENSHOTS, SCROLLING Skill: `argent-device-interact` When: Performing touch interactions, typing, pressing hardware buttons, launching/restarting apps, opening URLs, rotating device, taking standalone screenshots, or verifying a visible UI code change. Phone/tablet iOS and Android only — for any TV target use the TV skill below. +APP PERMISSIONS (GRANT / DENY / RESET WITHOUT THE SETTINGS UI) +Skill: `argent-settings-permissions` +When: You must change an app runtime permission (camera, microphone, photos, contacts, notifications, calendar, location, location-always, media-library, motion, reminders) that the app itself can't flip — pre-authorize or deny it before the app asks, re-enable one the user already denied (iOS never re-prompts), or reset it so the first-run dialog reappears. iOS simulator (`simctl privacy`) and Android (`pm grant`/`revoke`). Do NOT use it when the app has an in-app toggle or is showing its own permission dialog — tap that instead (see `argent-device-interact`); nor for permissions/settings outside that list. +Prompt keywords: permission, grant, deny, revoke, reset permission, privacy, camera access, location access, TCC + TV INTERACTION (APPLE TV / ANDROID TV / FIRE TV) Skill: `argent-tv-interact` When: Any TV target — a `list-devices` entry with `runtimeKind: "tv"` (Apple TV simulator or Android TV emulator) or `platform:"vega"` / `kind:"vvd"` (Amazon Fire TV / VVD), or the user mentions Apple TV / tvOS / Android TV / leanback / Vega / Fire TV. A TV UI is focus-driven, not touch-driven: drive it with `describe` (read focus) + `tv-remote` (D-pad presses) + `keyboard` (type); `gesture-*` tools do NOT apply. Covers booting the target, app lifecycle, focus navigation, typing, screenshots, and (Vega) VVD lifecycle + Fast Refresh. diff --git a/packages/skills/skills/argent-device-interact/SKILL.md b/packages/skills/skills/argent-device-interact/SKILL.md index 1f73007c9..144261596 100644 --- a/packages/skills/skills/argent-device-interact/SKILL.md +++ b/packages/skills/skills/argent-device-interact/SKILL.md @@ -216,6 +216,8 @@ When using `screenshot` for permission or native modal navigation: - Tap one control at a time and inspect the returned auto-screenshot before doing anything else. - After the modal is dismissed, return to normal discovery with `describe`, `native-describe-screen`, or `debugger-component-tree`. +> **Prefer the dialog over the Settings tool.** When the app triggers its own permission prompt, answering it here is the real user path — do that. Reach for the `settings-permissions` tool only when you can't get to the change through the app: pre-authorize/deny a permission _before_ the app asks, re-enable one the user already denied (iOS won't re-prompt), or reset it so the prompt reappears. See the `argent-settings-permissions` skill. + Optional rotation parameter: `{ "udid": "", "rotation": "LandscapeLeft" }` — rotates the capture without changing simulator orientation. Screenshots are downscaled by default (30% of original resolution) to reduce context size. Use the normal downscaled screenshot for UI context and state checks. `scale` accepts values from 0.01 to 1.0, but do not use `scale: 1.0` as a general readability or tapping aid. diff --git a/packages/skills/skills/argent-settings-permissions/SKILL.md b/packages/skills/skills/argent-settings-permissions/SKILL.md new file mode 100644 index 000000000..b6303906c --- /dev/null +++ b/packages/skills/skills/argent-settings-permissions/SKILL.md @@ -0,0 +1,131 @@ +--- +name: argent-settings-permissions +description: Grant, deny, or reset an app's runtime permissions (camera, microphone, photos, contacts, notifications, calendar, location, location-always, media-library, motion, reminders) on an iOS simulator or Android device using the argent `settings-permissions` tool - without navigating the system Settings UI. Use when the permission cannot be changed through the app itself - and only then, pre-authorize before the app asks, deny up front, re-enable a permission the user already denied, or reset so the prompt reappears. If the app can flip it - via an in-app toggle or the system permission dialog the app triggers - interact with the app instead. +--- + +## What this tool is for + +`settings-permissions` edits the platform's permission store directly - the iOS simulator's TCC database via `xcrun simctl privacy`, or Android's package-manager permission flags via `pm grant` / `pm revoke`. It replaces the manual **Settings → Privacy** dance during test setup: pre-authorize a service so the app never has to ask, deny it up front to test the refusal path, or reset it so the first-run dialog appears again on the next launch. + +It is a **test-setup / out-of-band** tool, not a general permissions toggle. The default way to change a permission is still through the app - this tool is the exception for the cases the app can't reach. + +## When to use it - and when NOT to + +Decide with this order. The first matching row wins. + +| Situation | Do this | Why | +| ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| The app has an **in-app control** for the permission (a toggle in its own settings screen) | **Tap it in the app** (`describe` → `gesture-tap`) - do NOT use this tool | It's real user behavior and exercises the flow you're testing. See `argent-device-interact`. | +| The app is **about to ask** (or just asked) and the system **permission dialog is on screen** | **Tap the dialog** (`Allow` / `Don't Allow` / `Allow While Using App`) - do NOT use this tool | The app-triggered prompt is the natural path; answering it is what a user does. `describe` exposes the dialog buttons; fall back to `screenshot` only if it doesn't. | +| You need the permission **already granted/denied before the app runs**, so no dialog interrupts the flow | **Use this tool** (`grant` / `deny`) before `launch-app` | The app can't pre-set its own permission; a real user would do it in Settings. This is the core use case. (`deny` suppresses the prompt on **iOS only** - see Gotchas.) | +| The user **already denied** it and you need it **on** again | **Use this tool** (`grant`) | iOS never re-shows a dialog once denied - the only in-device path is the Settings app. This tool is the shortcut. | +| You need the **first-run dialog to appear again** (test the prompt itself, or reset dirty state) | **Use this tool** (`reset`) | Returns the permission to "not yet asked" so the app prompts on next use. | +| The permission is **not one this tool supports** on the target platform (see the support table) | **Do NOT use this tool** | It will return an "unsupported" error. Use the app dialog if the app triggers one, or navigate the real Settings app. | +| The setting isn't one of the **11 runtime permissions** below (e.g. Wi-Fi, cellular data, dark mode, VPN, Focus) | **Do NOT use this tool** | Out of scope - drive the Settings app or the app's own UI instead. | + +**Rule of thumb:** if a human tester could flip it _inside the app_, do that. Reach for `settings-permissions` only for a change a human would otherwise make in the **system Settings app**. + +## Supported permissions & platform coverage + +| `permission` | iOS simulator (`simctl privacy` service) | Android (`android.permission.*`) | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| `camera` | `camera` - only if the target simulator's **runtime** models it (varies by simruntime, not by the installed Xcode; simulators have no camera hardware) | `CAMERA` | +| `microphone` | `microphone` | `RECORD_AUDIO` | +| `photos` | `photos` + `photos-add` (add-only access is a separate TCC service; deny/reset clear both) | `READ_MEDIA_IMAGES` + `READ_MEDIA_VIDEO` + `READ_MEDIA_VISUAL_USER_SELECTED` + `READ_EXTERNAL_STORAGE` | +| `contacts` | `contacts` | `READ_CONTACTS` + `WRITE_CONTACTS` | +| `notifications` | **unsupported** - no simctl service; answer the app's dialog instead | `POST_NOTIFICATIONS` | +| `calendar` | `calendar` | `READ_CALENDAR` + `WRITE_CALENDAR` | +| `location` | `location` | `ACCESS_FINE_LOCATION` + `ACCESS_COARSE_LOCATION` | +| `location-always` | `location-always` | `ACCESS_BACKGROUND_LOCATION` (a **grant** also adds fine + coarse - background alone can't read location) | +| `media-library` | `media-library` | `READ_MEDIA_AUDIO` + `READ_EXTERNAL_STORAGE` | +| `motion` | `motion` | `ACTIVITY_RECOGNITION` | +| `reminders` | `reminders` | **unsupported** - no Android runtime permission | + +One abstract permission can map to several concrete Android permissions; which ones actually exist depends on the app's manifest and the device's API level (e.g. `READ_MEDIA_*` on API 33+ vs `READ_EXTERNAL_STORAGE` below it). + +## Actions + +- **`grant`** - pre-authorize the permission. Requires `bundleId`. +- **`deny`** - refuse it (iOS `revoke`). Requires `bundleId`. Use to test the app's "permission denied" path. +- **`reset`** - return to the not-yet-asked state so the dialog reappears on next use. Always per-app (`bundleId` required): + - iOS: `simctl privacy reset ` removes that app's TCC row. A device-wide reset (no bundleId) is **not** offered - on recent iOS runtimes it exits 0 but leaves existing per-app grants untouched, so it would report a change that never happened. + - Android: `pm revoke` + a best-effort `pm clear-permission-flags` (the flag-clear first appears in Android 13 / API 33; the revoke is what counts toward success). On API 29-32 the flag-clear is unavailable, so a `reset` there revokes the grant but cannot clear a "don't ask again" (user-fixed) state - the dialog may stay suppressed on those older devices. + +## Parameters + +```json +{ + "udid": "", + "action": "grant", + "permission": "camera", + "bundleId": "com.example.app" +} +``` + +- `udid` - target from `list-devices` (iOS simulator UDID, or Android serial). See `argent-ios-simulator-setup` / `argent-android-emulator-setup` to get one. +- `action` - `grant` | `deny` | `reset`. +- `permission` - one of the 11 names above. +- `bundleId` - iOS bundle id or Android package name. **Required for every action**. + +## Platform behavior + +**iOS simulator only.** Runs `xcrun simctl privacy grant|revoke|reset ` - always per-app (`bundleId` required). There is no host-side TCC switch on a physical iPhone, so this tool does not apply to real iOS devices. The simulator must be **booted** first (`boot-device`) - otherwise simctl fails with a "current state: Shutdown" error and the tool surfaces the boot hint. + +**Android emulator and physical device.** Runs `pm grant` / `pm revoke` (and, for `reset`, a best-effort `pm clear-permission-flags … user-set user-fixed` - the revoke is what decides success; the flag-clear needs Android 13 / API 33+) over adb. Requirements: + +- The app must be **installed** - the tool probes with `pm list packages` first and errors clearly if the package is missing (a transport/timeout failure surfaces adb's real cause, not a false "not installed"). +- The app must **declare** the permission in its manifest. `pm` rejects any mapped permission the manifest doesn't request; those come back in the result's `skipped` list. The action succeeds if **at least one** mapped permission sticks, and errors only if `pm` rejected **all** of them. + +## Gotchas + +- **Changing a permission can terminate a running app** (system behavior on both platforms). Prefer setting permissions **before** `launch-app`; if you change one while the app is running, `restart-app` afterward. +- **Reset is per-app on both platforms** - pass `bundleId`; there is no reliable device-wide reset. +- **A partial Android result is normal.** `applied` lists what actually changed; `skipped` lists mapped permissions `pm` rejected (usually not in the manifest, or gated by API level). Both together tell you what happened. +- **A pre-launch `deny` suppresses the prompt on iOS only.** On iOS a TCC denial answers the app's request, so no dialog appears. On Android `pm revoke` clears the grant but sets no "user-fixed" flag, so the app's next request still shows the system dialog - a pre-launch `deny` there tests the revoked _state_, not a suppressed prompt. +- **`camera` on iOS** may be rejected by a simulator **runtime** that doesn't model the service (it varies by simruntime, not by the installed Xcode - a runtime can accept `camera` even when the Xcode's `simctl privacy` usage text omits it). simctl reports a rejection as a generic CoreSimulator error, so a `camera` failure (unless it's the shutdown-simulator case, which gets the boot hint instead) carries a hint to run `xcrun simctl privacy` and list the supported services. +- **`grant location` needs the app installed first (iOS).** Location authorization isn't stored in TCC and isn't applied to a bundle id until the app exists, so a pre-install `grant location` / `grant location-always` records nothing - the tool checks and errors clearly instead of reporting a false success. (TCC-backed services like `camera`/`photos` _can_ be granted before install; they persist and apply on install.) + +## Result + +Returns `{ action, permission, bundleId, applied, skipped? }`: + +- `applied` - the platform-level services/permissions actually changed (the simctl service on iOS; the `android.permission.*` names on Android). +- `skipped` - Android only, present when some mapped permissions were rejected but others succeeded. + +The call **fails** when nothing could be applied - read the error; it names the reason: an unsupported permission for the platform (`notifications` on iOS, `reminders` on Android), the app not installed (including a pre-install `grant location` on iOS), a shutdown simulator (iOS), or `pm` rejecting every mapped permission (usually a missing manifest entry). A non-shutdown `camera` failure additionally hints to list the simulator runtime's supported services (a shutdown-simulator failure gets the boot hint instead). + +## Examples + +Pre-grant the camera before launching, so the app never prompts: + +```json +{ "udid": "", "action": "grant", "permission": "camera", "bundleId": "com.example.app" } +``` + +Test the denied path - refuse location, then launch and observe the fallback: + +```json +{ "udid": "", "action": "deny", "permission": "location", "bundleId": "com.example.app" } +``` + +Reset notifications on Android so the first-run prompt appears again next launch: + +```json +{ + "udid": "", + "action": "reset", + "permission": "notifications", + "bundleId": "com.example.app" +} +``` + +Grant always-on location on Android (fans out to background + foreground automatically): + +```json +{ + "udid": "", + "action": "grant", + "permission": "location-always", + "bundleId": "com.example.app" +} +``` diff --git a/packages/skills/skills/argent-test-ui-flow/SKILL.md b/packages/skills/skills/argent-test-ui-flow/SKILL.md index 0c72b0db2..1f484ce0b 100644 --- a/packages/skills/skills/argent-test-ui-flow/SKILL.md +++ b/packages/skills/skills/argent-test-ui-flow/SKILL.md @@ -26,7 +26,7 @@ For implementation tasks that modify visible UI, this workflow can also serve as 2. **Find target**: Before tapping, use a discovery tool to get element coordinates: - **React Native apps**: use `debugger-component-tree` — it returns component names with (tap: x,y) coordinates. This is the preferred tool for RN apps on either platform. To use it, resolve the `argent-react-native-app-workflow` skill for setup; on Android you must also run `adb -s reverse tcp:8081 tcp:8081` so Metro is reachable from the device. - **Standard app screens and in-app modals**: use `describe`. On iOS this returns the AX tree (falls back to native-devtools when AX is empty); on Android it returns the uiautomator tree in the same DescribeNode shape. - - **Permission prompts / system modal overlays**: try `describe` first. Fall back to `screenshot` only if the overlay is not exposed reliably. + - **Permission prompts / system modal overlays**: try `describe` first. Fall back to `screenshot` only if the overlay is not exposed reliably. When the app raises its own permission dialog, answer it here — that's the real flow under test. To take a prompt _out_ of the flow (pre-grant/deny before launch, re-enable a permission the user already denied, or reset it so the dialog reappears), use the `argent-settings-permissions` skill during setup instead of interacting with the dialog. - **Fallback**: use `screenshot` to estimate where the desired component is, then verify immediately after the action. 3. **Interact**: Perform the action (`gesture-tap`, `gesture-swipe`, `keyboard`, `button`, ...) — you receive a screenshot automatically. 4. **Verify**: Check the returned screenshot for expected results. If it shows a loading/transitional state, prefer blocking until it settles with `await-ui-element` (expected element `visible`, or a spinner `hidden`) over a guessed delay — but only with a selector you can trust (`text`/`identifier`/`role`) that the screen is known to have or that you saw in a prior `describe`; a guessed one just times out. Otherwise use a short fixed wait. Pick evidence by what's being asserted: diff --git a/packages/tool-server/src/tools/settings-permissions/index.ts b/packages/tool-server/src/tools/settings-permissions/index.ts new file mode 100644 index 000000000..4bcbccfb8 --- /dev/null +++ b/packages/tool-server/src/tools/settings-permissions/index.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import type { ToolCapability, ToolDefinition } from "@argent/registry"; +import { dispatchByPlatform } from "../../utils/cross-platform-tool"; +import { PERMISSION_ACTIONS, PERMISSION_NAMES } from "./types"; +import type { SettingsPermissionsResult, SettingsPermissionsServices } from "./types"; +import { iosImpl, iosRemoteImpl } from "./platforms/ios"; +import { androidImpl } from "./platforms/android"; + +// Mirror launch-app / restart-app: the leading-letter rule keeps a value like +// `--user` from masquerading as a flag, and the safe alphabet keeps the value +// inert when interpolated into an `adb shell` string (shellQuote is the real +// guard; this is defense in depth). +const BUNDLE_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9._-]*$/; + +const zodSchema = z.object({ + udid: z + .string() + .min(1) + .describe("Target device id from `list-devices` (iOS simulator UDID or Android serial)."), + action: z + .enum(PERMISSION_ACTIONS) + .describe( + "`grant` pre-authorizes the permission, `deny` refuses it, `reset` returns it to the not-yet-asked state so the app prompts on next use." + ), + permission: z + .enum(PERMISSION_NAMES) + .describe( + "The permission to change. `notifications` is Android-only (iOS has no simctl service for it); `reminders` is iOS-only; `camera` works on Android and on iOS only when the target simulator's runtime models the service (varies by simruntime, not by the installed Xcode)." + ), + bundleId: z + .string() + .regex(BUNDLE_ID_PATTERN, "bundleId may only contain letters, digits, '.', '_' and '-'") + .describe( + "App to change the permission for — required for every action. iOS: bundle id (e.g. com.example.app). Android: package name. `reset` is per-app too: simctl's device-wide reset (no bundleId) silently leaves existing per-app grants untouched on recent iOS, so the permission is always reset for this one app." + ), +}); + +type Params = z.infer; + +const capability: ToolCapability = { + // `simctl privacy` edits the simulator's TCC store — physical iPhones have no + // equivalent host-side switch, so no `device: true` on apple. + apple: { simulator: true }, + // sim-remote runs the same `simctl privacy` verb on a remote simulator, so a + // sim-remote setup can pre-set permissions too — matching the rest of the + // launch-app / restart-app / reinstall-app / open-url family. + appleRemote: { simulator: true }, + android: { emulator: true, device: true, unknown: true }, +}; + +export const settingsPermissionsTool: ToolDefinition = { + id: "settings-permissions", + description: `Grant, deny, or reset a runtime permission for an app without navigating the system Settings UI. Use during test setup to pre-authorize (or explicitly deny) a service before the app asks, or \`reset\` so the permission dialog appears again on next use. Always per-app: bundleId is required. +Permissions: camera, microphone, photos, contacts, notifications, calendar, location, location-always, media-library, motion, reminders. +iOS simulator: runs \`xcrun simctl privacy grant|revoke|reset \`. \`notifications\` is not supported (no such simctl service). \`reset\` is per-app — simctl's device-wide reset (no bundleId) is a no-op for existing grants on recent iOS, so it is not offered. \`grant location\`/\`location-always\` needs the app already installed (location auth isn't stored in TCC and isn't applied to a bundle id until the app exists); other services can be granted before install. +Android: runs \`pm grant\` / \`pm revoke\` (reset also best-effort clears the user-set permission flags) on the mapped \`android.permission.*\` runtime permissions. The app must be installed and declare them in its manifest; \`reminders\` has no Android equivalent. +Some permission changes terminate the app if it is running (system behavior on both platforms) — set permissions before launching, or relaunch after. +Returns { action, permission, bundleId, applied, skipped? }: \`applied\` lists the platform-level services/permissions actually changed; \`skipped\` (Android) lists mapped permissions pm rejected, e.g. ones the manifest doesn't declare. Fails if nothing could be applied.`, + searchHint: "grant deny reset revoke app permissions privacy camera microphone location settings", + zodSchema, + capability, + services: () => ({}), + execute: dispatchByPlatform< + SettingsPermissionsServices, + SettingsPermissionsServices, + Params, + SettingsPermissionsResult + >({ + toolId: "settings-permissions", + capability, + ios: iosImpl, + iosRemote: iosRemoteImpl, + android: androidImpl, + }), +}; diff --git a/packages/tool-server/src/tools/settings-permissions/platforms/android.ts b/packages/tool-server/src/tools/settings-permissions/platforms/android.ts new file mode 100644 index 000000000..f7bb77e54 --- /dev/null +++ b/packages/tool-server/src/tools/settings-permissions/platforms/android.ts @@ -0,0 +1,251 @@ +import { FAILURE_CODES, FailureError, getFailureSignal } from "@argent/registry"; +import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { adbShell, isTerminalAdbError, shellQuote } from "../../../utils/adb"; +import type { + PermissionAction, + PermissionName, + SettingsPermissionsParams, + SettingsPermissionsResult, + SettingsPermissionsServices, +} from "../types"; + +// Tool permission → the `android.permission.*` runtime permissions it covers. +// One abstract permission fans out to several concrete ones because Android +// splits what iOS models as a single service (fine/coarse location, per-media +// read permissions) and because the concrete set shifted across API levels +// (READ_EXTERNAL_STORAGE pre-33 vs READ_MEDIA_* on 33+). The handler applies +// the action to every entry and succeeds if at least one sticks — which entry +// exists depends on the app's manifest and the device's API level, and `pm` +// itself is the authority on that. +// +// `photos` also lists READ_MEDIA_VISUAL_USER_SELECTED: on API 34+ the platform +// auto-adds this runtime permission to any app requesting READ_MEDIA_IMAGES/ +// VIDEO, and the "select photos" partial-access dialog grants it persistently. +// Omitting it would let `deny photos` report full success while the app still +// passes its partial-access check, and `reset photos` would leave the app in +// the "keep/select more" flow instead of the first-run dialog. It doesn't exist +// below API 34, where `pm` rejects it and it lands in `skipped` — expected. +// +// `reminders` is empty: iOS Reminders (EventKit) has no Android runtime +// permission, so it surfaces an unsupported error rather than silently +// no-opping. +const ANDROID_PERMISSIONS: Record = { + "camera": ["android.permission.CAMERA"], + "microphone": ["android.permission.RECORD_AUDIO"], + "photos": [ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VIDEO", + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", + "android.permission.READ_EXTERNAL_STORAGE", + ], + "contacts": ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"], + "notifications": ["android.permission.POST_NOTIFICATIONS"], + "calendar": ["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"], + "location": [ + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + ], + "location-always": ["android.permission.ACCESS_BACKGROUND_LOCATION"], + "media-library": [ + "android.permission.READ_MEDIA_AUDIO", + "android.permission.READ_EXTERNAL_STORAGE", + ], + "motion": ["android.permission.ACTIVITY_RECOGNITION"], + "reminders": [], +}; + +// Resolve the concrete permission list for a (permission, action) pair. +// `location-always` is the one action-dependent case: granting +// ACCESS_BACKGROUND_LOCATION alone leaves the app unable to read location at +// all (Android requires the foreground permissions too), while iOS's +// `location-always` grants full always-access — so a grant fans out to +// foreground + background to match the caller's intent. deny/reset stay +// background-only: taking away "always" shouldn't also strip "while in use". +function permissionsFor(permission: PermissionName, action: PermissionAction): string[] { + if (permission === "location-always" && action === "grant") { + return [...ANDROID_PERMISSIONS.location, ...ANDROID_PERMISSIONS["location-always"]]; + } + return ANDROID_PERMISSIONS[permission]; +} + +interface PmResult { + ok: boolean; + detail: string; +} + +// A pm call can fail two very different ways, and only one of them is a +// per-permission "pm rejected this" result: +// 1. pm ran and refused the permission (not declared / not changeable) — a +// manifest-style rejection that belongs in `skipped`/the aggregate error. +// 2. the adb transport itself failed — the device dropped mid-fan-out +// (unauthorized / offline / not found) or the call was killed at its +// timeout. That is not "this permission was rejected"; it means every +// remaining call is unreliable and the observed cause (a dead device, a +// wedged pm) must reach the caller, not be relabelled as a manifest gap. +// adbShell already classifies (2) into a FailureError with error_kind + +// subprocess metadata, so we detect it and let that error propagate intact +// rather than folding it into the per-permission failure shape. +function isTransportFailure(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return isTerminalAdbError(message) || getFailureSignal(err)?.error_kind === "timeout"; +} + +// pm errors arrive as a Java exception followed by a dozen `at com.android...` +// stack frames. Only the exception line says anything actionable ("Package X +// has not requested permission Y"); drop the frames so the surfaced error and +// telemetry stay readable. +function stripStackFrames(detail: string): string { + return detail + .split("\n") + .filter((line) => line.trim() && !/^\s*at\s/.test(line)) + .join(" ") + .slice(0, 500); +} + +// Run one `pm ` and classify the outcome. pm's mutating subcommands +// (grant / revoke / clear-permission-flags) are silent on success; any output +// (SecurityException, "Unknown permission", usage text after a bad argument) +// is a failure description even on builds where the exit code stays 0. A +// non-zero exit throws from runAdb and is captured the same way. +async function runPm(udid: string, pmArgs: string): Promise { + try { + const out = await adbShell(udid, `pm ${pmArgs}`, { timeoutMs: 15_000 }); + const trimmed = out.trim(); + if (trimmed && !/^Success/i.test(trimmed)) { + return { ok: false, detail: stripStackFrames(trimmed) }; + } + return { ok: true, detail: trimmed }; + } catch (err) { + // A transport/timeout failure is not a pm rejection — propagate adbShell's + // classified FailureError (error_kind + subprocess metadata) so a dead or + // wedged device surfaces its real cause instead of a bogus "manifest gap". + if (isTransportFailure(err)) throw err; + return { + ok: false, + detail: stripStackFrames(err instanceof Error ? err.message : String(err)), + }; + } +} + +export const androidImpl: PlatformImpl< + SettingsPermissionsServices, + SettingsPermissionsParams, + SettingsPermissionsResult +> = { + requires: ["adb"], + handler: async (_services, params) => { + const { udid, action, permission, bundleId } = params; + + const permissions = permissionsFor(permission, action); + if (permissions.length === 0) { + throw new FailureError( + `Permission '${permission}' has no Android runtime-permission equivalent, so there is nothing to ${action}.`, + { + error_code: FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED, + failure_stage: "android_settings_permission_map_permissions", + failure_area: "tool_server", + error_kind: "unsupported", + } + ); + } + + const pkg = shellQuote(bundleId); + + // `pm grant`/`pm revoke` silently exit 0 when the package is not installed + // (observed on API 34 and API 36), so a typo'd bundleId would otherwise + // report a false success. `pm list packages ` is the authoritative + // existence probe: it exits 0 whether or not the package exists (verified on + // API 36) and prints a `package:` line only for installed packages. + // Because a *successful* run answers both "installed" and "not installed" + // (with vs without the line), any *thrown* error here is unambiguously a + // transport / timeout / package-manager-not-yet-up failure — never a "not + // installed" verdict. Let it propagate with adb's real cause rather than + // catching it and reporting a confidently wrong "not installed" (the failure + // mode of the old `pm path` probe, which exits non-zero for a missing + // package and so could not tell the two apart). + const listing = await adbShell(udid, `pm list packages ${pkg}`, { timeoutMs: 15_000 }); + const installed = listing.split("\n").some((line) => line.trim() === `package:${bundleId}`); + if (!installed) { + throw new FailureError( + `Package ${bundleId} is not installed on ${udid} — install the app before changing its permissions.`, + { + error_code: FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED, + failure_stage: "android_settings_permission_package_missing", + failure_area: "tool_server", + error_kind: "not_found", + } + ); + } + + const applied: string[] = []; + const failures: Array<{ permission: string; detail: string }> = []; + + for (const perm of permissions) { + let result: PmResult; + if (action === "grant") { + result = await runPm(udid, `grant ${pkg} ${perm}`); + } else if (action === "deny") { + result = await runPm(udid, `revoke ${pkg} ${perm}`); + } else { + // reset = `pm revoke`, then a best-effort attempt to drop the + // user-set/user-fixed flags so the next request shows the dialog again. + // The revoke is the state-changing step and alone decides success: it is + // rejected only when pm genuinely refuses the permission (not declared / + // not a changeable type) — the manifest-style failure the aggregate + // error below describes. Clearing the flags is a refinement that must + // never demote a permission the revoke already changed: + // - `clear-permission-flags` first appears in Android 13 (API 33) — it + // is absent from android11/android12-release's PackageManagerShell- + // Command and an unknown pm subcommand exits non-zero — so coupling + // reset to it would make every reset on API < 33 falsely report + // failure. (On API 29-32 this means reset can't clear a user-fixed + // "don't ask again" state, only the grant; that ceiling is inherent + // to the platform, not something the tool can work around.) + // - and it cannot undo the revoke, so a flag-clear failure on a newer + // device still leaves the permission revoked, not "pm-rejected". + // So we run it but ignore its outcome — `result` stays the revoke's, and + // a transport error thrown by `runPm` here is swallowed for the same + // reason (the revoke already landed; don't fail a done deed). + result = await runPm(udid, `revoke ${pkg} ${perm}`); + if (result.ok) { + await runPm(udid, `clear-permission-flags ${pkg} ${perm} user-set user-fixed`).catch( + () => {} + ); + } + } + if (result.ok) { + applied.push(perm); + } else { + failures.push({ permission: perm, detail: result.detail }); + } + } + + // Partial success is expected (which concrete permission exists depends on + // manifest + API level), but zero successes means the action did nothing. + // The package is already known-installed (the preflight above proved it), so + // don't reassert that here — surface pm's own per-permission reasons, which + // also carry any transport/timeout cause verbatim, so the caller sees the + // real problem rather than a fixed manifest guess. + if (applied.length === 0) { + const details = failures.map((f) => `${f.permission}: ${f.detail}`).join("; "); + throw new FailureError( + `Failed to ${action} '${permission}' for ${bundleId} on ${udid} — every mapped runtime permission was rejected. ` + + `Usually the manifest doesn't declare it, or it isn't a runtime-changeable permission; see the per-permission detail for the exact cause. (${details})`, + { + error_code: FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED, + failure_stage: "android_settings_permission_pm", + failure_area: "tool_server", + error_kind: "subprocess", + } + ); + } + + return { + action, + permission, + bundleId, + applied, + ...(failures.length > 0 ? { skipped: failures.map((f) => f.permission) } : {}), + }; + }, +}; diff --git a/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts b/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts new file mode 100644 index 000000000..bb83b44ae --- /dev/null +++ b/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts @@ -0,0 +1,244 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { FAILURE_CODES, FailureError, subprocessFailureMetadata } from "@argent/registry"; +import type { PlatformImpl } from "../../../utils/cross-platform-tool"; +import { simctlPrivacy as remoteSimctlPrivacy } from "../../../utils/sim-remote"; +import type { + PermissionAction, + PermissionName, + SettingsPermissionsParams, + SettingsPermissionsResult, + SettingsPermissionsServices, +} from "../types"; + +const execFileAsync = promisify(execFile); + +// Tool action → `simctl privacy` action. Only the deny/revoke name differs; +// the tool says "deny" because that's the vocabulary of the permission dialog +// the agent is replacing. +const SIMCTL_ACTION: Record = { + grant: "grant", + deny: "revoke", + reset: "reset", +}; + +// Tool permission → the `simctl privacy` service(s) it covers. A LIST because a +// single tool permission can span more than one TCC service, and the first +// entry is the primary one (its failure fails the action); later entries are +// best-effort refinements. +// - `photos` covers both full-library access (`photos`, kTCCServicePhotos) and +// add-only access (`photos-add`, kTCCServicePhotosAdd), which simctl lists as +// separate services. A deny/reset that touched only `photos` would leave a +// surviving add-only grant, so photos fans out to both; `photos-add` is +// best-effort since some runtimes may not model it. +// - `notifications` is empty: notification authorization lives outside TCC, so +// it surfaces a clear unsupported error instead of a bogus simctl call. +// - `camera` support varies by simruntime (simulators have no camera hardware, +// and some runtimes don't model the service — independent of whether `simctl +// privacy`'s usage text lists it). It is passed through rather than +// pre-rejected so a runtime that accepts it works; a runtime that rejects it +// fails with a generic NSError (NSPOSIXErrorDomain, "Failed to set access" / +// "Operation not permitted") that carries no reliable "unsupported service" +// text, so the handler below keys a list-services hint off the service name +// rather than the message wording. +const IOS_SERVICES: Record = { + "camera": ["camera"], + "microphone": ["microphone"], + "photos": ["photos", "photos-add"], + "contacts": ["contacts"], + "notifications": [], + "calendar": ["calendar"], + "location": ["location"], + "location-always": ["location-always"], + "media-library": ["media-library"], + "motion": ["motion"], + "reminders": ["reminders"], +}; + +// Permissions whose authorization does NOT live in TCC.db. `location` / +// `location-always` are tracked by locationd's clients.plist, keyed on an +// *installed* app: `simctl privacy grant location ` against a +// not-yet-installed app exits 0 but records nothing, and — unlike a TCC service +// — the grant is not persisted to be applied on a later install. So a grant of +// one of these must verify the app is installed first, or the tool would report +// a success that never happened. TCC-backed services are exempt: a pre-install +// grant there is legitimately stored and applied once the app installs. +const NON_TCC_GRANT_NEEDS_INSTALL: ReadonlySet = new Set([ + "location", + "location-always", +]); + +// Runs one `simctl privacy` mutation (throwing on failure) plus an optional +// install probe. Lets a single handler serve local sims (`xcrun simctl`) and +// remote sims (`sim-remote simctl`) without an `isRemote` branch in the body. +// Only the local backend can probe install state (`simctl get_app_container`); +// sim-remote has no app-container verb, so the remote backend omits it and the +// location pre-grant guard is skipped there. +interface IosPrivacyBackend { + run(udid: string, simctlAction: string, service: string, bundleId: string): Promise; + /** + * Whether `bundleId` is installed on `udid`, when the backend can tell: + * `true` installed, `false` definitively not installed, `undefined` when the + * probe couldn't answer (e.g. a shutdown/booting simulator, where the probe + * fails for installed and missing apps alike). Omitted entirely by backends + * that can't probe (sim-remote). The location grant guard only rejects on a + * definitive `false`, so an `undefined` verdict falls through to the privacy + * call, which then surfaces the real cause (e.g. the boot-device hint). + */ + isInstalled?(udid: string, bundleId: string): Promise; +} + +const localBackend: IosPrivacyBackend = { + async run(udid, simctlAction, service, bundleId) { + await execFileAsync("xcrun", ["simctl", "privacy", udid, simctlAction, service, bundleId], { + timeout: 30_000, + }); + }, + async isInstalled(udid, bundleId) { + try { + // Exits 0 and prints the container path for an installed app. + await execFileAsync("xcrun", ["simctl", "get_app_container", udid, bundleId], { + timeout: 15_000, + }); + return true; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + // `get_app_container` needs a booted device: on a shutdown/booting sim it + // fails with "Unable to lookup in current state: Shutdown" for installed + // AND missing apps, so that error is NOT a "not installed" verdict — + // return undefined so the guard is skipped and the privacy call surfaces + // the boot hint instead of misdirecting the agent to reinstall the app. + if (/current state:/i.test(detail)) return undefined; + // Any other non-zero exit on a usable device means the app is absent + // ("No such file or directory" / "... is not installed"). + return false; + } + }, +}; + +const remoteBackend: IosPrivacyBackend = { + run: remoteSimctlPrivacy, +}; + +function buildIosHandler( + backend: IosPrivacyBackend +): PlatformImpl< + SettingsPermissionsServices, + SettingsPermissionsParams, + SettingsPermissionsResult +>["handler"] { + return async (_services, params) => { + const { udid, action, permission, bundleId } = params; + + const services = IOS_SERVICES[permission]; + if (services.length === 0) { + throw new FailureError( + `Permission '${permission}' cannot be changed on the iOS simulator — ` + + `\`xcrun simctl privacy\` has no service for it. ` + + `Interact with the notification permission dialog in the app instead.`, + { + error_code: FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED, + failure_stage: "ios_settings_permission_map_service", + failure_area: "tool_server", + error_kind: "unsupported", + } + ); + } + + // Location auth isn't stored in TCC and doesn't persist for a not-yet- + // installed app, so a pre-install grant silently records nothing. Reject it + // up front with an actionable error instead of returning a false success. + if (action === "grant" && NON_TCC_GRANT_NEEDS_INSTALL.has(permission) && backend.isInstalled) { + const installed = await backend.isInstalled(udid, bundleId); + // Only reject on a definitive "not installed"; an undefined verdict (probe + // couldn't answer, e.g. shutdown sim) falls through so the privacy call + // reports the real cause rather than a wrong "install the app" steer. + if (installed === false) { + throw new FailureError( + `Cannot grant '${permission}' to ${bundleId} on ${udid}: the app is not installed. ` + + `Location authorization isn't stored in TCC and isn't applied to a bundle id until the app ` + + `exists on the device, so a pre-install grant would silently do nothing — install the app first, then grant.`, + { + error_code: FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED, + failure_stage: "ios_settings_permission_app_not_installed", + failure_area: "tool_server", + error_kind: "not_found", + } + ); + } + } + + const applied: string[] = []; + for (let i = 0; i < services.length; i++) { + const service = services[i]!; + const isPrimary = i === 0; + try { + await backend.run(udid, SIMCTL_ACTION[action], service, bundleId); + applied.push(service); + } catch (err) { + // A secondary service (e.g. `photos-add`) that this runtime doesn't + // model must not fail the whole action — the primary already succeeded + // or will report its own failure. Skip it silently. + if (!isPrimary) continue; + const detail = err instanceof Error ? err.message : String(err); + // simctl privacy requires a booted device; its "Unable to lookup in + // current state: Shutdown" doesn't tell an agent what to do about it. + const shutdownHint = /current state:\s*shutdown/i.test(detail) + ? " The simulator must be booted first — use boot-device." + : ""; + // `camera` is the one service some simruntimes don't model (simulators + // have no camera hardware). simctl rejects an unsupported service with a + // generic NSError (NSPOSIXErrorDomain, "Failed to set access" / + // "Operation not permitted") that is indistinguishable from any other + // failure, so there is no reliable text to classify it as unsupported — + // key the hint off the service we know can be missing instead of + // parsing simctl's wording. + const cameraHint = + service === "camera" && !shutdownHint + ? " The 'camera' service isn't modeled by every simulator runtime (it varies by simruntime, not by the installed Xcode); try a different iOS runtime, or run `xcrun simctl privacy` to list the services it supports." + : ""; + throw new FailureError( + `Failed to ${action} '${permission}' on ${udid}: ${detail.trim()}${shutdownHint}${cameraHint}`, + { + error_code: FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED, + failure_stage: "ios_settings_permission_simctl_privacy", + failure_area: "tool_server", + error_kind: "subprocess", + // Both backends run the `simctl privacy` verb (local via xcrun, + // remote via sim-remote), so it's the same subprocess for telemetry. + ...subprocessFailureMetadata(err, "xcrun_simctl"), + }, + { cause: err instanceof Error ? err : new Error(String(err)) } + ); + } + } + + return { + action, + permission, + bundleId, + applied, + }; + }; +} + +export const iosImpl: PlatformImpl< + SettingsPermissionsServices, + SettingsPermissionsParams, + SettingsPermissionsResult +> = { + requires: ["xcrun"], + handler: buildIosHandler(localBackend), +}; + +// Remote analogue of `iosImpl`: routes `simctl privacy` through `sim-remote` +// instead of `xcrun`. The install probe is unavailable remotely, so the +// location pre-grant guard is skipped (the backend omits `isInstalled`). +export const iosRemoteImpl: PlatformImpl< + SettingsPermissionsServices, + SettingsPermissionsParams, + SettingsPermissionsResult +> = { + requires: ["sim-remote"], + handler: buildIosHandler(remoteBackend), +}; diff --git a/packages/tool-server/src/tools/settings-permissions/types.ts b/packages/tool-server/src/tools/settings-permissions/types.ts new file mode 100644 index 000000000..60d4953a4 --- /dev/null +++ b/packages/tool-server/src/tools/settings-permissions/types.ts @@ -0,0 +1,48 @@ +export const PERMISSION_ACTIONS = ["grant", "deny", "reset"] as const; + +export type PermissionAction = (typeof PERMISSION_ACTIONS)[number]; + +export const PERMISSION_NAMES = [ + "camera", + "microphone", + "photos", + "contacts", + "notifications", + "calendar", + "location", + "location-always", + "media-library", + "motion", + "reminders", +] as const; + +export type PermissionName = (typeof PERMISSION_NAMES)[number]; + +export interface SettingsPermissionsParams { + udid: string; + action: PermissionAction; + permission: PermissionName; + bundleId: string; +} + +export interface SettingsPermissionsResult { + action: PermissionAction; + permission: PermissionName; + bundleId: string; + /** + * The platform-level identifiers the action was actually applied to: the + * `simctl privacy` service on iOS, the `android.permission.*` names on + * Android. Lets the caller see exactly what changed (one abstract + * permission can fan out to several Android runtime permissions). + */ + applied: string[]; + /** + * Android only: mapped `android.permission.*` entries the package manager + * rejected (typically not declared in the app's manifest, or gated by the + * device's API level). Present only when at least one other mapped + * permission succeeded — if all of them fail, the tool errors instead. + */ + skipped?: string[]; +} + +export type SettingsPermissionsServices = Record; diff --git a/packages/tool-server/src/utils/adb.ts b/packages/tool-server/src/utils/adb.ts index bc6a9fa4a..5c7a25453 100644 --- a/packages/tool-server/src/utils/adb.ts +++ b/packages/tool-server/src/utils/adb.ts @@ -578,7 +578,14 @@ const TERMINAL_ADB_ERROR_PATTERNS: RegExp[] = [ /device(?: '[^']*')? offline/i, ]; -function isTerminalAdbError(message: string): boolean { +/** + * True when an adb error message names a device state no retry can fix + * (unauthorized / not found / offline / no devices). Callers that probe or + * fan out `adb` calls use this to tell a genuine transport/device failure — + * which should propagate with adb's own cause — apart from a command-level + * rejection they can classify themselves (e.g. `pm` refusing a permission). + */ +export function isTerminalAdbError(message: string): boolean { return TERMINAL_ADB_ERROR_PATTERNS.some((pattern) => pattern.test(message)); } diff --git a/packages/tool-server/src/utils/setup-registry.ts b/packages/tool-server/src/utils/setup-registry.ts index 27913be20..9db5e7a70 100644 --- a/packages/tool-server/src/utils/setup-registry.ts +++ b/packages/tool-server/src/utils/setup-registry.ts @@ -23,6 +23,7 @@ import { createBootDeviceTool } from "../tools/devices/boot-device"; import { createLaunchAppTool } from "../tools/launch-app"; import { createRestartAppTool } from "../tools/restart-app"; import { reinstallAppTool } from "../tools/reinstall-app"; +import { settingsPermissionsTool } from "../tools/settings-permissions"; import { openUrlTool } from "../tools/open-url"; import { createScreenshotTool } from "../tools/screenshot"; import { gestureTapTool } from "../tools/gesture-tap"; @@ -109,6 +110,7 @@ export function createRegistry(): Registry { registry.registerTool(createLaunchAppTool(registry)); registry.registerTool(createRestartAppTool(registry)); registry.registerTool(reinstallAppTool); + registry.registerTool(settingsPermissionsTool); registry.registerTool(openUrlTool); registry.registerTool(createScreenshotTool(registry)); registry.registerTool(screenshotDiffTool); diff --git a/packages/tool-server/src/utils/sim-remote.ts b/packages/tool-server/src/utils/sim-remote.ts index 9b6175ae9..343740da7 100644 --- a/packages/tool-server/src/utils/sim-remote.ts +++ b/packages/tool-server/src/utils/sim-remote.ts @@ -118,6 +118,21 @@ export async function simctlOpenUrl(udid: string, url: string): Promise { await run(["simctl", "openurl", stripRemotePrefix(udid), url]); } +/** + * Remote analogue of `xcrun simctl privacy ` + * — edits the remote simulator's TCC store. Throws (via `run`) on a non-zero + * exit; the settings-permissions iOS handler wraps that into its classified + * FailureError with the same boot / list-services hints as the local path. + */ +export async function simctlPrivacy( + udid: string, + action: string, + service: string, + bundleId: string +): Promise { + await run(["simctl", "privacy", stripRemotePrefix(udid), action, service, bundleId]); +} + /** Copy the given text into the simulator's pasteboard (sim-remote streams stdin). */ export async function simctlPbcopy(udid: string, text: string): Promise { await run(["simctl", "pbcopy", stripRemotePrefix(udid)], { stdin: text }); diff --git a/packages/tool-server/test/settings-permissions.test.ts b/packages/tool-server/test/settings-permissions.test.ts new file mode 100644 index 000000000..e48dda0a2 --- /dev/null +++ b/packages/tool-server/test/settings-permissions.test.ts @@ -0,0 +1,881 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const execFileMock = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { ...actual, execFile: (...args: unknown[]) => execFileMock(...args) }; +}); + +vi.mock("../src/utils/adb", async (importOriginal) => { + const actual = await importOriginal(); + return { + adbShell: vi.fn(async () => ""), + shellQuote: actual.shellQuote, + // Real classifier — the Android handler uses it to tell a transport/timeout + // failure (propagate) from a pm rejection (fold into `skipped`). + isTerminalAdbError: actual.isTerminalAdbError, + }; +}); + +import type { DeviceInfo } from "@argent/registry"; +import { + FAILURE_CODES, + FailureError, + getFailureSignal, + zodObjectToJsonSchema, +} from "@argent/registry"; +import { settingsPermissionsTool } from "../src/tools/settings-permissions"; +import { iosImpl } from "../src/tools/settings-permissions/platforms/ios"; +import { androidImpl } from "../src/tools/settings-permissions/platforms/android"; +import type { SettingsPermissionsParams } from "../src/tools/settings-permissions/types"; +import { adbShell } from "../src/utils/adb"; +import { __primeDepCacheForTests, __resetDepCacheForTests } from "../src/utils/check-deps"; + +const mockAdbShell = vi.mocked(adbShell); + +const IOS_UDID = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"; +const ANDROID_SERIAL = "emulator-5554"; + +const iosDevice: DeviceInfo = { id: IOS_UDID, platform: "ios", kind: "simulator" }; +const androidDevice: DeviceInfo = { id: ANDROID_SERIAL, platform: "android", kind: "emulator" }; + +// FailureError attaches its FailureSignal under a non-enumerable symbol, so +// toMatchObject can't see it — assert through the public accessor instead. +// The `typeof code === "string"` guard is load-bearing: if a FAILURE_CODES +// member ever resolves to `undefined` (e.g. vitest loading a stale @argent/ +// registry dist that predates a new code), the matcher would otherwise degrade +// to `undefined === undefined` and pass for *any* rejection — masking real +// failures. The string check turns that into an always-false matcher so the +// affected assertions fail loudly instead. (See the FAILURE_CODES type test.) +function failsWith(code: string): (err: unknown) => boolean { + return (err) => typeof code === "string" && getFailureSignal(err)?.error_code === code; +} + +// The promisified execFile mock: resolve = success, reject-style = call with error. +// Resolves with a `{ stdout, stderr }` object so a consumer that reads `.stdout` +// (sim-remote's `run`, used by the ios-remote branch) sees the same shape the +// real execFile custom-promisify yields; handlers that ignore the return +// (local xcrun path) are unaffected. +function execFileSucceeds(): void { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown, out?: unknown) => void) => { + cb(null, { stdout: "", stderr: "" }); + } + ); +} + +function execFileFails(message: string): void { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown) => void) => { + cb(Object.assign(new Error(message), { code: 1, stderr: message })); + } + ); +} + +// Default adb behavior: the `pm list packages` existence preflight finds the +// package (it prints a `package:` line and exits 0), and every mutating pm +// command succeeds silently (pm's real success shape). +function adbDefaults(overrides?: (cmd: string) => string | Promise | undefined): void { + mockAdbShell.mockImplementation(async (_serial, cmd) => { + const overridden = overrides?.(cmd); + if (overridden !== undefined) return overridden; + if (cmd.startsWith("pm list packages")) return "package:com.example.app"; + return ""; + }); +} + +beforeEach(() => { + execFileMock.mockReset(); + mockAdbShell.mockReset(); + adbDefaults(); +}); + +describe("settings-permissions failure codes are defined", () => { + // Guards the whole suite: `failsWith` compares against these constants, and a + // stale @argent/registry dist that predates them would resolve them to + // `undefined`, silently defanging every `failsWith` assertion. Assert they are + // real strings so a missing code fails here loudly instead of hiding elsewhere. + it("resolves the three settings-permissions codes to strings", () => { + for (const code of [ + "SETTINGS_PERMISSION_UNSUPPORTED", + "IOS_SETTINGS_PERMISSION_FAILED", + "ANDROID_SETTINGS_PERMISSION_FAILED", + ] as const) { + expect(typeof FAILURE_CODES[code], code).toBe("string"); + } + }); +}); + +describe("settings-permissions schema", () => { + const schema = settingsPermissionsTool.zodSchema!; + + it("accepts grant with a bundleId", () => { + const parsed = schema.safeParse({ + udid: IOS_UDID, + action: "grant", + permission: "camera", + bundleId: "com.example.app", + }); + expect(parsed.success).toBe(true); + }); + + it("rejects grant without a bundleId", () => { + const parsed = schema.safeParse({ udid: IOS_UDID, action: "grant", permission: "camera" }); + expect(parsed.success).toBe(false); + }); + + it("rejects deny without a bundleId", () => { + const parsed = schema.safeParse({ udid: IOS_UDID, action: "deny", permission: "photos" }); + expect(parsed.success).toBe(false); + }); + + it("rejects reset without a bundleId (bundleId is required for every action)", () => { + // Device-wide reset is gone: simctl's no-bundleId reset silently leaves + // existing per-app grants intact on recent iOS, so the tool is per-app only. + const parsed = schema.safeParse({ udid: IOS_UDID, action: "reset", permission: "location" }); + expect(parsed.success).toBe(false); + }); + + it("rejects unknown permissions and actions", () => { + expect( + schema.safeParse({ + udid: IOS_UDID, + action: "grant", + permission: "bluetooth", + bundleId: "com.example.app", + }).success + ).toBe(false); + expect( + schema.safeParse({ + udid: IOS_UDID, + action: "revoke", + permission: "camera", + bundleId: "com.example.app", + }).success + ).toBe(false); + }); + + it("rejects an empty udid", () => { + const parsed = schema.safeParse({ + udid: "", + action: "grant", + permission: "camera", + bundleId: "com.example.app", + }); + expect(parsed.success).toBe(false); + }); + + // Same attack surface as launch-app/restart-app: bundleId is interpolated + // into an `adb shell` string, so shell metacharacters must never validate. + const injectionPayloads = [ + "com.foo;rm -rf /sdcard", + "com.foo`touch /sdcard/owned`", + "com.foo$(touch /sdcard/owned)", + "com.foo && reboot", + "com.foo\nmalicious", + "com.foo'; id; echo '", + "--user", + "-X", + ]; + for (const payload of injectionPayloads) { + it(`rejects bundleId with shell metachars: ${JSON.stringify(payload)}`, () => { + const parsed = schema.safeParse({ + udid: ANDROID_SERIAL, + action: "grant", + permission: "camera", + bundleId: payload, + }); + expect(parsed.success).toBe(false); + }); + } + + it("derives a sane MCP JSON schema with bundleId required for every action", () => { + // bundleId is a plain required field now (no superRefine), so it must appear + // in the JSON schema's `required` list — pin the derivation so a zod upgrade + // can't silently drop it and let a bundleId-less call reach the handler. + const json = zodObjectToJsonSchema(schema) as { + required?: string[]; + properties?: Record; + }; + expect(json.required).toEqual(["udid", "action", "permission", "bundleId"]); + expect(json.properties?.bundleId?.pattern).toBe("^[A-Za-z_][A-Za-z0-9._-]*$"); + expect(json.properties?.action?.enum).toEqual(["grant", "deny", "reset"]); + expect(json.properties?.permission?.enum).toHaveLength(11); + }); +}); + +describe("settings-permissions iOS branch", () => { + function params(overrides: Partial): SettingsPermissionsParams { + return { + udid: IOS_UDID, + action: "grant", + permission: "microphone", + bundleId: "com.example.app", + ...overrides, + }; + } + + it("grant runs `simctl privacy grant `", async () => { + execFileSucceeds(); + const result = await iosImpl.handler({}, params({}), iosDevice); + expect(execFileMock).toHaveBeenCalledTimes(1); + const [cmd, args] = execFileMock.mock.calls[0]!; + expect(cmd).toBe("xcrun"); + expect(args).toEqual(["simctl", "privacy", IOS_UDID, "grant", "microphone", "com.example.app"]); + expect(result).toEqual({ + action: "grant", + permission: "microphone", + bundleId: "com.example.app", + applied: ["microphone"], + }); + }); + + it("deny maps to simctl's `revoke`", async () => { + execFileSucceeds(); + await iosImpl.handler({}, params({ action: "deny", permission: "photos" }), iosDevice); + const [, args] = execFileMock.mock.calls[0]!; + expect(args).toEqual(["simctl", "privacy", IOS_UDID, "revoke", "photos", "com.example.app"]); + }); + + it("reset is per-app: `simctl privacy reset `", async () => { + // A device-wide reset (no bundleId) is intentionally not supported — it is a + // no-op for existing per-app grants on recent iOS, so reset always targets + // the one app and echoes its bundleId back. + execFileSucceeds(); + const result = await iosImpl.handler( + {}, + params({ action: "reset", permission: "location-always" }), + iosDevice + ); + const [, args] = execFileMock.mock.calls[0]!; + expect(args).toEqual([ + "simctl", + "privacy", + IOS_UDID, + "reset", + "location-always", + "com.example.app", + ]); + expect(result.bundleId).toBe("com.example.app"); + }); + + it("notifications is rejected as unsupported without calling simctl", async () => { + await expect( + iosImpl.handler({}, params({ permission: "notifications" }), iosDevice) + ).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED)); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it("a camera failure surfaces IOS_SETTINGS_PERMISSION_FAILED with a list-services hint", async () => { + // simctl rejects a service it doesn't model with a generic CoreSimulator + // NSError, indistinguishable from any other failure — so instead of parsing + // its wording, camera (the one service simulators may lack) always carries a + // hint to list the supported services. + execFileFails( + "An error was encountered processing the command (domain=NSPOSIXErrorDomain, code=1):\n" + + "Simulator device failed to complete the requested operation.\nOperation not permitted\n" + + "Underlying error (domain=NSPOSIXErrorDomain, code=1):\n\tFailed to set access" + ); + const rejection = expect( + iosImpl.handler({}, params({ permission: "camera" }), iosDevice) + ).rejects; + await rejection.toSatisfy(failsWith(FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED)); + await rejection.toThrow(/run `xcrun simctl privacy` to list the services it supports/); + }); + + it("a non-camera failure does not get the camera hint", async () => { + execFileFails("Simulator device failed to complete the requested operation."); + await expect( + iosImpl.handler({}, params({ permission: "microphone" }), iosDevice) + ).rejects.not.toThrow(/list the services it supports/); + }); + + it("other simctl failures surface as IOS_SETTINGS_PERMISSION_FAILED", async () => { + execFileFails("Invalid device: nope"); + await expect(iosImpl.handler({}, params({}), iosDevice)).rejects.toSatisfy( + failsWith(FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED) + ); + }); + + it("a shutdown-simulator failure carries a boot-device hint", async () => { + execFileFails( + "An error was encountered processing the command (domain=NSCocoaErrorDomain, code=405):\nUnable to lookup in current state: Shutdown" + ); + await expect(iosImpl.handler({}, params({}), iosDevice)).rejects.toThrow( + /must be booted first — use boot-device/ + ); + }); + + it("a camera failure on a shutdown simulator gets the boot-device hint, not the camera hint", async () => { + // cameraHint is gated on `!shutdownHint`, so a shutdown error must win even + // for camera — otherwise the agent is told to list services when the real + // fix is to boot the device. Pins that guard (the camera-hint and shutdown + // tests above never pair the two). + execFileFails( + "An error was encountered processing the command (domain=NSCocoaErrorDomain, code=405):\nUnable to lookup in current state: Shutdown" + ); + const rejection = expect( + iosImpl.handler({}, params({ permission: "camera" }), iosDevice) + ).rejects; + await rejection.toThrow(/must be booted first — use boot-device/); + await rejection.not.toThrow(/list the services it supports/); + }); + + it("maps each supported permission to its simctl privacy service (identity)", async () => { + // Lock IOS_SERVICES: microphone/photos/location-always are asserted above, + // this pins the rest so an accidental empty/typo (which would surface a false + // "unsupported") is caught — notably `reminders`, the sole iOS-only service. + execFileSucceeds(); + const cases: Array<[SettingsPermissionsParams["permission"], string]> = [ + ["contacts", "contacts"], + ["calendar", "calendar"], + ["location", "location"], + ["media-library", "media-library"], + ["motion", "motion"], + ["reminders", "reminders"], + ]; + for (const [permission, service] of cases) { + execFileMock.mockClear(); + const result = await iosImpl.handler({}, params({ permission }), iosDevice); + // `location` grant runs a `get_app_container` install probe first, so the + // privacy call isn't necessarily calls[0] — find it by verb. + const privacyCall = execFileMock.mock.calls.find((c) => (c[1] as string[])[1] === "privacy"); + expect(privacyCall?.[1], permission).toEqual([ + "simctl", + "privacy", + IOS_UDID, + "grant", + service, + "com.example.app", + ]); + expect(result.applied, permission).toEqual([service]); + } + }); + + it("photos fans out to the `photos` and `photos-add` TCC services", async () => { + // add-only access lives in the separate `photos-add` service, so a deny/reset + // that touched only `photos` would leave a surviving add-only grant. Both are + // targeted; `photos-add` is best-effort (see the next test). + execFileSucceeds(); + for (const action of ["grant", "deny", "reset"] as const) { + execFileMock.mockClear(); + const result = await iosImpl.handler({}, params({ action, permission: "photos" }), iosDevice); + const services = execFileMock.mock.calls.map((c) => (c[1] as string[])[4]); + expect(services, action).toEqual(["photos", "photos-add"]); + expect(result.applied, action).toEqual(["photos", "photos-add"]); + } + }); + + it("a runtime that rejects the best-effort `photos-add` service still succeeds on `photos`", async () => { + // `photos` (the primary) must succeed; a `photos-add` this runtime doesn't + // model must be skipped silently, not fail the whole action. + execFileMock.mockImplementation( + (_cmd: string, args: string[], _opts: unknown, cb: (err: unknown, out?: string) => void) => { + if (args[4] === "photos-add") { + cb(Object.assign(new Error("Failed to set access"), { code: 1 })); + } else { + cb(null, ""); + } + } + ); + const result = await iosImpl.handler({}, params({ permission: "photos" }), iosDevice); + expect(result.applied).toEqual(["photos"]); + }); + + it("granting location to an uninstalled app fails instead of a false success", async () => { + // location auth isn't TCC-backed and doesn't persist pre-install, so a grant + // to a missing app records nothing — verify install first (get_app_container + // exits non-zero for a missing app) and reject rather than return applied. + execFileMock.mockImplementation( + (_cmd: string, args: string[], _opts: unknown, cb: (err: unknown, out?: string) => void) => { + if (args[0] === "simctl" && args[1] === "get_app_container") { + cb(Object.assign(new Error("No such file or directory"), { code: 1 })); + } else { + cb(null, ""); + } + } + ); + for (const permission of ["location", "location-always"] as const) { + const rejection = expect( + iosImpl.handler({}, params({ action: "grant", permission }), iosDevice) + ).rejects; + await rejection.toSatisfy(failsWith(FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED)); + await rejection.toThrow(/the app is not installed/); + } + // The privacy grant must never have run for the missing app. + const ranPrivacy = execFileMock.mock.calls.some((c) => (c[1] as string[])[1] === "privacy"); + expect(ranPrivacy).toBe(false); + }); + + it("granting location to an installed app runs the privacy grant", async () => { + // get_app_container succeeds (installed) → the grant proceeds normally. + execFileSucceeds(); + const result = await iosImpl.handler( + {}, + params({ action: "grant", permission: "location" }), + iosDevice + ); + expect(result.applied).toEqual(["location"]); + const privacyCall = execFileMock.mock.calls.find((c) => (c[1] as string[])[1] === "privacy"); + expect(privacyCall?.[1]).toEqual([ + "simctl", + "privacy", + IOS_UDID, + "grant", + "location", + "com.example.app", + ]); + }); + + it("denying or resetting location does NOT require the app to be installed", async () => { + // The install guard is grant-only: a deny/reset of location for a missing + // app is a harmless no-op on device, so it must not probe install state. + execFileSucceeds(); + for (const action of ["deny", "reset"] as const) { + execFileMock.mockClear(); + await iosImpl.handler({}, params({ action, permission: "location" }), iosDevice); + const probed = execFileMock.mock.calls.some( + (c) => (c[1] as string[])[1] === "get_app_container" + ); + expect(probed, action).toBe(false); + } + }); + + it("granting location on a shutdown simulator surfaces the boot hint, not a false 'not installed'", async () => { + // get_app_container needs a booted sim: on a shutdown sim it fails with + // "Unable to lookup in current state: Shutdown" for installed AND missing + // apps alike, so the install guard must NOT read that as "not installed". + // It must fall through to the privacy grant, which fails the same way and + // gets the boot hint. Regression guard: a blanket catch→false previously + // mislabeled an installed app as uninstalled on a shutdown sim and steered + // the agent to reinstall instead of boot. + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown) => void) => { + cb( + Object.assign(new Error("Unable to lookup in current state: Shutdown"), { + code: 149, + }) + ); + } + ); + const rejection = expect( + iosImpl.handler({}, params({ action: "grant", permission: "location" }), iosDevice) + ).rejects; + await rejection.toThrow(/must be booted first — use boot-device/); + await rejection.not.toThrow(/the app is not installed/); + }); +}); + +describe("settings-permissions Android branch", () => { + function params(overrides: Partial): SettingsPermissionsParams { + return { + udid: ANDROID_SERIAL, + action: "grant", + permission: "camera", + bundleId: "com.example.app", + ...overrides, + }; + } + + it("grant runs `pm grant` for the mapped permission", async () => { + const result = await androidImpl.handler({}, params({}), androidDevice); + expect(mockAdbShell).toHaveBeenCalledWith( + ANDROID_SERIAL, + "pm grant 'com.example.app' android.permission.CAMERA", + expect.anything() + ); + expect(result.applied).toEqual(["android.permission.CAMERA"]); + expect(result.skipped).toBeUndefined(); + }); + + it("deny runs `pm revoke`", async () => { + await androidImpl.handler({}, params({ action: "deny" }), androidDevice); + expect(mockAdbShell).toHaveBeenCalledWith( + ANDROID_SERIAL, + "pm revoke 'com.example.app' android.permission.CAMERA", + expect.anything() + ); + }); + + it("reset revokes and clears the user-set permission flags", async () => { + await androidImpl.handler({}, params({ action: "reset" }), androidDevice); + const commands = mockAdbShell.mock.calls.map((c) => c[1]); + expect(commands).toEqual([ + "pm list packages 'com.example.app'", + "pm revoke 'com.example.app' android.permission.CAMERA", + "pm clear-permission-flags 'com.example.app' android.permission.CAMERA user-set user-fixed", + ]); + }); + + it("reset skips clear-permission-flags when the revoke itself fails", async () => { + adbDefaults((cmd) => { + if (cmd.startsWith("pm revoke")) throw new Error("pm revoke exited with code 255"); + return undefined; + }); + await expect( + androidImpl.handler({}, params({ action: "reset" }), androidDevice) + ).rejects.toSatisfy(failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)); + const commands = mockAdbShell.mock.calls.map((c) => c[1]); + expect(commands.some((c) => c.startsWith("pm clear-permission-flags"))).toBe(false); + }); + + it("reset still counts a revoked permission whose flags could not be cleared", async () => { + // clear-permission-flags is best-effort: it first appears in Android 13 + // (API 33) and can't undo the revoke, so its failure must NOT demote a + // permission the revoke already changed. Revoke succeeded here -> applied, + // not skipped, no error (previously this reported a misleading total failure). + adbDefaults((cmd) => { + if (cmd.startsWith("pm clear-permission-flags")) { + throw new Error("pm clear-permission-flags exited with code 255"); + } + return undefined; + }); + const result = await androidImpl.handler({}, params({ action: "reset" }), androidDevice); + expect(result.applied).toEqual(["android.permission.CAMERA"]); + expect(result.skipped).toBeUndefined(); + }); + + it("a transport-level adb failure at the preflight is rethrown, not mislabeled as not-installed", async () => { + adbDefaults((cmd) => { + if (cmd.startsWith("pm list packages")) { + throw new Error("adb: device 'emulator-5554' not found"); + } + return undefined; + }); + const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects; + await rejection.toThrow(/device 'emulator-5554' not found/); + await rejection.not.toThrow(/not installed/); + }); + + it("a slow/unavailable package manager at the preflight is rethrown, not mislabeled as not-installed", async () => { + // `pm list packages` exits 0 for a missing package, so any THROW here is a + // real failure (timeout/kill, or pm not up yet right after boot) — it must + // surface the real cause, not "not installed" (the old `pm path` probe's bug). + adbDefaults((cmd) => { + if (cmd.startsWith("pm list packages")) { + throw new Error("Could not access the Package Manager. Is the system running?"); + } + return undefined; + }); + const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects; + await rejection.toThrow(/Could not access the Package Manager/); + await rejection.not.toThrow(/not installed/); + }); + + it("a package pm cannot resolve fails with not-found instead of a silent success", async () => { + // `pm list packages ` exits 0 with NO output for a missing package + // (verified on API 36), so the handler detects the absent `package:` line + // and never issues a grant/revoke (which would exit 0 for a missing package). + mockAdbShell.mockImplementation(async (_serial, cmd) => { + if (cmd.startsWith("pm list packages")) return ""; + return ""; + }); + await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy( + failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED) + ); + const commands = mockAdbShell.mock.calls.map((c) => c[1]); + expect(commands).toEqual(["pm list packages 'com.example.app'"]); + }); + + it("does not treat a substring package match as installed", async () => { + // `pm list packages` filters by substring, so a request for `com.example.app` + // can return only `com.example.app.helper`; the exact-line check must reject + // that rather than operate on the wrong package. + mockAdbShell.mockImplementation(async (_serial, cmd) => { + if (cmd.startsWith("pm list packages")) return "package:com.example.app.helper"; + return ""; + }); + await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy( + failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED) + ); + }); + + it("finds the target package among substring siblings", async () => { + // The realistic installed shape: `pm list packages ` filters by + // substring, so an installed app usually returns several lines — the target + // plus every package whose name contains it (e.g. `com.google.android.gms` + // returns both `...gms.supervision` and `...gms`). The per-line `.some(...)` + // must match the exact target line among the siblings, in any order; a switch + // to whole-output or first-line matching would read a real installed app as + // "not installed" while every other Android test stayed green. + mockAdbShell.mockImplementation(async (_serial, cmd) => { + if (cmd.startsWith("pm list packages")) { + return "package:com.example.app.helper\npackage:com.example.app\npackage:com.example.app.debug"; + } + return ""; + }); + const result = await androidImpl.handler({}, params({}), androidDevice); + expect(result.applied).toEqual(["android.permission.CAMERA"]); + expect(mockAdbShell).toHaveBeenCalledWith( + ANDROID_SERIAL, + "pm grant 'com.example.app' android.permission.CAMERA", + expect.anything() + ); + }); + + it("location fans out to fine + coarse", async () => { + const result = await androidImpl.handler({}, params({ permission: "location" }), androidDevice); + expect(result.applied).toEqual([ + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + ]); + }); + + it("granting location-always also grants the foreground location permissions", async () => { + // Background location alone is unusable on Android — the OS requires the + // foreground permissions to be granted too. + const result = await androidImpl.handler( + {}, + params({ permission: "location-always" }), + androidDevice + ); + expect(result.applied).toEqual([ + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_BACKGROUND_LOCATION", + ]); + }); + + it("denying location-always touches only the background permission", async () => { + const result = await androidImpl.handler( + {}, + params({ action: "deny", permission: "location-always" }), + androidDevice + ); + expect(result.applied).toEqual(["android.permission.ACCESS_BACKGROUND_LOCATION"]); + }); + + it("maps each abstract permission to its concrete android.permission.* set", async () => { + // Lock the ANDROID_PERMISSIONS table: camera/photos/location(-always) are + // covered above, this pins the rest so an accidental emptying/typo of a + // mapping surfaces here instead of silently granting the wrong permission or + // reporting "unsupported". Notably notifications→POST_NOTIFICATIONS (a + // primary Android use case) and the two-permission contacts/calendar/ + // media-library fan-outs, none of which had a mapping assertion. + const cases: Array<[SettingsPermissionsParams["permission"], string[]]> = [ + ["microphone", ["android.permission.RECORD_AUDIO"]], + ["contacts", ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"]], + ["notifications", ["android.permission.POST_NOTIFICATIONS"]], + ["calendar", ["android.permission.READ_CALENDAR", "android.permission.WRITE_CALENDAR"]], + [ + "media-library", + ["android.permission.READ_MEDIA_AUDIO", "android.permission.READ_EXTERNAL_STORAGE"], + ], + ["motion", ["android.permission.ACTIVITY_RECOGNITION"]], + ]; + for (const [permission, expected] of cases) { + const result = await androidImpl.handler({}, params({ permission }), androidDevice); + expect(result.applied, permission).toEqual(expected); + } + }); + + it("partial pm failures (a real device throws on exit 255) land in `skipped`, not an error", async () => { + // photos → READ_MEDIA_IMAGES ok, READ_MEDIA_VIDEO ok, READ_EXTERNAL_STORAGE + // rejected. On a real device (verified on API 34) `pm grant` for a + // non-changeable permission exits 255 and writes the SecurityException to + // stderr, so adbShell THROWS and the failure is handled in runPm's catch + // branch (not the exit-0 stdout inspection). Exercise that realistic path. + adbDefaults((cmd) => { + if (cmd.includes("READ_EXTERNAL_STORAGE")) { + throw new Error( + "adb -s emulator-5554 shell pm grant 'com.example.app' android.permission.READ_EXTERNAL_STORAGE failed: " + + "java.lang.SecurityException: Permission android.permission.READ_EXTERNAL_STORAGE requested by com.example.app is not a changeable permission type\n" + + "\tat com.android.server.pm.permission.PermissionManagerServiceImpl.grantRuntimePermissionInternal(PermissionManagerServiceImpl.java:1423)" + ); + } + return undefined; + }); + const result = await androidImpl.handler({}, params({ permission: "photos" }), androidDevice); + expect(result.applied).toEqual([ + "android.permission.READ_MEDIA_IMAGES", + "android.permission.READ_MEDIA_VIDEO", + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED", + ]); + expect(result.skipped).toEqual(["android.permission.READ_EXTERNAL_STORAGE"]); + }); + + it("photos includes READ_MEDIA_VISUAL_USER_SELECTED so partial-access is cleared", async () => { + // On API 34+ the platform auto-adds USER_SELECTED alongside READ_MEDIA_*, and + // the partial-access dialog grants it persistently. Omitting it would let + // `deny photos` leave the app passing its partial-access check. Pin that the + // mapping fans out to it (pre-34 devices reject it → it lands in `skipped`). + const result = await androidImpl.handler( + {}, + params({ action: "deny", permission: "photos" }), + androidDevice + ); + expect(result.applied).toContain("android.permission.READ_MEDIA_VISUAL_USER_SELECTED"); + expect(mockAdbShell).toHaveBeenCalledWith( + ANDROID_SERIAL, + "pm revoke 'com.example.app' android.permission.READ_MEDIA_VISUAL_USER_SELECTED", + expect.anything() + ); + }); + + it("pm rejecting every mapped permission (thrown on exit 255) raises the failure and strips Java stack frames", async () => { + // The realistic API 34+ path: pm exits 255, adbShell throws, and the message + // (built from adb's stderr) carries the exception plus its Java stack frames. + // runPm's catch branch runs stripStackFrames on the THROWN err.message. + adbDefaults((cmd) => { + if (cmd.startsWith("pm grant")) { + throw new Error( + "adb -s emulator-5554 shell pm grant 'com.example.app' android.permission.CAMERA failed: " + + "java.lang.SecurityException: Package com.example.app has not requested permission android.permission.CAMERA\n" + + "\tat com.android.server.pm.permission.PermissionManagerServiceImpl.grantRuntimePermissionInternal(PermissionManagerServiceImpl.java:1423)\n" + + "\tat android.os.Binder.execTransact(Binder.java:1275)" + ); + } + return undefined; + }); + const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects; + await rejection.toSatisfy(failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)); + await rejection.toSatisfy( + (err: unknown) => + err instanceof Error && + err.message.includes("has not requested permission") && + !err.message.includes("at com.android.server") && + !err.message.includes("at android.os.Binder") + ); + }); + + it("pm reporting a failure as exit-0 stdout is still treated as a failure", async () => { + // Some pm builds print the SecurityException to stdout and exit 0; runPm's + // stdout inspection must still classify a non-`Success` output as a failure. + adbDefaults((cmd) => + cmd.startsWith("pm grant") + ? "Exception occurred while executing 'grant':\njava.lang.SecurityException: Package com.example.app has not requested permission android.permission.CAMERA" + : undefined + ); + await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy( + failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED) + ); + }); + + it("reminders is rejected as unsupported (no Android equivalent)", async () => { + await expect( + androidImpl.handler({}, params({ permission: "reminders" }), androidDevice) + ).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED)); + expect(mockAdbShell).not.toHaveBeenCalled(); + }); + + it("a device dropping mid-fan-out propagates the transport error, not a `skipped` entry", async () => { + // If the device disconnects after one permission already applied, the + // remaining pm calls are not manifest rejections — the transport is dead. + // The handler must surface adb's real cause (a terminal "device not found"), + // not return success with the dead-device permissions in `skipped`. + adbDefaults((cmd) => { + if (cmd.includes("READ_MEDIA_VIDEO")) + throw new Error("adb: device 'emulator-5554' not found"); + return undefined; + }); + const rejection = expect( + androidImpl.handler({}, params({ permission: "photos" }), androidDevice) + ).rejects; + await rejection.toThrow(/device 'emulator-5554' not found/); + await rejection.not.toThrow(/every mapped runtime permission was rejected/); + }); + + it("a timed-out pm call propagates the classified FailureError with its telemetry intact", async () => { + // adbShell classifies a killed/timed-out call as a FailureError with + // error_kind "timeout" + subprocess metadata. A wedged device must keep that + // classification, not be relabelled as a generic manifest failure — the iOS + // branch forwards the same metadata, and sibling Android tools propagate it. + const timeoutErr = new FailureError("pm grant ... (killed=true signal=SIGKILL)", { + error_code: FAILURE_CODES.ANDROID_ADB_COMMAND_FAILED, + failure_stage: "android_adb_command", + failure_area: "tool_server", + error_kind: "timeout", + }); + adbDefaults((cmd) => { + if (cmd.startsWith("pm grant")) throw timeoutErr; + return undefined; + }); + await expect(androidImpl.handler({}, params({}), androidDevice)).rejects.toSatisfy( + (err: unknown) => err === timeoutErr && getFailureSignal(err)?.error_kind === "timeout" + ); + }); + + it("resetting location-always touches only the background permission (not fine/coarse)", async () => { + // The grant→foreground fan-out is grant-only: a reset must not also revoke + // ACCESS_FINE/COARSE_LOCATION ("taking away 'always' shouldn't strip 'while + // in use'"). Pins that the fan-out condition stays `action === "grant"` — a + // loosening to `action !== "deny"` would revoke fine+coarse here. + await androidImpl.handler( + {}, + params({ action: "reset", permission: "location-always" }), + androidDevice + ); + const commands = mockAdbShell.mock.calls.map((c) => c[1]); + expect(commands).toEqual([ + "pm list packages 'com.example.app'", + "pm revoke 'com.example.app' android.permission.ACCESS_BACKGROUND_LOCATION", + "pm clear-permission-flags 'com.example.app' android.permission.ACCESS_BACKGROUND_LOCATION user-set user-fixed", + ]); + }); +}); + +describe("settings-permissions dispatch wiring (through tool.execute)", () => { + // The per-branch tests above call iosImpl/androidImpl directly, so they can't + // catch a mis-wired dispatch table (e.g. `ios: androidImpl, android: iosImpl`, + // which typechecks and would run `pm` against iOS UDIDs). These drive the real + // `execute` with shaped udids and assert each platform reaches its OWN binary + // — xcrun for iOS, adb for Android, sim-remote for ios-remote — so any swap of + // the branches fails here. Dep cache is primed so `ensureDeps` doesn't shell + // out to `command -v` and perturb `execFileMock` call counts. + beforeEach(() => { + __resetDepCacheForTests(); + __primeDepCacheForTests(["xcrun", "adb", "sim-remote"]); + }); + + it("an iOS udid runs `xcrun simctl privacy`, never adb", async () => { + execFileSucceeds(); + const result = await settingsPermissionsTool.execute( + {}, + { udid: IOS_UDID, action: "grant", permission: "microphone", bundleId: "com.example.app" } + ); + expect(result.applied).toEqual(["microphone"]); + const [cmd, args] = execFileMock.mock.calls[0]!; + expect(cmd).toBe("xcrun"); + expect((args as string[]).slice(0, 2)).toEqual(["simctl", "privacy"]); + expect(mockAdbShell).not.toHaveBeenCalled(); + }); + + it("an Android serial runs `pm` over adb, never xcrun", async () => { + // Give execFile a resolving impl so an accidental ios/android swap fails via + // a clean assertion (wrong `applied` shape) instead of hanging on an + // unconfigured mock that never invokes its callback. + execFileSucceeds(); + const result = await settingsPermissionsTool.execute( + {}, + { udid: ANDROID_SERIAL, action: "grant", permission: "camera", bundleId: "com.example.app" } + ); + expect(result.applied).toEqual(["android.permission.CAMERA"]); + expect(mockAdbShell).toHaveBeenCalledWith( + ANDROID_SERIAL, + "pm grant 'com.example.app' android.permission.CAMERA", + expect.anything() + ); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + it("a `remote:` udid routes to the ios-remote branch (sim-remote)", async () => { + // The ios-remote branch + appleRemote capability let a sim-remote setup + // pre-set permissions, matching the launch-app / open-url family. sim-remote + // shells out via the same execFile, so assert the `sim-remote` invocation. + execFileSucceeds(); + const result = await settingsPermissionsTool.execute( + {}, + { + udid: `remote:${IOS_UDID}`, + action: "grant", + permission: "microphone", + bundleId: "com.example.app", + } + ); + expect(result.applied).toEqual(["microphone"]); + const [cmd, args] = execFileMock.mock.calls[0]!; + expect(cmd).toBe("sim-remote"); + expect((args as string[]).slice(0, 2)).toEqual(["simctl", "privacy"]); + expect(mockAdbShell).not.toHaveBeenCalled(); + }); +});