Skip to content

feat(maestro): support setPermissions and launchApp.permissions - #2363

Open
Rohit3523 wants to merge 17 commits into
callstack:mainfrom
Rohit3523:feat/maestro-setPermissions
Open

Rohit3523 wants to merge 17 commits into
callstack:mainfrom
Rohit3523:feat/maestro-setPermissions

Conversation

@Rohit3523

@Rohit3523 Rohit3523 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Maestro setPermissions + launchApp.permissions parse and execute (allow|deny|unset, iOS-only location: always|inuse|never and photos: limited, bare ${VAR}; JS expressions rejected). all resolves in the backends (one simctl privacy … all call on iOS, declared-permission intersection on Android) with specifics overriding; launch permissions apply after state clearing but before launch; no silent all: allow default. Unservable names fail UNSUPPORTED_OPERATION with hints. upstream/131_setPermissions now classifies identical.

Latest head: Maestro admission sets and hint text derive from explicit MAESTRO_* allowlists in contracts (native-only contacts-limited/location-always can never leak into hints); the test-only Android parser export is removed; Android all skips role-managed ids (WRITE_SETTINGS) instead of aborting. Scope is ~36 files; the growth is backend widening the Maestro surface requires (calendar/location/media-library, multi-id intersection, all resolver) — a thin translator alone would emit targets the old backends reject.

- launchApp:
    clearState: true
    permissions:
      all: deny
      camera: allow
- setPermissions:
    permissions:
      notifications: unset

Validation

Tested at a3e35d20f: 78 focused tests pass; tsc --noEmit clean; pnpm format clean; fallow audit --base upstream/main has only the 2 inherited findings; check:affected --base upstream/main --run all runnable checks pass.

Live via test --maestro on this head (on-device state, not just replay): Android lab app microphone: allow (RECORD_AUDIO granted=true), all: deny (granted=false), launchApp{clearState, mic: allow} (grant survives); camera2 camera: allow and clearing launch (CAMERA granted); system contacts-app all: deny + contacts: allow (contacts re-granted, all other runtime granted=false); iOS Safari all: allow (TCC allowed rows) then all: unset (prompt state); notifications: unset fails loud with supported-services hint, microphone grant preserved.

CI on the pushed head is pending. READ-only gap closed live after the above: lab app rebuilt with READ_CONTACTS only (scratch app.config.js change, reverted; original APK restored) — all: deny, contacts: allowREAD_CONTACTS granted=true, RECORD_AUDIO granted=false, no WRITE_CONTACTS in the dump; contacts: denycontacts: allow 2/2.

@Rohit3523
Rohit3523 force-pushed the feat/maestro-setPermissions branch from dd06012 to 8d08026 Compare September 6, 2026 15:18
@Rohit3523 Rohit3523 changed the title feat: support Maestro setPermissions feat(maestro): support setPermissions and launchApp.permissions Sep 6, 2026
@Rohit3523
Rohit3523 marked this pull request as ready for review September 6, 2026 17:04
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

Three behavior gaps remain at 8d08026.

launchApp applies permissions after open has already launched the app. Startup code can request access before the requested state is installed. Apply permissions after state clearing but before launch, and verify with an app that requests access immediately on startup.

location: never maps to reset, which restores the prompt state rather than denying access. Map it to denial and add a regression distinguishing never from unset.

all expands a fixed list that includes camera, despite the reported iOS run showing camera is unavailable. The sequential changes can therefore stop partway through. Derive the supported set from the existing backend capability information and validate before mutation; test all on the reported runtime. This head also has no CI checks yet.

…tion never

- launchApp.permissions now runs after state clearing but before open,
  so startup code observes the requested state; the map is validated
  before any mutation via a new clearAppState public operation.
- location never maps to deny (unset keeps the reset prompt state).
- ios all expansion skips the probe-unsupported camera/notifications
  so the sequential mutations cannot stop partway through.
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

The launch ordering and location: never fixes are addressed at 4de150a. The all case still needs the shared backend capability information: replacing the fixed list with fixed camera/notifications exclusions only reflects one host. It can skip a supported permission or still fail partway through on another runtime. Resolve and validate the runtime-supported set before clearing state or changing permissions, then test varying service sets and verify all on-device. The updated startup ordering also needs live verification; this head has no CI checks yet.

@thymikee thymikee 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 at 4de150a. Parser, runtime-port, and daemon adapter suites plus tsc --noEmit pass locally. The parse/IR/projection layers are in good shape; the remaining problems are in how all and the fan-out meet the backends, plus some duplication.

