Skip to content

feat(tool-server): settings-permissions tool to grant/deny/reset app permissions#462

Open
hubgan wants to merge 5 commits into
mainfrom
feat/settings-permission-tool
Open

feat(tool-server): settings-permissions tool to grant/deny/reset app permissions#462
hubgan wants to merge 5 commits into
mainfrom
feat/settings-permission-tool

Conversation

@hubgan

@hubgan hubgan commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a settings-permissions MCP tool that edits an app's runtime permissions out-of-band, without navigating the system Settings UI. It's a test-setup / out-of-band tool — the default way to change a permission is still through the app (in-app toggle or the app-triggered dialog). Reach for this only when the app can't get there:

  • pre-authorize (grant) or deny a permission before the app asks, so no dialog interrupts the flow
  • re-enable one the user already denied (iOS never re-prompts once denied)
  • reset it so the first-run dialog reappears on next launch

Supported permissions: camera, microphone, photos, contacts, notifications, calendar, location, location-always, media-library, motion, reminders.

How

iOS simulatorxcrun simctl privacy grant|revoke|reset <service> [bundleId].

  • notifications has no simctl service → surfaced as an unsupported error.
  • An "invalid service" failure (e.g. camera on an Xcode that doesn't model it) is wrapped as unsupported with a hint to list supported services.
  • A shutdown simulator error carries a boot-device hint.

Androidpm grant / pm revoke (plus clear-permission-flags on reset) over adb.

  • One abstract permission fans out to the concrete android.permission.* set (fine/coarse location, per-media reads); location-always grant also grants foreground location since background alone is unusable.
  • A pm path preflight rejects an uninstalled package (pm grant/revoke exit 0 for a missing package, which would otherwise report a false success). A transport-level adb failure is rethrown rather than mislabeled as "not installed" (isTerminalAdbError is now exported for this).
  • Partial results land in skipped; the action fails only when pm rejects every mapped permission.

Supporting changes

  • New failure codes: ANDROID_SETTINGS_PERMISSION_FAILED, IOS_SETTINGS_PERMISSION_FAILED, SETTINGS_PERMISSION_UNSUPPORTED.
  • New argent-settings-permissions skill, plus cross-references from argent-device-interact, argent-test-ui-flow, and the argent rule (with a strong "prefer the app dialog over this tool" steer).
  • bundleId is regex-validated (same shell-injection guard as launch-app/restart-app) and schema-required for grant/deny.

Testing

vitest37/37 pass (schema validation incl. shell-injection payloads, iOS branch, Android branch incl. partial/total pm failures and the preflight). tsc --noEmit clean for tool-server + registry + tests; prettier clean.

…permissions

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 <service> [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.
@hubgan hubgan requested a review from latekvo July 7, 2026 08:52
@hubgan hubgan marked this pull request as ready for review July 7, 2026 08:59

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the new settings-permissions tool end-to-end — built the branch and ran the real iOS and Android handlers against an iOS 18.5 simulator and an API-34 Android emulator, plus the schema, docs, and test suite. It is well structured and the common grant/deny/per-app paths work as intended (verified observable state changes on both platforms). Inline notes below, mostly around reset semantics on each platform and the accuracy of a few error diagnoses.

Comment thread packages/tool-server/src/tools/settings-permissions/platforms/android.ts Outdated
Comment thread packages/tool-server/src/tools/settings-permissions/platforms/android.ts Outdated
action,
permission,
...(bundleId ? { bundleId } : {}),
applied: [service],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On success applied is set to [service] unconditionally, but a device-wide reset (no bundleId) does not actually clear existing per-app TCC entries on recent iOS. Reproduced on iOS 18.5: after granting camera to two apps, simctl privacy <udid> reset camera with no bundle exits 0 but leaves every per-app row intact, while the tool returns applied:["camera"]; a per-app reset camera <bundleId> correctly removes the single row. So on the device-wide reset path the caller gets a truthful-looking applied for a change that did not happen. Unlike the Android branch, the iOS branch never inspects the resulting state — the prose warns about this runtime behavior, but the return value still reports success.

Comment thread packages/tool-server/src/tools/settings-permissions/platforms/ios.ts Outdated
Comment thread packages/tool-server/test/settings-permissions.test.ts Outdated
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.`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description states the tool returns { action, permission, bundleId, applied, skipped? }, but bundleId is omitted from the result on an iOS device-wide reset (no bundleId supplied), where the handler spreads it only when present. The SKILL doc writes it as bundleId?; this description implies it is always echoed back.

- 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.
@hubgan hubgan requested a review from latekvo July 7, 2026 10:26
Comment thread packages/tool-server/src/utils/adb.ts
Comment thread packages/tool-server/src/tools/settings-permissions/platforms/ios.ts Outdated
// 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}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The installed-app check relies on an exact per-line match against package:<bundleId>. Because pm list packages <pkg> filters by substring, the normal output for an installed app is frequently multiple lines — the target plus any packages whose names contain it (e.g. pm list packages com.google.android.gms returns both package:com.google.android.gms.supervision and package:com.google.android.gms). The per-line .some(...) handles this correctly, but the tests only exercise the single-line, empty, and sibling-only-substring outputs — never the target-among-siblings shape — so a change to whole-output or first-line matching would keep every test green while making a real installed app read as "not installed".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e00cd0a — added a finds the target package among substring siblings test: pm list packages returns three lines (…app.helper, …app, …app.debug) and the test asserts the exact-target line is matched and the grant proceeds. A regression to whole-output or first-line matching now fails, since the first line is a sibling. Also confirmed against a real device — pm list packages com.google.android.gms returns both …gms.supervision and …gms, and the preflight resolves the exact target and grants.

- 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.
@hubgan hubgan requested a review from latekvo July 7, 2026 13:27
const ANDROID_PERMISSIONS: Record<PermissionName, string[]> = {
"camera": ["android.permission.CAMERA"],
"microphone": ["android.permission.RECORD_AUDIO"],
"photos": [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On API 34+ the platform auto-adds READ_MEDIA_VISUAL_USER_SELECTED (a dangerous runtime permission) to any app that requests READ_MEDIA_IMAGES/READ_MEDIA_VIDEO, and the partial-access dialog grants it persistently while IMAGES/VIDEO get only a session-long grant. Because the photos mapping never includes it, deny photos revokes the three listed permissions and reports full success while the app still passes its partial-access check and keeps reading the user-selected media, and reset photos does not return the app to the not-yet-asked state (the next request shows the "keep/select more" flow, not the first-run dialog). Reproduced on an API 34 emulator: with USER_SELECTED granted, deny photos returned applied: [READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_EXTERNAL_STORAGE] and dumpsys still showed READ_MEDIA_VISUAL_USER_SELECTED: granted=true afterward. This only bites when partial access was previously established through the real dialog, but that dirty-state flow is one the skill doc explicitly targets ("re-enable one the user already denied", "reset dirty state").

// 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") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reset half of the asymmetry documented above is unpinned: tests cover the grant fan-out and deny staying background-only, but no test resets location-always. Changing this condition to action !== "deny" — which makes reset revoke ACCESS_FINE/COARSE_LOCATION too, exactly the "taking away 'always' shouldn't also strip 'while in use'" behavior the comment forbids — leaves all 43 tests green (verified by running the suite against that mutation). The suite pins the other comment-contracts in this file (shutdown-beats-camera-hint, per-app reset, the deny side of this same rule), so the reset side is the one gap.

return { ok: false, detail: stripStackFrames(trimmed) };
}
return { ok: true, detail: trimmed };
} catch (err) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch folds every adbShell throw into the same per-permission failure shape as a pm rejection, which conflates two very different situations. If the device drops off mid-fan-out after one permission already succeeded (flaky USB / wireless adb), the call returns success with the dead-device permissions in skipped — which types.ts documents as entries "the package manager rejected (typically not declared in the app's manifest...)" — and the observed detail (e.g. device 'emulator-5554' not found) is dropped entirely on that path, so the caller is pointed at the manifest instead of the dead transport. Reproduced against the built module: grant photos with a disconnect after READ_MEDIA_IMAGES returns {applied: [READ_MEDIA_IMAGES], skipped: [READ_MEDIA_VIDEO, READ_EXTERNAL_STORAGE]} with no error. The catch also discards the FailureError classification adbShell already attached: on the all-rejected path the rethrow reports error_kind: "subprocess" with no subprocessFailureMetadata, so a wedged device timing out every pm call loses its timeout kind and exit/signal/command telemetry fields — the iOS branch of this same tool forwards exactly that metadata, and sibling Android branches (launch-app, open-url) let the classified adb error propagate.

// 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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clear-permission-flags first appears in Android 13 — it is absent from the android11-release and android12-release PackageManagerShellCommand and present from android13-release — so this boundary (and the "API < 30" corollary below, the same claim in the skill doc, and the test comment) is off by two major versions. The ignore-the-outcome design holds either way, but the real consequence is wider than documented: on API 29-32, reset can never clear a user-fixed state (set automatically after repeated denies on 11/12), so a permission in "don't ask again" is revoked and reported reset while the dialog stays suppressed — and nothing in the result distinguishes that from a full reset. The skill doc's "does not exist before Android 11" actively misleads someone debugging exactly that on an Android 12 device.

const IOS_SERVICE: Record<PermissionName, string | null> = {
"camera": "camera",
"microphone": "microphone",
"photos": "photos",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

photos maps only to the photos TCC service, but add-only photo access lives in the separate photos-add service (kTCCServicePhotosAdd, listed separately by simctl privacy). After an app has been granted add-only access, deny photos and reset photos both return applied: ["photos"] while the kTCCServicePhotosAdd row survives untouched — reproduced on an iOS 18 simulator: granted photos-add, ran deny and then reset through the handler, and TCC.db still showed the add-only row at auth 2 after both reported success. For that state the documented deny/reset outcomes (the denied path, or the prompt reappearing) are reported as applied but don't happen.

// 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a FAILURE_CODES member ever resolves to undefined at test runtime — vitest loads @argent/registry from its built dist, so a stale local dist that predates the three new codes does exactly that — this comparison becomes undefined === undefined, and every rejects.toSatisfy(failsWith(...)) in the file passes for any rejection, including TypeErrors from broken mocks. Verified end-to-end: with the code removed from the loaded dist, the full suite stayed green and a probe rejection carrying a plain Error satisfied the matcher. CI rebuilds before testing so it is shielded; a local vitest run without a rebuild is not, and nothing asserts the codes are strings.

| ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For deny, this row's promise — "already granted/denied before the app runs, so no dialog interrupts the flow" — doesn't hold on Android: pm revoke clears the grant but records no refusal (it sets no user-fixed flag, and dialog suppression keys off user-fixed/policy-fixed), so the app's next requestPermissions still shows the system dialog. That is faithful to Android's own single-deny semantics, but it silently diverges from the iOS half of this tool, where deny writes a TCC denial that answers the app without prompting. Nothing in the doc or tool description warns that a pre-launch deny keeps the dialog out of the flow on iOS only.


| `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` |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This row — plus the gotcha at line 84, the closing note at line 93, and the permission describe-string in index.ts — attributes camera availability to "the installed Xcode's simctl", but the implementation comment (platforms/ios.ts lines 28-35) states support varies by simruntime, independent of whether the Xcode's simctl privacy usage text lists it. That is also the observed behavior: on Xcode 16.4 (and 26.5 per the earlier review round) the usage text omits camera while camera grants succeed. An agent following this wording will blame or switch the Xcode install, or treat the usage listing as authoritative — the error hint in ios.ts points at that same listing — when the actual variable is the target simulator's runtime.

- **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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Always" isn't true: the hint is deliberately suppressed when the camera failure is a shutdown-simulator error (platforms/ios.ts gates it on !shutdownHint, pinned by the "camera failure on a shutdown simulator" test). The closing note at line 93 has the same problem in a worse spot — it sits right after a failure list that includes "a shutdown simulator (iOS)" and still says a camera failure "additionally hints".

- 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Reach for the argent-settings-permissions tool" names the skill, not the tool — the tool id is settings-permissions (the sentence's own closing reference, "See the argent-settings-permissions skill", uses the name correctly). An agent searching its tool list for argent-settings-permissions finds nothing.

hubgan added 2 commits July 8, 2026 12:17
- 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.
… 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.
@hubgan hubgan requested a review from latekvo July 9, 2026 07:15
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The interface contract above (lines 80-88) reserves false for a definitive "not installed" and undefined for "the probe couldn't answer", but this catch routes only the shutdown shape to undefined — every other probe failure becomes a definitive verdict. Two shapes that aren't verdicts land here: a nonexistent/stale UDID (get_app_container fails with "Invalid device", and device resolution is shape-based, so a deleted sim's UDID reaches this path) and a probe timeout (killed, no "current state" text — a wedged CoreSimulator). Both produce "the app is not installed — install the app first, then grant" for a question the probe never answered. Verified end-to-end: on a random UUID, grant location reports the app isn't installed while grant microphone on the same UUID correctly surfaces simctl's "Invalid device". No false success — the call fails either way — but the steer is wrong in exactly the confidently-wrong-diagnosis way the guard exists to avoid.

// 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This nets the four device-state patterns plus the timeout kind, but adb's client↔daemon leg fails with shapes that match neither: adb: protocol fault (couldn't read status): Connection reset by peer (an adb server restarting mid-command — a real event on hosts running mixed adb versions, where a version-mismatched client kills and restarts the shared server) and adb: cannot connect to daemon at tcp:...: Connection refused. Both classify as error_kind: "subprocess" and match no terminal pattern (verified against the built adbShell with a redirected ADB_SERVER_SOCKET), so they fold into the per-permission failure shape: a one-call blip mid-fan-out puts the lost permission into skipped under a success result — a deny whose revoke never executed reports success — and a daemon that stays down after the preflight produces the "every mapped runtime permission was rejected... manifest" aggregate, with the transport cause surviving only inside the parenthesized detail. The same failure at the preflight (line 166) propagates with adb's own classification, and the comment above (lines 80-84) defines "the adb transport itself failed" as exactly the category that must propagate — the daemon leg is in that category but escapes the net.

// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This range is under-inclusive (it inherited the round-3 review comment's own phrasing): FLAG_PERMISSION_USER_FIXED exists since API 23 — it is what the checkbox-era "Don't ask again" sets on 23-29; API 30 only made it automatic after the second deny — and the flag-clear subcommand is absent everywhere below 33. So reset cannot clear a user-fixed state on API 23-32, not just 29-32; as written here and in the SKILL gotcha it implies sub-29 devices are unaffected. The capability matrix declares no Android version floor, so those levels are in scope.

- **A partial Android result is normal.** `applied` lists what actually changed; `skipped` lists mapped permissions `pm` rejected (usually not in the manifest, or gated by API level). Both together tell you what happened.
- **A pre-launch `deny` suppresses the prompt on iOS only.** On iOS a TCC denial answers the app's request, so no dialog appears. On Android `pm revoke` clears the grant but sets no "user-fixed" flag, so the app's next request still shows the system dialog - a pre-launch `deny` there tests the revoked _state_, not a suppressed prompt.
- **`camera` on iOS** may be rejected by a simulator **runtime** that doesn't model the service (it varies by simruntime, not by the installed Xcode - a runtime can accept `camera` even when the Xcode's `simctl privacy` usage text omits it). simctl reports a rejection as a generic CoreSimulator error, so a `camera` failure (unless it's the shutdown-simulator case, which gets the boot hint instead) carries a hint to run `xcrun simctl privacy` and list the supported services.
- **`grant location` needs the app installed first (iOS).** Location authorization isn't stored in TCC and isn't applied to a bundle id until the app exists, so a pre-install `grant location` / `grant location-always` records nothing - the tool checks and errors clearly instead of reporting a false success. (TCC-backed services like `camera`/`photos` _can_ be granted before install; they persist and apply on install.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remote branch this PR also adds can't keep this promise: sim-remote has no app-container verb, so the remote backend omits the install probe, and a pre-install grant location on a sim-remote target returns applied: ["location"] — the false success this sentence says the tool prevents. The exemption is stated only in code comments; neither this doc nor the tool description scopes the check to local simulators, and the only remote-path test grants microphone, so nothing exercises the remote guard-skip.

| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `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` |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"deny/reset clear both" is stronger than the implementation guarantees: photos-add is deliberately best-effort — a secondary-service failure is skipped silently, it does not appear in the result's skipped (which types.ts pins as Android-only), and types.ts's applied doc still describes fan-out as an Android-only phenomenon. When the second call fails, the result is a success whose applied lists only photos — consistent with the code's stated best-effort intent, but not with this row's unconditional wording.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants