From 93d1e341afa82d91614ef545d122a980fb1f3f20 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Fri, 3 Jul 2026 10:02:15 +0200 Subject: [PATCH 1/5] feat(tool-server): settings-permissions tool to grant/deny/reset app permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `settings-permissions` MCP tool that edits an app's runtime permissions out-of-band, without navigating the system Settings UI — for test setup where a permission must be pre-authorized, denied up front, re-enabled after the user denied it (iOS never re-prompts), or reset so the first-run dialog reappears. - iOS simulator: `xcrun simctl privacy grant|revoke|reset [bundleId]`. `notifications` is unsupported (no simctl service); an "invalid service" failure (e.g. `camera` on an Xcode that doesn't model it) surfaces as unsupported with a hint; a shutdown simulator carries a boot-device hint. - Android: `pm grant`/`pm revoke` (+ `clear-permission-flags` for reset) over adb. One abstract permission fans out to the concrete `android.permission.*` set; a `pm path` preflight rejects an uninstalled package (pm grant/revoke exit 0 for missing packages); partial results land in `skipped`, and the action fails only when pm rejects every mapped permission. Export `isTerminalAdbError` so the Android preflight can tell a transport-level adb failure from a genuine "not installed" verdict. Add the ANDROID/IOS_SETTINGS_PERMISSION_FAILED and SETTINGS_PERMISSION_UNSUPPORTED failure codes, the `argent-settings-permissions` skill, and cross-references from the device-interact / test-ui-flow skills and the argent rule. --- packages/registry/src/failure-codes.ts | 3 + packages/skills/rules/argent.md | 5 + .../skills/argent-device-interact/SKILL.md | 2 + .../argent-settings-permissions/SKILL.md | 129 ++++++ .../skills/argent-test-ui-flow/SKILL.md | 2 +- .../src/tools/settings-permissions/index.ts | 81 ++++ .../settings-permissions/platforms/android.ts | 219 +++++++++ .../settings-permissions/platforms/ios.ts | 118 +++++ .../src/tools/settings-permissions/types.ts | 48 ++ packages/tool-server/src/utils/adb.ts | 10 +- .../tool-server/src/utils/setup-registry.ts | 2 + .../test/settings-permissions.test.ts | 427 ++++++++++++++++++ 12 files changed, 1044 insertions(+), 2 deletions(-) create mode 100644 packages/skills/skills/argent-settings-permissions/SKILL.md create mode 100644 packages/tool-server/src/tools/settings-permissions/index.ts create mode 100644 packages/tool-server/src/tools/settings-permissions/platforms/android.ts create mode 100644 packages/tool-server/src/tools/settings-permissions/platforms/ios.ts create mode 100644 packages/tool-server/src/tools/settings-permissions/types.ts create mode 100644 packages/tool-server/test/settings-permissions.test.ts 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..7ed923af6 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 `argent-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..eacbc65ca --- /dev/null +++ b/packages/skills/skills/argent-settings-permissions/SKILL.md @@ -0,0 +1,129 @@ +--- +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. | +| 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 installed Xcode's simctl models it (simulators have no camera hardware) | `CAMERA` | +| `microphone` | `microphone` | `RECORD_AUDIO` | +| `photos` | `photos` | `READ_MEDIA_IMAGES` + `READ_MEDIA_VIDEO` + `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. + - iOS: `bundleId` is optional; omitting it asks simctl to reset the service for **all** apps. On recent iOS runtimes a device-wide reset can leave existing per-app entries untouched — **prefer passing `bundleId`**. + - Android: `bundleId` is **required** (`pm revoke` + `pm clear-permission-flags` are per-package; there is no device-wide reset). + +## 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 `grant`/`deny` (schema-enforced) and for every action on Android. Optional only for iOS `reset`. + +## Platform behavior + +**iOS simulator only.** Runs `xcrun simctl privacy grant|revoke|reset [bundleId]`. 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`, `pm clear-permission-flags … user-set user-fixed`) over adb. Requirements: + +- The app must be **installed** — the tool probes with `pm path` first and errors clearly if the package is missing. +- 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. +- **Don't chase a device-wide reset on Android** — it's per-package only; pass `bundleId`. +- **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. +- **`camera` on iOS** may be rejected by an older Xcode whose simctl doesn't model it — the tool wraps simctl's "invalid service" error with a hint to run `xcrun simctl privacy` to list supported services. + +## 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** if nothing could be applied (unsupported permission for the platform, app not installed, or `pm` rejected everything) — read the error; it names the reason (missing manifest entry, shutdown simulator, unsupported service). + +## 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..d80c895db --- /dev/null +++ b/packages/tool-server/src/tools/settings-permissions/index.ts @@ -0,0 +1,81 @@ +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 } 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 installed Xcode's `simctl privacy` supports it." + ), + bundleId: z + .string() + .regex(BUNDLE_ID_PATTERN, "bundleId may only contain letters, digits, '.', '_' and '-'") + .optional() + .describe( + "App to change the permission for. iOS: bundle id (e.g. com.example.app). Android: package name. Required for grant/deny and for every action on Android; on iOS, `reset` without a bundleId resets the service for ALL apps — but on recent iOS runtimes simctl's device-wide reset can leave existing per-app entries untouched, so prefer passing the bundleId." + ), + }) + .superRefine((val, ctx) => { + if (val.action !== "reset" && !val.bundleId) { + ctx.addIssue({ + code: "custom", + path: ["bundleId"], + message: "bundleId is required for grant/deny — only `reset` can apply device-wide (iOS).", + }); + } + }); + +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 }, + 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. +Permissions: camera, microphone, photos, contacts, notifications, calendar, location, location-always, media-library, motion, reminders. +iOS simulator: runs \`xcrun simctl privacy\`. \`notifications\` is not supported (no such simctl service). \`reset\` with no bundleId asks simctl to reset the service for ALL apps — but on recent iOS runtimes that can leave existing per-app entries untouched, so prefer a per-app reset with bundleId. +Android: runs \`pm grant\` / \`pm revoke\` (reset also 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; bundleId is always required. +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, + 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..b1896410e --- /dev/null +++ b/packages/tool-server/src/tools/settings-permissions/platforms/android.ts @@ -0,0 +1,219 @@ +import { FAILURE_CODES, FailureError } 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. +// +// `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_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; +} + +// 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) { + 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", + } + ); + } + + // grant/deny already require bundleId at the schema level; Android reset + // needs it too (per-permission reset goes through `pm revoke` + + // `pm clear-permission-flags`, both package-scoped — there is no + // per-service reset like simctl's). + if (!bundleId) { + throw new FailureError( + `Device-wide reset is not supported on Android — pass a bundleId; the package manager only changes permissions per package.`, + { + error_code: FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED, + failure_stage: "android_settings_permission_check_bundle_id", + 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), so a typo'd bundleId would otherwise report a false + // success. `pm path` is the cheap authoritative existence probe: it prints + // `package:...` for an installed app and exits non-zero otherwise. A + // transport-level failure (device offline / unauthorized / not found) is + // NOT a "not installed" verdict — rethrow adb's own error so the caller + // sees the real cause instead of a confidently wrong diagnosis. + let installed: string; + try { + installed = await adbShell(udid, `pm path ${pkg}`, { timeoutMs: 15_000 }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (isTerminalAdbError(message)) throw err; + installed = ""; + } + if (!installed.includes("package:")) { + 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 = revoke + drop the user-set/user-fixed flags so the app is + // back to "not asked yet" and the next request shows the dialog. Both + // steps must succeed for the perm to count as reset: a revoke whose + // flags survive leaves the app "user-denied" (no dialog on next ask), + // which is a deny, not a reset — report it in `skipped`, not `applied`. + result = await runPm(udid, `revoke ${pkg} ${perm}`); + if (result.ok) { + result = await runPm(udid, `clear-permission-flags ${pkg} ${perm} user-set user-fixed`); + } + } + 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 — + // surface pm's own reasons so the caller can fix the bundleId/manifest. + 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} — pm rejected every mapped permission. ` + + `The app must be installed and declare the permission in its manifest. (${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..707376290 --- /dev/null +++ b/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts @@ -0,0 +1,118 @@ +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 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 → `simctl privacy` service. Identity for everything simctl +// models, with two exceptions: +// - `notifications` has NO simctl privacy service (notification authorization +// lives outside TCC), so it is null and surfaces a clear unsupported error. +// - `camera` is not listed by every Xcode's simctl (the simulator has no +// camera hardware). It is passed through rather than pre-rejected so an +// Xcode that does support it works; on one that doesn't, simctl's own +// "invalid service" error is wrapped with a hint below. +const IOS_SERVICE: Record = { + "camera": "camera", + "microphone": "microphone", + "photos": "photos", + "contacts": "contacts", + "notifications": null, + "calendar": "calendar", + "location": "location", + "location-always": "location-always", + "media-library": "media-library", + "motion": "motion", + "reminders": "reminders", +}; + +export const iosImpl: PlatformImpl< + SettingsPermissionsServices, + SettingsPermissionsParams, + SettingsPermissionsResult +> = { + requires: ["xcrun"], + handler: async (_services, params) => { + const { udid, action, permission, bundleId } = params; + + const service = IOS_SERVICE[permission]; + if (!service) { + 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", + } + ); + } + + const args = ["simctl", "privacy", udid, SIMCTL_ACTION[action], service]; + // `simctl privacy` requires the bundle id for grant/revoke (enforced by the + // tool schema); for reset it is optional — omitting it resets the service + // for every app on the device. + if (bundleId) args.push(bundleId); + + try { + await execFileAsync("xcrun", args, { timeout: 30_000 }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + // Distinguish "this Xcode's simctl doesn't model that service" (a + // capability gap worth naming) from an ordinary simctl failure. The + // pattern is a best-effort heuristic: the rejection text comes from a + // CoreSimulator NSError whose exact wording we could not pin on an Xcode + // that rejects a service (this host's accepts everything we map, camera + // included). A miss just falls through to the generic branch with + // simctl's own text — nothing is lost, only the nicer hint. + const unsupportedService = /invalid.*service|unknown.*service/i.test(detail); + // 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." + : ""; + throw new FailureError( + unsupportedService + ? `This Xcode's \`simctl privacy\` does not support the '${service}' service ` + + `(simctl said: ${detail.trim()}). Run \`xcrun simctl privacy\` to list the services it supports.` + : `Failed to ${action} '${permission}' on ${udid}: ${detail.trim()}${shutdownHint}`, + { + error_code: unsupportedService + ? FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED + : FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED, + failure_stage: "ios_settings_permission_simctl_privacy", + failure_area: "tool_server", + error_kind: unsupportedService ? "unsupported" : "subprocess", + ...subprocessFailureMetadata(err, "xcrun_simctl"), + }, + { cause: err instanceof Error ? err : new Error(String(err)) } + ); + } + + return { + action, + permission, + ...(bundleId ? { bundleId } : {}), + applied: [service], + }; + }, +}; 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..5f0be1a26 --- /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..83feac2c4 100644 --- a/packages/tool-server/src/utils/adb.ts +++ b/packages/tool-server/src/utils/adb.ts @@ -578,7 +578,15 @@ const TERMINAL_ADB_ERROR_PATTERNS: RegExp[] = [ /device(?: '[^']*')? offline/i, ]; -function isTerminalAdbError(message: string): boolean { +/** + * True when an adb error message names a terminal device state (unauthorized / + * not found / offline / no devices) — a transport-level condition no retry or + * alternate interpretation fixes. Exported so callers that probe the device + * (e.g. settings-permissions' `pm path` existence preflight) can distinguish + * "the command failed because the device is unreachable" from "the command ran + * and answered negatively". + */ +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/test/settings-permissions.test.ts b/packages/tool-server/test/settings-permissions.test.ts new file mode 100644 index 000000000..d1a5803fd --- /dev/null +++ b/packages/tool-server/test/settings-permissions.test.ts @@ -0,0 +1,427 @@ +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, + isTerminalAdbError: actual.isTerminalAdbError, + }; +}); + +import type { DeviceInfo } from "@argent/registry"; +import { FAILURE_CODES, 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"; + +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. +function failsWith(code: string): (err: unknown) => boolean { + return (err) => getFailureSignal(err)?.error_code === code; +} + +// The promisified execFile mock: resolve = success, reject-style = call with error. +function execFileSucceeds(): void { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown, out?: string) => void) => { + cb(null, ""); + } + ); +} + +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 path` existence preflight finds the package, +// 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 path")) return "package:/data/app/base.apk"; + return ""; + }); +} + +beforeEach(() => { + execFileMock.mockReset(); + mockAdbShell.mockReset(); + adbDefaults(); +}); + +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("accepts reset without a bundleId (device-wide reset on iOS)", () => { + const parsed = schema.safeParse({ udid: IOS_UDID, action: "reset", permission: "location" }); + expect(parsed.success).toBe(true); + }); + + 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 despite the superRefine", () => { + // This is the first tool schema built as z.object(...).superRefine(...); + // pin the derivation so a zod upgrade can't silently break the + // MCP-visible schema (zod 4 keeps refinements inside the ZodObject and + // z.toJSONSchema simply omits them). + const json = zodObjectToJsonSchema(schema) as { + required?: string[]; + properties?: Record; + }; + expect(json.required).toEqual(["udid", "action", "permission"]); + 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 without bundleId omits the bundle argument (all apps)", async () => { + execFileSucceeds(); + const result = await iosImpl.handler( + {}, + params({ action: "reset", permission: "location-always", bundleId: undefined }), + iosDevice + ); + const [, args] = execFileMock.mock.calls[0]!; + expect(args).toEqual(["simctl", "privacy", IOS_UDID, "reset", "location-always"]); + expect(result.bundleId).toBeUndefined(); + }); + + 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("an 'invalid service' simctl failure surfaces as unsupported", async () => { + execFileFails("Invalid privacy service: camera"); + await expect( + iosImpl.handler({}, params({ permission: "camera" }), iosDevice) + ).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED)); + }); + + 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/ + ); + }); +}); + +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 path '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 does not count a permission whose flags could not be cleared", async () => { + // Revoke succeeds but the flags survive: the app stays "user-denied" and + // the dialog will NOT reappear — that's a deny, not a reset. + adbDefaults((cmd) => { + if (cmd.startsWith("pm clear-permission-flags")) { + throw new Error("pm clear-permission-flags exited with code 255"); + } + return undefined; + }); + await expect( + androidImpl.handler({}, params({ action: "reset" }), androidDevice) + ).rejects.toSatisfy(failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)); + }); + + it("a transport-level adb failure at the preflight is rethrown, not mislabeled as not-installed", async () => { + adbDefaults((cmd) => { + if (cmd.startsWith("pm path")) 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 package pm cannot resolve fails with not-found instead of a silent success", async () => { + // pm grant/revoke exit 0 for an unknown package (observed on API 34), so + // the handler must catch it at the `pm path` preflight. + mockAdbShell.mockImplementation(async (_serial, cmd) => { + if (cmd.startsWith("pm path")) throw new Error("pm path exited with code 1"); + 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 path 'com.example.app'"]); + }); + + 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("partial pm failures land in `skipped`, not an error", async () => { + // photos → READ_MEDIA_IMAGES ok, READ_MEDIA_VIDEO ok, READ_EXTERNAL_STORAGE rejected + adbDefaults((cmd) => { + if (cmd.includes("READ_EXTERNAL_STORAGE")) { + return "Exception occurred while executing 'grant':\njava.lang.SecurityException: Permission android.permission.READ_EXTERNAL_STORAGE requested by com.example.app is not a changeable permission type"; + } + 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", + ]); + expect(result.skipped).toEqual(["android.permission.READ_EXTERNAL_STORAGE"]); + }); + + it("pm rejecting every mapped permission raises ANDROID_SETTINGS_PERMISSION_FAILED", async () => { + 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\n\tat com.android.server.pm.permission.PermissionManagerServiceImpl.grantRuntimePermissionInternal(PermissionManagerServiceImpl.java:1423)\n\tat android.os.Binder.execTransact(Binder.java:1275)" + : undefined + ); + const rejection = expect(androidImpl.handler({}, params({}), androidDevice)).rejects; + await rejection.toSatisfy(failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)); + // The surfaced message keeps the exception line but drops the Java stack + // frames (they'd bloat every error the agent sees). + await rejection.toSatisfy( + (err: unknown) => + err instanceof Error && + err.message.includes("has not requested permission") && + !err.message.includes("at com.android.server") + ); + }); + + it("a thrown adb error (non-zero exit) counts as a pm failure", async () => { + adbDefaults((cmd) => { + if (cmd.startsWith("pm grant")) throw new Error("pm grant exited with code 255"); + return 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("reset without bundleId is rejected on Android", async () => { + await expect( + androidImpl.handler({}, params({ action: "reset", bundleId: undefined }), androidDevice) + ).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED)); + expect(mockAdbShell).not.toHaveBeenCalled(); + }); +}); From 10d5bd1b2501d075e5f7f6aebfce7d6760f94e89 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Tue, 7 Jul 2026 12:25:08 +0200 Subject: [PATCH 2/5] fix(tool-server): address settings-permissions review comments - Android reset: clear-permission-flags is now best-effort, so a revoked permission counts as applied even when the flag-clear fails or is unavailable (pre-Android-11). The revoke alone decides success. - Android preflight: probe with `pm list packages` (exits 0 whether or not the package exists) instead of `pm path`, so a timeout, offline, or package-manager-unavailable failure surfaces its real cause instead of a false "not installed". - iOS: require bundleId for every action and drop the device-wide reset path. simctl's no-bundleId reset is a no-op for existing per-app grants on recent iOS, so it reported a change that never happened. - iOS: remove the invalid-service regex (it never matched real simctl output). A camera failure now carries a list-services hint keyed off the service name. - Tests: pm-failure cases now throw (real API 34+ exit-255 behavior) so the catch path and stack-frame stripping run on err.message. - Docs: SKILL.md updated for required bundleId, per-app reset, pm list packages, and best-effort flag-clear. --- .../argent-settings-permissions/SKILL.md | 22 +-- .../src/tools/settings-permissions/index.ts | 61 +++--- .../settings-permissions/platforms/android.ts | 67 +++---- .../settings-permissions/platforms/ios.ts | 42 ++-- .../src/tools/settings-permissions/types.ts | 4 +- packages/tool-server/src/utils/adb.ts | 7 +- .../test/settings-permissions.test.ts | 185 ++++++++++++------ 7 files changed, 219 insertions(+), 169 deletions(-) diff --git a/packages/skills/skills/argent-settings-permissions/SKILL.md b/packages/skills/skills/argent-settings-permissions/SKILL.md index eacbc65ca..06c24f5e4 100644 --- a/packages/skills/skills/argent-settings-permissions/SKILL.md +++ b/packages/skills/skills/argent-settings-permissions/SKILL.md @@ -47,9 +47,9 @@ One abstract permission can map to several concrete Android permissions; which o - **`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. - - iOS: `bundleId` is optional; omitting it asks simctl to reset the service for **all** apps. On recent iOS runtimes a device-wide reset can leave existing per-app entries untouched — **prefer passing `bundleId`**. - - Android: `bundleId` is **required** (`pm revoke` + `pm clear-permission-flags` are per-package; there is no device-wide reset). +- **`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 does not exist before Android 11; the revoke is what counts toward success). ## Parameters @@ -65,32 +65,32 @@ One abstract permission can map to several concrete Android permissions; which o - `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 `grant`/`deny` (schema-enforced) and for every action on Android. Optional only for iOS `reset`. +- `bundleId` — iOS bundle id or Android package name. **Required for every action**. ## Platform behavior -**iOS simulator only.** Runs `xcrun simctl privacy grant|revoke|reset [bundleId]`. 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. +**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`, `pm clear-permission-flags … user-set user-fixed`) over adb. Requirements: +**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) over adb. Requirements: -- The app must be **installed** — the tool probes with `pm path` first and errors clearly if the package is missing. +- 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. -- **Don't chase a device-wide reset on Android** — it's per-package only; pass `bundleId`. +- **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. -- **`camera` on iOS** may be rejected by an older Xcode whose simctl doesn't model it — the tool wraps simctl's "invalid service" error with a hint to run `xcrun simctl privacy` to list supported services. +- **`camera` on iOS** may be rejected by an Xcode whose simctl doesn't model it (simulators have no camera hardware). simctl reports this as a generic CoreSimulator error, so a `camera` failure always carries a hint to run `xcrun simctl privacy` and list the supported services. ## Result -Returns `{ action, permission, bundleId?, applied, skipped? }`: +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** if nothing could be applied (unsupported permission for the platform, app not installed, or `pm` rejected everything) — read the error; it names the reason (missing manifest entry, shutdown simulator, unsupported service). +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, a shutdown simulator (iOS), or `pm` rejecting every mapped permission (usually a missing manifest entry). A `camera` failure additionally hints to list the Xcode's supported services. ## Examples diff --git a/packages/tool-server/src/tools/settings-permissions/index.ts b/packages/tool-server/src/tools/settings-permissions/index.ts index d80c895db..510577545 100644 --- a/packages/tool-server/src/tools/settings-permissions/index.ts +++ b/packages/tool-server/src/tools/settings-permissions/index.ts @@ -12,39 +12,28 @@ import { androidImpl } from "./platforms/android"; // 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 installed Xcode's `simctl privacy` supports it." - ), - bundleId: z - .string() - .regex(BUNDLE_ID_PATTERN, "bundleId may only contain letters, digits, '.', '_' and '-'") - .optional() - .describe( - "App to change the permission for. iOS: bundle id (e.g. com.example.app). Android: package name. Required for grant/deny and for every action on Android; on iOS, `reset` without a bundleId resets the service for ALL apps — but on recent iOS runtimes simctl's device-wide reset can leave existing per-app entries untouched, so prefer passing the bundleId." - ), - }) - .superRefine((val, ctx) => { - if (val.action !== "reset" && !val.bundleId) { - ctx.addIssue({ - code: "custom", - path: ["bundleId"], - message: "bundleId is required for grant/deny — only `reset` can apply device-wide (iOS).", - }); - } - }); +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 installed Xcode's `simctl privacy` supports it." + ), + 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; @@ -57,10 +46,10 @@ const capability: ToolCapability = { 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. + 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\`. \`notifications\` is not supported (no such simctl service). \`reset\` with no bundleId asks simctl to reset the service for ALL apps — but on recent iOS runtimes that can leave existing per-app entries untouched, so prefer a per-app reset with bundleId. -Android: runs \`pm grant\` / \`pm revoke\` (reset also 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; bundleId is always required. +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. +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", diff --git a/packages/tool-server/src/tools/settings-permissions/platforms/android.ts b/packages/tool-server/src/tools/settings-permissions/platforms/android.ts index b1896410e..0931aa66d 100644 --- a/packages/tool-server/src/tools/settings-permissions/platforms/android.ts +++ b/packages/tool-server/src/tools/settings-permissions/platforms/android.ts @@ -1,6 +1,6 @@ import { FAILURE_CODES, FailureError } from "@argent/registry"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import { adbShell, isTerminalAdbError, shellQuote } from "../../../utils/adb"; +import { adbShell, shellQuote } from "../../../utils/adb"; import type { PermissionAction, PermissionName, @@ -119,40 +119,23 @@ export const androidImpl: PlatformImpl< ); } - // grant/deny already require bundleId at the schema level; Android reset - // needs it too (per-permission reset goes through `pm revoke` + - // `pm clear-permission-flags`, both package-scoped — there is no - // per-service reset like simctl's). - if (!bundleId) { - throw new FailureError( - `Device-wide reset is not supported on Android — pass a bundleId; the package manager only changes permissions per package.`, - { - error_code: FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED, - failure_stage: "android_settings_permission_check_bundle_id", - 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), so a typo'd bundleId would otherwise report a false - // success. `pm path` is the cheap authoritative existence probe: it prints - // `package:...` for an installed app and exits non-zero otherwise. A - // transport-level failure (device offline / unauthorized / not found) is - // NOT a "not installed" verdict — rethrow adb's own error so the caller - // sees the real cause instead of a confidently wrong diagnosis. - let installed: string; - try { - installed = await adbShell(udid, `pm path ${pkg}`, { timeoutMs: 15_000 }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (isTerminalAdbError(message)) throw err; - installed = ""; - } - if (!installed.includes("package:")) { + // (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.`, { @@ -174,14 +157,22 @@ export const androidImpl: PlatformImpl< } else if (action === "deny") { result = await runPm(udid, `revoke ${pkg} ${perm}`); } else { - // reset = revoke + drop the user-set/user-fixed flags so the app is - // back to "not asked yet" and the next request shows the dialog. Both - // steps must succeed for the perm to count as reset: a revoke whose - // flags survive leaves the app "user-denied" (no dialog on next ask), - // which is a deny, not a reset — report it in `skipped`, not `applied`. + // 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` does not exist before Android 11 (it is an + // unknown pm subcommand there and exits non-zero), so coupling reset + // to it would make every reset on API < 30 falsely report failure; + // - 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. result = await runPm(udid, `revoke ${pkg} ${perm}`); if (result.ok) { - result = await runPm(udid, `clear-permission-flags ${pkg} ${perm} user-set user-fixed`); + await runPm(udid, `clear-permission-flags ${pkg} ${perm} user-set user-fixed`); } } if (result.ok) { diff --git a/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts b/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts index 707376290..72501a065 100644 --- a/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts +++ b/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts @@ -67,41 +67,39 @@ export const iosImpl: PlatformImpl< ); } - const args = ["simctl", "privacy", udid, SIMCTL_ACTION[action], service]; - // `simctl privacy` requires the bundle id for grant/revoke (enforced by the - // tool schema); for reset it is optional — omitting it resets the service - // for every app on the device. - if (bundleId) args.push(bundleId); + // Always per-app (bundleId is schema-required). A device-wide reset with no + // bundleId is deliberately not offered: on recent iOS runtimes simctl exits + // 0 but leaves every existing per-app TCC row intact, so it would report a + // success that never happened. A per-app `reset ` does + // remove the row. + const args = ["simctl", "privacy", udid, SIMCTL_ACTION[action], service, bundleId]; try { await execFileAsync("xcrun", args, { timeout: 30_000 }); } catch (err) { const detail = err instanceof Error ? err.message : String(err); - // Distinguish "this Xcode's simctl doesn't model that service" (a - // capability gap worth naming) from an ordinary simctl failure. The - // pattern is a best-effort heuristic: the rejection text comes from a - // CoreSimulator NSError whose exact wording we could not pin on an Xcode - // that rejects a service (this host's accepts everything we map, camera - // included). A miss just falls through to the generic branch with - // simctl's own text — nothing is lost, only the nicer hint. - const unsupportedService = /invalid.*service|unknown.*service/i.test(detail); // 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 Xcodes don't model (simulators have no + // camera hardware). simctl rejects an unsupported service with a generic + // CoreSimulator NSError ("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 + ? " If this Xcode's `simctl privacy` does not model the 'camera' service, run `xcrun simctl privacy` to list the services it supports." + : ""; throw new FailureError( - unsupportedService - ? `This Xcode's \`simctl privacy\` does not support the '${service}' service ` + - `(simctl said: ${detail.trim()}). Run \`xcrun simctl privacy\` to list the services it supports.` - : `Failed to ${action} '${permission}' on ${udid}: ${detail.trim()}${shutdownHint}`, + `Failed to ${action} '${permission}' on ${udid}: ${detail.trim()}${shutdownHint}${cameraHint}`, { - error_code: unsupportedService - ? FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED - : FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED, + error_code: FAILURE_CODES.IOS_SETTINGS_PERMISSION_FAILED, failure_stage: "ios_settings_permission_simctl_privacy", failure_area: "tool_server", - error_kind: unsupportedService ? "unsupported" : "subprocess", + error_kind: "subprocess", ...subprocessFailureMetadata(err, "xcrun_simctl"), }, { cause: err instanceof Error ? err : new Error(String(err)) } @@ -111,7 +109,7 @@ export const iosImpl: PlatformImpl< return { action, permission, - ...(bundleId ? { bundleId } : {}), + bundleId, applied: [service], }; }, diff --git a/packages/tool-server/src/tools/settings-permissions/types.ts b/packages/tool-server/src/tools/settings-permissions/types.ts index 5f0be1a26..60d4953a4 100644 --- a/packages/tool-server/src/tools/settings-permissions/types.ts +++ b/packages/tool-server/src/tools/settings-permissions/types.ts @@ -22,13 +22,13 @@ export interface SettingsPermissionsParams { udid: string; action: PermissionAction; permission: PermissionName; - bundleId?: string; + bundleId: string; } export interface SettingsPermissionsResult { action: PermissionAction; permission: PermissionName; - bundleId?: string; + bundleId: string; /** * The platform-level identifiers the action was actually applied to: the * `simctl privacy` service on iOS, the `android.permission.*` names on diff --git a/packages/tool-server/src/utils/adb.ts b/packages/tool-server/src/utils/adb.ts index 83feac2c4..bb2a5e360 100644 --- a/packages/tool-server/src/utils/adb.ts +++ b/packages/tool-server/src/utils/adb.ts @@ -581,10 +581,9 @@ const TERMINAL_ADB_ERROR_PATTERNS: RegExp[] = [ /** * True when an adb error message names a terminal device state (unauthorized / * not found / offline / no devices) — a transport-level condition no retry or - * alternate interpretation fixes. Exported so callers that probe the device - * (e.g. settings-permissions' `pm path` existence preflight) can distinguish - * "the command failed because the device is unreachable" from "the command ran - * and answered negatively". + * alternate interpretation fixes. Exported so callers that probe a device can + * distinguish "the command failed because the device is unreachable" from "the + * command ran and answered negatively". */ export function isTerminalAdbError(message: string): boolean { return TERMINAL_ADB_ERROR_PATTERNS.some((pattern) => pattern.test(message)); diff --git a/packages/tool-server/test/settings-permissions.test.ts b/packages/tool-server/test/settings-permissions.test.ts index d1a5803fd..660ab0e07 100644 --- a/packages/tool-server/test/settings-permissions.test.ts +++ b/packages/tool-server/test/settings-permissions.test.ts @@ -11,7 +11,6 @@ vi.mock("../src/utils/adb", async (importOriginal) => { return { adbShell: vi.fn(async () => ""), shellQuote: actual.shellQuote, - isTerminalAdbError: actual.isTerminalAdbError, }; }); @@ -54,13 +53,14 @@ function execFileFails(message: string): void { ); } -// Default adb behavior: the `pm path` existence preflight finds the package, -// and every mutating pm command succeeds silently (pm's real success shape). +// 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 path")) return "package:/data/app/base.apk"; + if (cmd.startsWith("pm list packages")) return "package:com.example.app"; return ""; }); } @@ -94,9 +94,11 @@ describe("settings-permissions schema", () => { expect(parsed.success).toBe(false); }); - it("accepts reset without a bundleId (device-wide reset on iOS)", () => { + 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(true); + expect(parsed.success).toBe(false); }); it("rejects unknown permissions and actions", () => { @@ -152,16 +154,15 @@ describe("settings-permissions schema", () => { }); } - it("derives a sane MCP JSON schema despite the superRefine", () => { - // This is the first tool schema built as z.object(...).superRefine(...); - // pin the derivation so a zod upgrade can't silently break the - // MCP-visible schema (zod 4 keeps refinements inside the ZodObject and - // z.toJSONSchema simply omits them). + 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"]); + 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); @@ -201,16 +202,26 @@ describe("settings-permissions iOS branch", () => { expect(args).toEqual(["simctl", "privacy", IOS_UDID, "revoke", "photos", "com.example.app"]); }); - it("reset without bundleId omits the bundle argument (all apps)", async () => { + 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", bundleId: undefined }), + params({ action: "reset", permission: "location-always" }), iosDevice ); const [, args] = execFileMock.mock.calls[0]!; - expect(args).toEqual(["simctl", "privacy", IOS_UDID, "reset", "location-always"]); - expect(result.bundleId).toBeUndefined(); + 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 () => { @@ -220,11 +231,28 @@ describe("settings-permissions iOS branch", () => { expect(execFileMock).not.toHaveBeenCalled(); }); - it("an 'invalid service' simctl failure surfaces as unsupported", async () => { - execFileFails("Invalid privacy service: camera"); - await expect( + 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.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED)); + ).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 () => { @@ -279,7 +307,7 @@ describe("settings-permissions Android branch", () => { await androidImpl.handler({}, params({ action: "reset" }), androidDevice); const commands = mockAdbShell.mock.calls.map((c) => c[1]); expect(commands).toEqual([ - "pm path 'com.example.app'", + "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", ]); @@ -297,23 +325,27 @@ describe("settings-permissions Android branch", () => { expect(commands.some((c) => c.startsWith("pm clear-permission-flags"))).toBe(false); }); - it("reset does not count a permission whose flags could not be cleared", async () => { - // Revoke succeeds but the flags survive: the app stays "user-denied" and - // the dialog will NOT reappear — that's a deny, not a reset. + it("reset still counts a revoked permission whose flags could not be cleared", async () => { + // clear-permission-flags is best-effort: it doesn't exist before Android 11 + // 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; }); - await expect( - androidImpl.handler({}, params({ action: "reset" }), androidDevice) - ).rejects.toSatisfy(failsWith(FAILURE_CODES.ANDROID_SETTINGS_PERMISSION_FAILED)); + 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 path")) throw new Error("adb: device 'emulator-5554' not found"); + 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; @@ -321,18 +353,47 @@ describe("settings-permissions Android branch", () => { 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 grant/revoke exit 0 for an unknown package (observed on API 34), so - // the handler must catch it at the `pm path` preflight. + // `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 path")) throw new Error("pm path exited with code 1"); + 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 path 'com.example.app'"]); + 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("location fans out to fine + coarse", async () => { @@ -367,11 +428,19 @@ describe("settings-permissions Android branch", () => { expect(result.applied).toEqual(["android.permission.ACCESS_BACKGROUND_LOCATION"]); }); - it("partial pm failures land in `skipped`, not an error", async () => { - // photos → READ_MEDIA_IMAGES ok, READ_MEDIA_VIDEO ok, READ_EXTERNAL_STORAGE rejected + 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")) { - return "Exception occurred while executing 'grant':\njava.lang.SecurityException: Permission android.permission.READ_EXTERNAL_STORAGE requested by com.example.app is not a changeable permission type"; + 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; }); @@ -383,29 +452,40 @@ describe("settings-permissions Android branch", () => { expect(result.skipped).toEqual(["android.permission.READ_EXTERNAL_STORAGE"]); }); - it("pm rejecting every mapped permission raises ANDROID_SETTINGS_PERMISSION_FAILED", async () => { - 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\n\tat com.android.server.pm.permission.PermissionManagerServiceImpl.grantRuntimePermissionInternal(PermissionManagerServiceImpl.java:1423)\n\tat android.os.Binder.execTransact(Binder.java:1275)" - : undefined - ); + 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)); - // The surfaced message keeps the exception line but drops the Java stack - // frames (they'd bloat every error the agent sees). 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 com.android.server") && + !err.message.includes("at android.os.Binder") ); }); - it("a thrown adb error (non-zero exit) counts as a pm failure", async () => { - adbDefaults((cmd) => { - if (cmd.startsWith("pm grant")) throw new Error("pm grant exited with code 255"); - return undefined; - }); + 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) ); @@ -417,11 +497,4 @@ describe("settings-permissions Android branch", () => { ).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED)); expect(mockAdbShell).not.toHaveBeenCalled(); }); - - it("reset without bundleId is rejected on Android", async () => { - await expect( - androidImpl.handler({}, params({ action: "reset", bundleId: undefined }), androidDevice) - ).rejects.toSatisfy(failsWith(FAILURE_CODES.SETTINGS_PERMISSION_UNSUPPORTED)); - expect(mockAdbShell).not.toHaveBeenCalled(); - }); }); From e00cd0a07c10495921795ef2ae08799512a19f22 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Tue, 7 Jul 2026 15:21:26 +0200 Subject: [PATCH 3/5] fix(tool-server): address second-round settings-permissions review - adb: revert isTerminalAdbError to module-private. The pm-list-packages preflight lets adb errors propagate unchanged and never classifies them, so the widened export had no consumer. - ios: correct the camera-service comments. A rejected simctl service fails with a generic NSPOSIXErrorDomain NSError ("Failed to set access" / "Operation not permitted"), not an "invalid service" string; the hint keys off the service name, and support varies by simruntime independent of the usage listing. - android: the zero-success error no longer claims "must be installed" (the preflight already proved it) nor pins the cause to the manifest. The per-permission detail carries the real reason (incl. transport/timeout). - tests: add the target-among-substring-siblings preflight case, table-driven Android/iOS mapping assertions (incl. notifications and iOS-only reminders), and the camera+shutdown hint-precedence case. --- .../settings-permissions/platforms/android.ts | 11 ++- .../settings-permissions/platforms/ios.ts | 25 +++-- packages/tool-server/src/utils/adb.ts | 9 +- .../test/settings-permissions.test.ts | 91 +++++++++++++++++++ 4 files changed, 114 insertions(+), 22 deletions(-) diff --git a/packages/tool-server/src/tools/settings-permissions/platforms/android.ts b/packages/tool-server/src/tools/settings-permissions/platforms/android.ts index 0931aa66d..db564c745 100644 --- a/packages/tool-server/src/tools/settings-permissions/platforms/android.ts +++ b/packages/tool-server/src/tools/settings-permissions/platforms/android.ts @@ -183,13 +183,16 @@ export const androidImpl: PlatformImpl< } // Partial success is expected (which concrete permission exists depends on - // manifest + API level), but zero successes means the action did nothing — - // surface pm's own reasons so the caller can fix the bundleId/manifest. + // 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} — pm rejected every mapped permission. ` + - `The app must be installed and declare the permission in its manifest. (${details})`, + `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", diff --git a/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts b/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts index 72501a065..ff5011cc4 100644 --- a/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts +++ b/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts @@ -25,10 +25,14 @@ const SIMCTL_ACTION: Record = { // models, with two exceptions: // - `notifications` has NO simctl privacy service (notification authorization // lives outside TCC), so it is null and surfaces a clear unsupported error. -// - `camera` is not listed by every Xcode's simctl (the simulator has no -// camera hardware). It is passed through rather than pre-rejected so an -// Xcode that does support it works; on one that doesn't, simctl's own -// "invalid service" error is wrapped with a hint below. +// - `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_SERVICE: Record = { "camera": "camera", "microphone": "microphone", @@ -83,12 +87,13 @@ export const iosImpl: PlatformImpl< const shutdownHint = /current state:\s*shutdown/i.test(detail) ? " The simulator must be booted first — use boot-device." : ""; - // `camera` is the one service some Xcodes don't model (simulators have no - // camera hardware). simctl rejects an unsupported service with a generic - // CoreSimulator NSError ("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. + // `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 ? " If this Xcode's `simctl privacy` does not model the 'camera' service, run `xcrun simctl privacy` to list the services it supports." diff --git a/packages/tool-server/src/utils/adb.ts b/packages/tool-server/src/utils/adb.ts index bb2a5e360..bc6a9fa4a 100644 --- a/packages/tool-server/src/utils/adb.ts +++ b/packages/tool-server/src/utils/adb.ts @@ -578,14 +578,7 @@ const TERMINAL_ADB_ERROR_PATTERNS: RegExp[] = [ /device(?: '[^']*')? offline/i, ]; -/** - * True when an adb error message names a terminal device state (unauthorized / - * not found / offline / no devices) — a transport-level condition no retry or - * alternate interpretation fixes. Exported so callers that probe a device can - * distinguish "the command failed because the device is unreachable" from "the - * command ran and answered negatively". - */ -export function isTerminalAdbError(message: string): boolean { +function isTerminalAdbError(message: string): boolean { return TERMINAL_ADB_ERROR_PATTERNS.some((pattern) => pattern.test(message)); } diff --git a/packages/tool-server/test/settings-permissions.test.ts b/packages/tool-server/test/settings-permissions.test.ts index 660ab0e07..f1f4eee4d 100644 --- a/packages/tool-server/test/settings-permissions.test.ts +++ b/packages/tool-server/test/settings-permissions.test.ts @@ -270,6 +270,50 @@ describe("settings-permissions iOS branch", () => { /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_SERVICE: microphone/photos/location-always are asserted above, + // this pins the rest so an accidental null/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); + const [, args] = execFileMock.mock.calls[0]!; + expect(args, permission).toEqual([ + "simctl", + "privacy", + IOS_UDID, + "grant", + service, + "com.example.app", + ]); + expect(result.applied, permission).toEqual([service]); + } + }); }); describe("settings-permissions Android branch", () => { @@ -396,6 +440,29 @@ describe("settings-permissions Android branch", () => { ); }); + 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([ @@ -428,6 +495,30 @@ describe("settings-permissions Android branch", () => { 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 From b1b53c2d33280f6df78b9315d70e38362d508111 Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Wed, 8 Jul 2026 12:17:39 +0200 Subject: [PATCH 4/5] fix(tool-server): address third-round settings-permissions review - iOS photos fans out to both `photos` and `photos-add` TCC services so deny/reset also clear the add-only grant (`photos-add` is best-effort per runtime). iOS handler refactored into a backend-injected factory serving local (`xcrun`) and remote (`sim-remote`) sims. - Reject a pre-install `grant location`/`location-always` on iOS: location auth isn't TCC-backed and doesn't persist before install, so it would silently record nothing. The install probe distinguishes a genuinely missing app from a shutdown/booting sim (which get_app_container can't answer) so the boot hint still wins there. - ios-remote support: `appleRemote` capability + `iosRemote` dispatch branch and a `simctlPrivacy` sim-remote verb, matching the launch-app family. - Android `photos` now includes READ_MEDIA_VISUAL_USER_SELECTED so deny/reset clear the API 34+ partial-access grant. - Android runPm propagates transport/timeout adb failures (device dropped mid-fan-out, killed calls) with adb's classified FailureError intact instead of folding them into a bogus per-permission "manifest" failure; best-effort clear-permission-flags never demotes a completed revoke. - Correct the clear-permission-flags availability to Android 13 / API 33. - Tests: dispatch wiring through `execute` (catches an ios/android swap), FAILURE_CODES-are-strings guard, reset location-always, iOS photos fan-out, location install guard + shutdown-sim regression, transport/timeout paths. - Docs: camera availability attributed to the simruntime (not Xcode); Android `deny` keeps the dialog unlike iOS; camera hint is conditional (suppressed on a shutdown sim); location-needs-install note; device-interact tool-name fix. --- .../skills/argent-device-interact/SKILL.md | 2 +- .../argent-settings-permissions/SKILL.md | 53 +-- .../src/tools/settings-permissions/index.ts | 11 +- .../settings-permissions/platforms/android.ts | 52 ++- .../settings-permissions/platforms/ios.ts | 243 ++++++++++---- packages/tool-server/src/utils/adb.ts | 9 +- packages/tool-server/src/utils/sim-remote.ts | 15 + .../test/settings-permissions.test.ts | 314 +++++++++++++++++- 8 files changed, 589 insertions(+), 110 deletions(-) diff --git a/packages/skills/skills/argent-device-interact/SKILL.md b/packages/skills/skills/argent-device-interact/SKILL.md index 7ed923af6..144261596 100644 --- a/packages/skills/skills/argent-device-interact/SKILL.md +++ b/packages/skills/skills/argent-device-interact/SKILL.md @@ -216,7 +216,7 @@ 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 `argent-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. +> **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. diff --git a/packages/skills/skills/argent-settings-permissions/SKILL.md b/packages/skills/skills/argent-settings-permissions/SKILL.md index 06c24f5e4..89f1fd776 100644 --- a/packages/skills/skills/argent-settings-permissions/SKILL.md +++ b/packages/skills/skills/argent-settings-permissions/SKILL.md @@ -13,33 +13,33 @@ It is a **test-setup / out-of-band** tool, not a general permissions toggle. The 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. | -| 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. | +| 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. **Caveat for `deny`:** it keeps the dialog out of the flow on **iOS** (a TCC denial answers the request). On **Android** `pm revoke` clears the grant but sets no "user-fixed" flag, so the app's next request still shows the system dialog — pre-deny there tests the revoked _state_, not a suppressed prompt. | +| 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 installed Xcode's simctl models it (simulators have no camera hardware) | `CAMERA` | -| `microphone` | `microphone` | `RECORD_AUDIO` | -| `photos` | `photos` | `READ_MEDIA_IMAGES` + `READ_MEDIA_VIDEO` + `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 | +| `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). @@ -49,7 +49,7 @@ One abstract permission can map to several concrete Android permissions; which o - **`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 does not exist before Android 11; the revoke is what counts toward success). + - 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 @@ -71,7 +71,7 @@ One abstract permission can map to several concrete Android permissions; which o **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) over adb. Requirements: +**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. @@ -81,7 +81,8 @@ One abstract permission can map to several concrete Android permissions; which o - **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. -- **`camera` on iOS** may be rejected by an Xcode whose simctl doesn't model it (simulators have no camera hardware). simctl reports this as a generic CoreSimulator error, so a `camera` failure always carries a hint to run `xcrun simctl privacy` and list the supported services. +- **`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 @@ -90,7 +91,7 @@ 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, a shutdown simulator (iOS), or `pm` rejecting every mapped permission (usually a missing manifest entry). A `camera` failure additionally hints to list the Xcode's supported services. +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 diff --git a/packages/tool-server/src/tools/settings-permissions/index.ts b/packages/tool-server/src/tools/settings-permissions/index.ts index 510577545..4bcbccfb8 100644 --- a/packages/tool-server/src/tools/settings-permissions/index.ts +++ b/packages/tool-server/src/tools/settings-permissions/index.ts @@ -3,7 +3,7 @@ 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 } from "./platforms/ios"; +import { iosImpl, iosRemoteImpl } from "./platforms/ios"; import { androidImpl } from "./platforms/android"; // Mirror launch-app / restart-app: the leading-letter rule keeps a value like @@ -25,7 +25,7 @@ const zodSchema = z.object({ 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 installed Xcode's `simctl privacy` supports it." + "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() @@ -41,6 +41,10 @@ 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 }, }; @@ -48,7 +52,7 @@ export const settingsPermissionsTool: ToolDefinition 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. +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.`, @@ -65,6 +69,7 @@ Returns { action, permission, bundleId, applied, skipped? }: \`applied\` lists t 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 index db564c745..f7bb77e54 100644 --- a/packages/tool-server/src/tools/settings-permissions/platforms/android.ts +++ b/packages/tool-server/src/tools/settings-permissions/platforms/android.ts @@ -1,6 +1,6 @@ -import { FAILURE_CODES, FailureError } from "@argent/registry"; +import { FAILURE_CODES, FailureError, getFailureSignal } from "@argent/registry"; import type { PlatformImpl } from "../../../utils/cross-platform-tool"; -import { adbShell, shellQuote } from "../../../utils/adb"; +import { adbShell, isTerminalAdbError, shellQuote } from "../../../utils/adb"; import type { PermissionAction, PermissionName, @@ -18,6 +18,14 @@ import type { // 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. @@ -27,6 +35,7 @@ const ANDROID_PERMISSIONS: Record = { "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"], @@ -64,6 +73,23 @@ interface PmResult { 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 @@ -90,6 +116,10 @@ async function runPm(udid: string, pmArgs: string): Promise { } 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)), @@ -164,15 +194,23 @@ export const androidImpl: PlatformImpl< // 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` does not exist before Android 11 (it is an - // unknown pm subcommand there and exits non-zero), so coupling reset - // to it would make every reset on API < 30 falsely report failure; + // - `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. + // 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`); + await runPm(udid, `clear-permission-flags ${pkg} ${perm} user-set user-fixed`).catch( + () => {} + ); } } if (result.ok) { diff --git a/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts b/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts index ff5011cc4..bb83b44ae 100644 --- a/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts +++ b/packages/tool-server/src/tools/settings-permissions/platforms/ios.ts @@ -2,6 +2,7 @@ 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, @@ -21,10 +22,17 @@ const SIMCTL_ACTION: Record = { reset: "reset", }; -// Tool permission → `simctl privacy` service. Identity for everything simctl -// models, with two exceptions: -// - `notifications` has NO simctl privacy service (notification authorization -// lives outside TCC), so it is null and surfaces a clear unsupported error. +// 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 @@ -33,31 +41,97 @@ const SIMCTL_ACTION: Record = { // "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_SERVICE: Record = { - "camera": "camera", - "microphone": "microphone", - "photos": "photos", - "contacts": "contacts", - "notifications": null, - "calendar": "calendar", - "location": "location", - "location-always": "location-always", - "media-library": "media-library", - "motion": "motion", - "reminders": "reminders", +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"], }; -export const iosImpl: PlatformImpl< +// 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 -> = { - requires: ["xcrun"], - handler: async (_services, params) => { +>["handler"] { + return async (_services, params) => { const { udid, action, permission, bundleId } = params; - const service = IOS_SERVICE[permission]; - if (!service) { + 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. ` + @@ -71,51 +145,100 @@ export const iosImpl: PlatformImpl< ); } - // Always per-app (bundleId is schema-required). A device-wide reset with no - // bundleId is deliberately not offered: on recent iOS runtimes simctl exits - // 0 but leaves every existing per-app TCC row intact, so it would report a - // success that never happened. A per-app `reset ` does - // remove the row. - const args = ["simctl", "privacy", udid, SIMCTL_ACTION[action], service, bundleId]; + // 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", + } + ); + } + } - try { - await execFileAsync("xcrun", args, { timeout: 30_000 }); - } catch (err) { - 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 - ? " If this Xcode's `simctl privacy` does not model the 'camera' service, run `xcrun simctl privacy` to list the services it supports." + 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." : ""; - 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", - ...subprocessFailureMetadata(err, "xcrun_simctl"), - }, - { cause: err instanceof Error ? err : new Error(String(err)) } - ); + // `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: [service], + 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/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/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 index f1f4eee4d..e48dda0a2 100644 --- a/packages/tool-server/test/settings-permissions.test.ts +++ b/packages/tool-server/test/settings-permissions.test.ts @@ -11,16 +11,25 @@ vi.mock("../src/utils/adb", async (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, getFailureSignal, zodObjectToJsonSchema } 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); @@ -32,15 +41,25 @@ const androidDevice: DeviceInfo = { id: ANDROID_SERIAL, platform: "android", kin // 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) => getFailureSignal(err)?.error_code === code; + 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?: string) => void) => { - cb(null, ""); + (_cmd: string, _args: string[], _opts: unknown, cb: (err: unknown, out?: unknown) => void) => { + cb(null, { stdout: "", stderr: "" }); } ); } @@ -71,6 +90,22 @@ beforeEach(() => { 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!; @@ -287,8 +322,8 @@ describe("settings-permissions iOS branch", () => { }); it("maps each supported permission to its simctl privacy service (identity)", async () => { - // Lock IOS_SERVICE: microphone/photos/location-always are asserted above, - // this pins the rest so an accidental null/typo (which would surface a false + // 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]> = [ @@ -302,8 +337,10 @@ describe("settings-permissions iOS branch", () => { for (const [permission, service] of cases) { execFileMock.mockClear(); const result = await iosImpl.handler({}, params({ permission }), iosDevice); - const [, args] = execFileMock.mock.calls[0]!; - expect(args, permission).toEqual([ + // `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, @@ -314,6 +351,119 @@ describe("settings-permissions iOS branch", () => { 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", () => { @@ -370,10 +520,10 @@ describe("settings-permissions Android branch", () => { }); it("reset still counts a revoked permission whose flags could not be cleared", async () => { - // clear-permission-flags is best-effort: it doesn't exist before Android 11 - // 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). + // 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"); @@ -539,10 +689,29 @@ describe("settings-permissions Android branch", () => { 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. @@ -588,4 +757,125 @@ describe("settings-permissions Android branch", () => { ).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(); + }); }); From 1eeeda9d305ff442e367ccb04f055b1adea8c60b Mon Sep 17 00:00:00 2001 From: Hubert Gancarczyk Date: Wed, 8 Jul 2026 12:27:37 +0200 Subject: [PATCH 5/5] docs(skills): normalize settings-permissions SKILL to regular dashes; tighten deny caveat Replace em-dashes with regular dashes per the repo writing-style rule, and move the long iOS-vs-Android deny caveat out of the When-to-use table cell into a concise Gotchas bullet. --- .../argent-settings-permissions/SKILL.md | 73 ++++++++++--------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/packages/skills/skills/argent-settings-permissions/SKILL.md b/packages/skills/skills/argent-settings-permissions/SKILL.md index 89f1fd776..b6303906c 100644 --- a/packages/skills/skills/argent-settings-permissions/SKILL.md +++ b/packages/skills/skills/argent-settings-permissions/SKILL.md @@ -1,27 +1,27 @@ --- 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. +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. +`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. +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 +## 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. **Caveat for `deny`:** it keeps the dialog out of the flow on **iOS** (a TCC denial answers the request). On **Android** `pm revoke` clears the grant but sets no "user-fixed" flag, so the app's next request still shows the system dialog — pre-deny there tests the revoked _state_, not a suppressed prompt. | -| 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. | +| 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**. @@ -29,27 +29,27 @@ Decide with this order. The first matching row wins. | `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` | +| `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` | +| `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) | +| `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 | +| `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. +- **`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 @@ -62,36 +62,37 @@ One abstract permission can map to several concrete Android permissions; which o } ``` -- `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**. +- `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. +**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: +**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 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. +- **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. -- **`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.) +- **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. +- `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). +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 @@ -101,7 +102,7 @@ Pre-grant the camera before launching, so the app never prompts: { "udid": "", "action": "grant", "permission": "camera", "bundleId": "com.example.app" } ``` -Test the denied path — refuse location, then launch and observe the fallback: +Test the denied path - refuse location, then launch and observe the fallback: ```json { "udid": "", "action": "deny", "permission": "location", "bundleId": "com.example.app" }