1. all on Android fails partway on most apps. pm grant/pm revoke throw SecurityException: Package … has not requested permission … for anything the package does not declare, and grantAndroidPermission/revokeAndroidPermission run those without allowFailure. So all: deny expands to five pm calls and stops at the first undeclared one. The lab app in the description declares only RECORD_AUDIO, which is why all could not have been verified there. This is the same class of problem as the iOS exclusion list: the servable set is a property of the device and app, not the platform. Two concrete directions:

  • Android: intersect the expansion with the package's requested permissions: from dumpsys package before issuing anything (permission-grant-state.ts already parses that dump).
  • iOS: simctl privacy accepts all as a service natively, and the backend already uses reset all as a fallback. all: X can be one settings permission <action> all call followed by the specific overrides. That removes ALL_EXCLUDED_PERMISSIONS entirely and the "this host's simctl privacy help" reasoning with it.

Either way all belongs in the backends as a permission target, not as a list the daemon adapter maintains.

2. The fan-out is not atomic and does not say what landed. Name validation happens before the first mutation, but the backend can still reject mid-sequence (undeclared Android permission, missing iOS service). The flow then fails with a half-applied map and the error names only the entry that failed. If (1) resolves the servable set up front, this mostly goes away. Until then the error should at least list the mutations already applied.

3. launchApp duplicates the launch invoke. Both branches build the same operation. Collapse to:

if (input.permissions) {
  const mutations = mapMaestroSetPermissions(input.permissions, platform);
  if (clearState) await invokeMutation({ kind: 'clearAppState', ...(appId ? { appId } : {}) }, context);
  await applyPermissionMutations(appId, mutations, context);
}
await invokeMutation(
  { kind: 'launchApp', ...(appId ? { appId } : {}), relaunch, clearState: clearState && !input.permissions, launchArgs },
  context,
  'deferred',
);

The comment claiming the split "matches what open --clearAppState does" is only partly true on iOS: clearAppState also flips isDirectAppLaunch in platform-apple/src/lifecycle.ts, which changes how a runtime launch URL is folded into the open. Probably harmless for Maestro flows, but say that rather than claim equivalence.

4. Duplicate-key check in readSetPermissionsMap is dead and wrong. The YAML layer already rejects duplicate keys (Map keys must be unique), so the check never fires for real duplicates. It does fire for prototype keys: permissions: { constructor: allow } is rejected as "duplicate permission". Drop it.

5. Value validation is triplicated. MAESTRO_PERMISSION_VALUES (parser), RESOLVED_PERMISSION_VALUES (runtime port), and PLAIN_VALUE_STATES + GRANULAR_MUTATIONS (daemon) are the same set, with three copies of the "allow|deny|unset (plus always|inuse|never|limited …)" message. Export one constant from the maestro package. The runtime-port check is justified because ${VAR} resolves there, but it should reference the same set.

6. Smaller cleanups.

  • mapMaestroPermission takes expandable as a parameter that is derivable from platform.
  • applyPermissions is a one-line wrapper with one caller; inline it into setPermissions.
  • The empty-map check is repeated by both callers of readSetPermissionsMap with different messages; move it into the reader.
  • isAgentTapCommand/isAgentAssertCommand restate their kind lists by hand. A const TAP_KINDS = [...] as const with .includes keeps the guard and the Extract union in sync.
  • daemon-request.ts: the settingsAppBundleId comment is good. Worth one line in snapshot-settings.ts too, since the precedence over the session app is invisible from the CLI side.

(1) and (2) are the blockers; the rest is cleanup that should land in the same PR.


Generated by Claude Code

…mission divergences

- iOS reset notifications bypasses the simctl probe gate into the
  existing reset-all fallback (verified live on iOS 26.3 where help
  omits the service); grant/deny stay loud rejections.
- Support matrix and replay docs now declare the intentional gaps
  vs upstream: no silent all-allow launch default, backend-servable
  all expansion, loud rejections, true-reset unset, never denies.
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

At 7285bb1, resetting notifications can now fall through to simctl reset all when the runtime does not list notifications. A flow asking only for notifications: unset can therefore reset microphone, location and other permissions too. Keep the operation targeted; if that is unavailable, fail explicitly rather than clearing unrelated state. Add a regression that preserves another permission across notification reset.

The earlier all-expansion finding also remains: the adapter still uses fixed lists, and the new docs describe them as runtime-supported even though no runtime preflight occurs. Please resolve capabilities before clearing state or applying permissions. CI and live all/startup validation are still missing.

…e layers

- settings permission all is now a backend target: iOS runs one
  simctl privacy call, Android intersects the package's declared
  permissions from dumpsys before mutating, skipping
  non-changeable ids with reasons instead of stopping partway.
  The adapter no longer keeps a fixed expansion list.
- Android serves the full upstream name table (bluetooth, calendar,
  location, media-library, phone, sms, storage) through pm.
- Fan-out failures report applied and failed mutations; launchApp
  collapses to one invoke; permission values share one maestro
  constant; duplicate-key and empty-map checks consolidated;
  TAP/ASSERT kind lists unified; settings app precedence noted.
…fallback

A notifications-only unset must not clear microphone, location and
other permissions through the reset-all sledgehammer. The probe gate
rejects unlisted notifications again; the reset-all fallback stays
for runtimes that list the service but block the direct reset.
Regression proves a microphone grant survives the failed reset.
@thymikee

thymikee commented Sep 6, 2026

Copy link
Copy Markdown
Member

Moving all into the platform backends addresses the fixed-list problem at 682d43d. Two correctness gaps remain.

On iOS, a listed notifications service that rejects reset still falls back to reset all. A notifications-only request can clear microphone and location grants. Fail the targeted operation instead and cover the listed-but-blocked case while preserving another grant.

On Android, tryPmUnit treats every nonzero result as a skip, and tryPhotosUnit catches every error. An offline device or failed operation can therefore let launchApp continue with incomplete permissions. Skip only established non-changeable permissions; propagate operational failures and add regression coverage.

The change adds roughly 812 net production lines, including broader Android permission support. Please account for that growth and explain why the existing permission paths cannot support a smaller design. Current live evidence predates these changes; all-permission and startup-order verification are still needed, and this head has no CI checks.

@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member

The latest coverage run also fails because the Maestro fuzz inventory does not cover setPermissions (scripts/fuzz/validation-arbitraries-maestro.test.ts). Please add a meaningful generator case alongside the existing requested fixes and verify the coverage lane. The Android smoke failure at automation-press looks unrelated.

…l Android failures, cover setPermissions in fuzz
@thymikee

thymikee commented Sep 7, 2026

Copy link
Copy Markdown
Member

At 752b88d, the targeted iOS reset and Android operational-error fixes address the previous failures, and setPermissions is now in the fuzz inventory. One design gap remains: the all-permission path decides whether to continue by matching raw pm stderr, including nested photos attempts. Classify those outcomes once at the Android permission boundary and let the fan-out consume typed reasons, with unknown failures still aborting.

Please also update the live evidence for all-permission handling and permission-before-startup ordering. The reported device runs predate those changes. The roughly 823 net production lines still need a short growth breakdown and an explanation of why a smaller design was rejected. This head has no CI results yet.

@thymikee

thymikee commented Sep 8, 2026

Copy link
Copy Markdown
Member

The branch now also conflicts with main at 752b88d. The previously reported permission-classification and validation gaps remain unresolved; resolve those together with the conflict before rerunning the affected checks.

…ssions

# Conflicts:
#	packages/maestro/src/internal/__tests__/program-ir-parser.test.ts
#	packages/maestro/src/internal/__tests__/runtime-port-fixtures.ts
#	packages/maestro/src/internal/conformance-normalize.ts
#	packages/maestro/src/internal/program-ir-command-parser.ts
#	packages/maestro/src/internal/program-ir.ts
#	packages/maestro/src/internal/runtime-port-commands.ts
#	packages/maestro/src/internal/runtime-port-types.ts
#	packages/maestro/test/conformance/expected-divergence.ts
#	scripts/fuzz/validation-arbitraries-maestro.ts
#	src/daemon/adapters/maestro/__tests__/daemon-runtime-public-operation.test.ts
#	src/daemon/adapters/maestro/daemon-runtime-port.ts
#	src/daemon/adapters/maestro/daemon-runtime-public-operation.ts
@Rohit3523

Copy link
Copy Markdown
Contributor Author

Done with the changes

@thymikee

Copy link
Copy Markdown
Member

Reviewed at e656ff1, as a follow-up to the review at 752b88d. Two backend problems remain, and the shared permission list is still declared in several places.

parseAndroidPermissionTarget (packages/platform-android/src/settings-permission.ts:451) still runs the contracts parsePermissionTarget first, and that parser does not accept bluetooth, phone, sms or storage. So those rows in ANDROID_PERMISSION_TABLE can never be reached, even though EXPANDABLE_PERMISSIONS.android and UNSUPPORTED_HINTS (set-permissions-mapping.ts:19) advertise them. A Maestro setPermissions: {bluetooth: allow} passes adapter validation and then fails in the backend with "permission setting requires a target", after earlier entries in the map already landed. Please either add the four names to the contracts PermissionTarget list and the CLI copy in src/commands/capture/settings.ts, with a backend test per name, or drop them from the table and the adapter list.

Named multi-id targets now run a strict pm grant|revoke for every id in the table (settings-permission.ts:395): contacts is READ+WRITE_CONTACTS, location is FINE+COARSE, calendar is READ+WRITE. Doesn't pm fail when the app does not declare one of those ids? If so, agent-device settings permission grant contacts now fails on an app that declares only READ_CONTACTS, which worked before this PR, and Maestro location: allow fails the same way on coarse-only apps. The all path already intersects with the declared permissions from dumpsys; could the named path use the same resolver? The fake-adb test at settings-permission.test.ts:326 accepts every id, so a case where the second id is rejected would catch this.

The set of servable names is declared four times: EXPANDABLE_PERMISSIONS, the UNSUPPORTED_HINTS text, ANDROID_PERMISSION_TABLE with its error string (settings-permission.ts:479), and contracts PERMISSION_TARGETS plus the CLI copy. That drift is what causes the first problem. Could per-platform targets live once in contracts, with adapter validation, hint text and backend parsing derived from them?

website/docs/docs/commands.md:732 still lists the old Android targets. The CLI now accepts all on both platforms and calendar/location/media-library on Android, contacts also changes WRITE_CONTACTS, and the deny/reset permission result is now a comma-joined list. Please update that page, add a CHANGELOG line for the CLI change, and refresh the PR body, which still says no native code changed. Two small leftovers from the merge can also go: the StopAppCommand row in BARE_UPSTREAM_CANONICAL (conformance-normalize.ts:138) is now dead, and mapMaestroAll repeats checks the parser already owns.

The PR adds about 816 net production lines. Could the Maestro layer stay a thin name/value translator that emits one settings permission call per entry, with Android using one resolver for both named targets and all? That would remove EXPANDABLE_PERMISSIONS, the hint text and the strict pm loop, and fix the first two problems by construction. The Android table expansion (bluetooth/phone/sms/storage and multi-id fan-out) also goes beyond the Maestro mapping in the issue and could be its own PR with device evidence. What would need to land first is a set of per-platform target constants in packages/contracts. If the larger shape is needed, can you say why the smaller one does not work?

The live runs in the PR body are from 8d08026 and cover only microphone. Since then the Android all and multi-id paths and the iOS notifications and all paths changed. A live Android run of setPermissions {all: deny, contacts: allow} on an app that declares only some of the ids, and a live iOS simulator run of all: allow and all: unset, both on this head, would cover the changed routes.

All 15 checks pass on e656ff1, and there are no conflicts. The next step is to fix the unreachable Android targets and the strict multi-id fan-out, then add the live Android and iOS evidence on the new head.

…ssions

# Conflicts:
#	packages/contracts/src/client-settings.ts
#	packages/contracts/src/settings.ts
#	src/commands/capture/settings.ts
@Rohit3523

Copy link
Copy Markdown
Contributor Author

All green :)

@thymikee

Copy link
Copy Markdown
Member

Reviewed at 9f187e6. Only a merge from main landed since e656ff1, so the review at e656ff1 still applies in full: the unreachable Android bluetooth/phone/sms/storage targets (settings-permission.ts#L451), the strict pm grant/pm revoke loop over multi-id targets such as contacts and location (L395), the permission names declared in several places, the stale commands.md and missing CHANGELOG entry, and the question whether a thin Maestro translator would do instead of about 810 net production lines.

Smoke Tests fails on RunnerTests.testAlertDismissDoesNotActivateAReplacementWithTheSameTitle. This diff touches no runner Swift or alert code, so that failure looks unrelated.

Live runs on this head are still needed. On Android: setPermissions {all: deny, contacts: allow} against an app that declares READ_CONTACTS but not WRITE_CONTACTS, with dumpsys package output, and launchApp {clearState: true, permissions: {...}} showing the grant after the clearing launch. On an iOS simulator: {all: allow} then {all: unset}, with a notifications result. The existing evidence is from 8d08026 and covers only microphone. Next: fix the two backend problems, then add those runs.

…rgets, single-source permission sets

Drop unreachable bluetooth/phone/sms/storage from Android table and Maestro
adapter; declare ANDROID/IOS_PERMISSION_TARGETS once in contracts and derive
adapter lists, hints, and backend error strings from them.

Intersect named multi-id pm targets (contacts/location/calendar/media-library)
with dumpsys requested permissions like all does: grant via resolveNamedPmIds,
revoke via single-read revokeNamedPmTarget; fail loudly when none declared,
fall back to strict table when dump unreadable.

Cleanups: drop dead StopAppCommand bare row, lifecycle kinds const,
simplify mapMaestroAll. Update commands.md targets and CHANGELOG.
@thymikee

Copy link
Copy Markdown
Member

Reviewed at e4dd9a4, as a follow-up to the review at 9f187e6. Three earlier points are fixed: the unreachable Android bluetooth/phone/sms/storage targets are gone, named multi-id targets such as contacts now intersect the package's declared permissions on grant, and commands.md and CHANGELOG are updated.

Android can still receive values it rejects. GRANULAR_MUTATIONS at set-permissions-mapping.ts#L47 applies on every platform. On Android, location: always becomes location-always, which ANDROID_PERMISSION_TABLE has no key for, and photos: limited sends mode=limited, which the Android parser rejects. Both pass the adapter's up-front validation and then fail in the backend, so a flow that was accepted stops halfway. Can the adapter emit only mutations the target platform accepts, for example by keying the granular table by platform or refusing these values on Android with UNSUPPORTED_OPERATION? A mapping test that runs every emitted Android mutation through the Android parser would pin it.

ANDROID_PERMISSION_TABLE at settings-permission.ts#L56 still has its own literal keys, so it can drift from ANDROID_PERMISSION_TARGETS. Could it be typed as Record<Exclude<AndroidPermissionTarget, 'all' | 'photos' | 'notifications'>, readonly string[]>? Also, IOS_PERMISSION_TARGETS = MOBILE_PERMISSION_TARGETS lets Maestro iOS flows accept contacts-limited and location-always, which are not Maestro names. Could the Maestro iOS list come from the Maestro vocabulary instead?

The file now reads dumpsys package in three places with different failure policies: all throws on an unreadable dump, while resolveNamedPmIds (#L485) and revokeNamedPmTarget fall back to the strict table and use two different parsers. Could one reader return requested ids and grants with a single failure policy for all three?

The new intersection tests cover grant only. Could you add deny and reset cases against a READ_CONTACTS-only dumpsys, with a fake that fails pm revoke for WRITE_CONTACTS? That is the path all: deny plus a named override depends on.

The PR body still says Android location and calendar fail with UNSUPPORTED_OPERATION, that launchApp permissions apply after launch, and cites only the microphone runs from 8d08026. Please refresh it for this head.

The change is still about 926 net production lines, and the growth has not been explained. Would a thin Maestro translator that emits only the (target, mode) pairs the existing settings permission backends accept cover the same Maestro surface, with the Android backend widening (calendar, location, media-library, the multi-id intersection, the all resolver) in its own PR with its own live evidence? What would need to change first, apart from one per-platform target table in contracts?

CI is green on e4dd9a4, and there are no conflicts. Live runs on this head are still needed: on Android, setPermissions {all: deny, contacts: allow} against an app that declares READ_CONTACTS but not WRITE_CONTACTS, and launchApp {clearState: true, permissions: {camera: allow}}, each with dumpsys package output after the run; on an iOS simulator, {all: allow} then {all: unset}, with privacy state after each step including notifications. Next: fix the Android granular mapping, answer the size question, then add those runs.

@thymikee

Copy link
Copy Markdown
Member

Reviewed at efe33f4, as a follow-up to the review at e4dd9a4. Four earlier points are fixed: the adapter now refuses iOS-only granular values on Android instead of emitting location-always or mode=limited, ANDROID_PERMISSION_TABLE is typed from AndroidPermissionTarget, dumpsys package has one reader with one failure policy, and deny/reset intersection tests now use a READ_CONTACTS-only dump with a failing WRITE_CONTACTS revoke.

The iOS error and its hint now disagree. EXPANDABLE_PERMISSIONS.ios drops contacts-limited and location-always, but UNSUPPORTED_HINTS.ios still builds from the unfiltered IOS_PERMISSION_TARGETS (set-permissions-mapping.ts#L36). So setPermissions {contacts-limited: allow} on iOS fails with "not supported on ios yet", and the same error's hint lists contacts-limited and location-always as supported. A caller can read that as "retry the same name". The list is also still a denylist over the native settings permission names, so a native-only target added to MOBILE_PERMISSION_TARGETS later becomes a Maestro name without anyone deciding that. Can the admission set and the hint come from one Maestro iOS name list, with a test that checks the hint against that same list?

The new parser test can pass without checking anything (set-permissions-mapping.test.ts#L144). It wraps each mapping call in a bare catch { continue } and never counts the mutations it checked, so if mapMaestroSetPermissions threw for every Android input after an unrelated refactor, the test would stay green. The names list is typed by hand, so a Maestro name added later never runs through the parser. Can the catch accept only an AppError with UNSUPPORTED_OPERATION or INVALID_ARGS, the test assert that it checked every ANDROID_PERMISSION_TARGETS name with allow, deny and unset, and the names come from MOBILE_PERMISSION_TARGETS plus the aliases?

Two new public exports look test-only. parseAndroidPermissionTarget is exported from @agent-device/platform-android/mechanics (mechanics.ts#L242), and its only reader outside the package is the daemon mapping test. MAESTRO_PERMISSION_VALUES in @agent-device/maestro says the daemon adapter shares it, but the adapter does not import it. Could the adapter validate values with MAESTRO_PERMISSION_VALUES so the comment is true, or could both exports go? A per-platform acceptance table in contracts that the adapter and the Android parser both read would let the test check against that table without a test-only export.

The size question from the earlier review has no answer yet. The change is about 968 net production lines, above the 700-line threshold, and this update added a shared reader and a platform guard without reducing scope. Would a thin Maestro translator that emits only the (target, mode) pairs the existing settings permission backends accept, checked against one per-platform acceptance table in contracts, cover the same Maestro surface, with the Android backend widening (calendar, location, media-library, the multi-id intersection, the all resolver, the declared-permission reader) in its own PR with its own live evidence? If not, what makes the smaller design not work?

The PR has merge conflicts with main, and no CI checks ran on efe33f4. The PR body is still not refreshed for this head.

Live runs on this head are still needed: (1) on Android, against an app that declares READ_CONTACTS but not WRITE_CONTACTS, setPermissions {all: deny, contacts: allow} through agent-device test --maestro, then adb shell dumpsys package <pkg> showing READ_CONTACTS granted and every other declared runtime permission not granted; (2) on Android, launchApp {clearState: true, permissions: {camera: allow}}, then dumpsys showing CAMERA granted after launch; (3) on an iOS simulator, setPermissions {all: allow} then {all: unset}, with the privacy state after each step, including notifications back to not determined after unset.

Next: resolve the conflicts, fix the iOS hint and the parser test, answer the size question, then attach those runs.

… skip role-managed ids

Maestro iOS admission and hint derive from explicit MAESTRO_* lists in contracts; adapter validates values with MAESTRO_PERMISSION_VALUES. parseAndroidPermissionTarget leaves the mechanics surface so the mapping test pins backend-servable pairs against the contracts table. Android all skips role-managed ids such as WRITE_SETTINGS instead of aborting.
@Rohit3523

Copy link
Copy Markdown
Contributor Author

Addressed the review at efe33f4 in a3e35d2 (pushed):

  • iOS hint/admission: explicit MAESTRO_ANDROID/IOS_PERMISSION_TARGETS in contracts; adapter admission + hints derive from them, with a test pinning the hint against the same list. No denylist, so a future native target can't silently become a Maestro name.
  • Parser test: catches only AppError(UNSUPPORTED_OPERATION|INVALID_ARGS), asserts every MAESTRO_ANDROID target x allow|deny|unset was checked, names from MOBILE_PERMISSION_TARGETS + aliases. Helpers extracted so fallow is clean.
  • Exports: parseAndroidPermissionTarget is module-private (off the mechanics surface); the adapter now validates values with MAESTRO_PERMISSION_VALUES, and the Android test pins against the contracts table.
  • Size: the net growth is the backend widening the Maestro surface needs (calendar/location/media-library, multi-id intersection, all resolver, declared-permission reader). A thin translator alone would emit targets the old backends reject; the prerequisite per-platform table now lives in contracts. Details in the refreshed PR body.

Live on this head via test --maestro (dumpsys/TCC verified): lab-app mic allow / all-deny / clearing launchApp, camera2 camera allow + launch, contacts all-deny + contacts-allow, iOS all allow/unset, notifications-unset loud failure preserving mic. One live find fixed here: Android all now skips role-managed ids (WRITE_SETTINGS 'managed by role') with a unit test.

What I need:

  1. Re-review of a3e35d2 and a CI signal on the pushed head.
  2. A call on the one gap: no READ_CONTACTS-only app exists on the emulator, so that subset is fake-adb covered (WRITE revoke never attempted). Is that sufficient, or can you point me at a test APK declaring READ but not WRITE_CONTACTS?

@Rohit3523

Rohit3523 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

READ-only live evidence is in (closes item 2 of my previous comment — no test APK needed).

Setup: added permissions: ['android.permission.READ_CONTACTS'] to expo.android in examples/test-app/app.config.js, rebuilt (expo prebuild + ./gradlew assembleDebug — note: expo run:android served the disk-cached build and skipped prebuild, so the Gradle step ran directly), installed on Pixel 9 (emulator-5554). aapt2 dump badging confirmed READ_CONTACTS present, WRITE_CONTACTS absent. Scratch config reverted afterward and the original APK reinstalled; git status clean.

Run (via test --maestro, head a3e35d2):

appId: com.callstack.agentdevicelab
---
- setPermissions:
    permissions:
      all: deny
      contacts: allow

Result 1/1 pass. adb shell dumpsys package com.callstack.agentdevicelab after:

requested permissions:
  ...
  android.permission.RECORD_AUDIO
  android.permission.READ_CONTACTS
runtime permissions:
  android.permission.RECORD_AUDIO: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
  android.permission.READ_CONTACTS: granted=true, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]

No WRITE_CONTACTS anywhere in the dump, and the flow passed — the named intersection attempted only the declared READ id (the pre-fix strict fan-out fails this exact flow on WRITE_CONTACTS has not requested permission). Follow-up contacts: denycontacts: allow also 2/2, ending READ_CONTACTS granted=true.

@thymikee

Copy link
Copy Markdown
Member

Reviewed at a3e35d2, as a follow-up to the review at efe33f4. The three code points are fixed: the iOS admission list and its hint now both come from MAESTRO_IOS_PERMISSION_TARGETS, the parser test accepts only the two typed refusals and checks every Android target with allow, deny and unset, and parseAndroidPermissionTarget is no longer exported. The size answer settles the earlier question: the old backends could not serve the targets Maestro needs, and the per-platform table now lives in contracts. The merge from main only touches CHANGELOG.md, and both sides are kept.

Run (1), Android READ_CONTACTS-only, is now covered. The other two required live runs are described in your comment and the PR body, but no output is attached. Run (2), Android launchApp {clearState: true, permissions: {camera: allow}}, has no flow YAML and no post-launch dumpsys, so nothing shows whether a clearState launch keeps the camera grant. Run (3), iOS setPermissions {all: allow} then {all: unset}, has no captured TCC rows and no notifications state after unset; the only notifications evidence on file is for the named notifications: unset route, which now refuses loudly, not for all: unset, so it's unclear whether all: unset returns notifications to not-determined on the shipped head. Can you attach, for a3e35d2: for (2) the flow YAML for that launch step plus adb shell dumpsys package <pkg> showing android.permission.CAMERA: granted=true; and for (3) the flow output for {all: allow} then {all: unset}, the simulator TCC rows for the bundle after each step, and the notifications authorization state after unset? If all: unset can't reset notifications on this runtime, the run should show a loud failure or warning rather than a silent pass.

Not blocking: the MAESTRO_PERMISSION_VALUES guard in set-permissions-mapping.ts:134 seems to duplicate the fall-through throw since runtime-port-commands.ts:196 already rejects unknown values earlier, the 'backend-servable' set in the test at set-permissions-mapping.test.ts:220 looks like a circular check against the same constant the adapter admits from, and MAESTRO_PERMISSION_ALIASES (set-permissions-mapping.ts:41) is exported only for the test to read — happy to leave all three as-is if you'd rather not touch them.

CI is green: the packet reports 15 checks with 0 not passing at a3e35d2.

I ran no tests or devices; these notes come from reading the code at a3e35d2 and the author's comments. I couldn't confirm whether simctl privacy reset all <bundle> resets notifications authorization on the runtime you used, so I can't judge whether a silent pass of all: unset is correct, and I haven't verified the 'managed by role' stderr match is stable across Android API levels other than the API 36 case you reported.

The Android clearState+camera dumpsys output and the iOS all:allow/unset TCC-and-notifications output are what's needed before this is ready to merge.

…ssions

# Conflicts:
#	packages/maestro/src/daemon-port/__tests__/daemon-runtime-port-set-permissions.test.ts
#	packages/maestro/src/daemon-port/__tests__/set-permissions-mapping.test.ts
#	packages/maestro/src/daemon-port/set-permissions-mapping.ts
@Rohit3523

Copy link
Copy Markdown
Contributor Author

Live evidence for a3e35d2 follow-up (run on 1ffc404, local build 0.21.6, 2026-09-18T03:41Z — includes a3e35d2 + main merge, no code change to permission paths since a3e35d2).

Run (2) — Android launchApp {clearState: true, permissions: {camera: allow}}
Device: Pixel 9 emulator-5554, API 36. Pkg com.android.camera2 (declares CAMERA).

Flow YAML (run2-android-launch-camera.yaml):

appId: com.android.camera2
---
- launchApp:
    appId: com.android.camera2
    clearState: true
    permissions:
      camera: allow

Before (after adb shell pm revoke com.android.camera2 android.permission.CAMERA):

android.permission.CAMERA: granted=false, flags=[ GRANTED_BY_DEFAULT|USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]

Replay:

./bin/agent-device.mjs replay run2-android-launch-camera.yaml --maestro --platform android
Replayed 1 step in 0.7s

After:

adb shell dumpsys package com.android.camera2 | grep CAMERA
android.permission.CAMERA: granted=true, flags=[ GRANTED_BY_DEFAULT|USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]

Grant survives the clearing launch — permissions apply after clear, before open.

Run (3) — iOS {all: allow} then {all: unset}
Device: iPhone 17 Pro Max 16562605-4CCF-48F6-BCD1-19369DF43263, iOS 26.5. Bundle com.apple.mobilesafari.

Flows:

# run3-ios-allow-all.yaml
appId: com.apple.mobilesafari
---
- setPermissions:
    permissions:
      all: allow
# run3-ios-unset-all.yaml — same with all: unset

Replay (both succeed, no silent skip):

replay run3-ios-allow-all.yaml --maestro --platform ios
Replayed 1 step in 4.1s
replay run3-ios-unset-all.yaml --maestro --platform ios
Replayed 1 step in 4.1s

TCC (TCC.db access WHERE client='com.apple.mobilesafari'):
After allow:

kTCCServiceAll|2
kTCCServiceLiverpool|2

After unset:

kTCCServiceLiverpool|2

Unset returns the bundle to prompt state via Apple's reset all (rows deleted = not-determined; Liverpool is Apple's leftover, not ours). Earlier on this head an allow showed 16 allowed rows (All + Calendar/Camera/Photos/Microphone/etc. all auth 2); the variance is Apple's grant all/reset all semantics, not adapter expansion — the adapter emits one simctl privacy <action> all <bundle> call.

Notifications state after unset:

  • Targeted route fails loudly, no silent pass:
replay run3-ios-notif-unset.yaml (notifications: unset) --maestro --platform ios
Error (REPLAY_DIVERGENCE): iOS simctl privacy does not support service "notifications" on this runtime.
Hint: Supported services: all, calendar, contacts, contacts-limited, location, location-always, media-library, microphone, motion, photos, photos-add, reminders, siri
  • xcrun simctl privacy <udid> help on this runtime lists no notifications or camera service (only all/calendar/contacts/location/photos-add/photos/media-library/microphone/motion/reminders/siri). So named notifications: unset correctly refuses with UNSUPPORTED_OPERATION; all: unset goes through Apple's reset all, which succeeds. There is no separate notifications auth store I could query on the simulator beyond TCC — notifications does not appear in TCC.db services.

@Rohit3523

Copy link
Copy Markdown
Contributor Author

done, pls rereview, thanks

@thymikee

Copy link
Copy Markdown
Member

Reviewed at aa9186d. Thanks for the live runs. Run (2) shows the CAMERA grant survives the clearState launch, and the TCC rows for {all: allow} then {all: unset} look right. The lazy-load commit keeps the same call order and errors, so the runs on 1ffc404 still cover it.

One point from the a3e35d2 review is still open. On iOS, all is one simctl privacy grant|reset all <bundle> call, and your run shows simctl privacy has no notifications service on this runtime. So setPermissions {all: allow} does not grant notifications and {all: unset} does not reset them, but the step still reports success. The replay docs say unset fully resets and that unservable names fail loudly. A flow that relies on all for notifications will pass that step and fail later, far from the cause. I believe Maestro's iOS all does cover notifications through applesimutils; can you confirm? The smallest fix is to say in the replay docs and the CHANGELOG entry that iOS all does not cover notifications. A warning from the iOS all path when notifications is left unchanged would be better.

I did not run a device or tests; this comes from the code and your posted output. CI is green: 15 checks, 0 failing at aa9186d. With that note or warning in place, this is ready for human review.

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