Skip to content

fix(android): warn when a permission revoke kills the session app - #1856

Merged
thymikee merged 8 commits into
mainfrom
fix/1796-android-revoke-hint
Aug 19, 2026
Merged

fix(android): warn when a permission revoke kills the session app#1856
thymikee merged 8 commits into
mainfrom
fix/1796-android-revoke-hint

Conversation

@thymikee

@thymikee thymikee commented Aug 18, 2026

Copy link
Copy Markdown
Member

Summary

On Android, settings permission deny|reset <perm> maps to pm revoke, and Android kills the app process whenever a runtime permission the app currently holds is revoked. A grant → deny/reset sequence therefore silently left the session on the launcher; the next selector failed with Selector did not match and nothing pointed at the cause (#1796, surfaced by #1793 / #1781 A1).

The revoke path now reads the prior grant state (dumpsys package <pkg>runtime permissions: … granted=true) before pm revoke and, when the permission was granted, says so on the response — issue option (a) + (c). No auto-relaunch: the issue's suggested surface is a hint, and relaunching implicitly would change what settings does to the session (open --relaunch is a one-liner the agent chooses); noted here as the alternative, not taken.

  • Response (--json, SDK, MCP) for deny/reset now carries permission (the resolved Android permission) and priorGrantState: granted | not_granted | unknown — read for the user pm revoke acts on, from the dump's Packages: > User <id>: > runtime permissions: nesting. For granted and unknown, warnings: ["<perm> was granted before this revoke, and Android kills an app when a granted permission is revoked: if <pkg> was running it is no longer. Relaunch it with open <pkg> --relaunch before the next interaction."]. The consequence is phrased conditionally on purpose: this implements option (a), so process death is never observed, and dumpsys reports granted=true for any user profile while pm revoke acts on the current one — asserting the app was killed would be false on a multi-user grant or a not-running app. Warnings are appended as an array on the shared warnings channel (compose, never clobber). grant is unchanged (no state read).
  • Human CLI: settings gains the shared messageWithWarningsOutput formatter (message line + one Warning: line per entry — same rendering open/debug use), so the hint is not --json-only. Output is byte-identical to before when there are no warnings.
  • Docs: website/docs/docs/commands.md (next to the pm revoke mapping) and the settings CLI help detail describe the kill + open <app> --relaunch recovery.

unknown is a first-class answer, not a synonym for not_granted: a failed/unparseable dump or an unresolvable acting user gets the same relaunch guidance without claiming what the state was. The read adds one am get-current-user and one dumpsys package call, on revokes only.

Closes #1796.

Validation

Unit (src/platforms/android/__tests__/settings.test.ts, permission-grant-state.test.ts, src/commands/capture/index.test.ts): 404 Android tests green. The load-bearing ones:

  • Exact argv and call order for a nonzero foreground user — deny microphone, reset camera, reset notifications, and grant each assert the full calls array, so a mutation that omits --user fails even though the response shape is unchanged.
  • Read and mutation agree about which user: foreground user 10 holds the permission, user 0 does not → priorGrantState: granted.
  • Unresolvable foreground user → no --user (platform default preserved) and priorGrantState: unknown.
  • Prior state read before the revoke; granted / not_granted / all three unknown causes; install-permission section, other users' blocks, and post-Packages: sections excluded.

Planted red, each restored afterwards:

  • Drop --user from the mutations (the pre-fix production behaviour) → 7 failures, including - '--user', - '10' in the argv diff and the call-order assertion shell pm revoke com.example.app … (no --user).
  • Restore the pre-fix state model (global granted=true scan, failures collapsing to "nothing granted") → 10 failures, + 'not_granted' - 'unknown'.
  • Restore the pre-fix reporting (members.find-style first hit) → covered by the earlier evidence in this PR.

Live, Pixel 7 CI / API 36 emulator, built CLI from this head. The defect and the fix, on the same device, with a secondary user created and the foreground user switched to it (am switch-user 10):

before (both users granted)
    User 0:  android.permission.RECORD_AUDIO: granted=true
    User 10: android.permission.RECORD_AUDIO: granted=true

--- pre-fix behaviour: bare `pm revoke` (foreground user 10)
    User 0:  android.permission.RECORD_AUDIO: granted=false   ← wrong user edited
    User 10: android.permission.RECORD_AUDIO: granted=true    ← running app untouched

--- this head: agent-device settings permission deny microphone (foreground user 10)
    "permission": "android.permission.RECORD_AUDIO", "priorGrantState": "granted",
    warning: "… if com.callstack.agentdevicelab was running it is no longer. Relaunch it with open … --relaunch …"
    User 0:  android.permission.RECORD_AUDIO: granted=true    ← untouched
    User 10: android.permission.RECORD_AUDIO: granted=false   ← the foreground user's grant
    pid after: ''                                             ← app actually killed

Single-user runs on the same device: reset while not granted → not_granted, no warning, pid unchanged; grant then deny → granted + warning, pid empty, launcher resumed; open --relaunch restores the app. Secondary user removed, foreground user restored to 0, sessions closed, dev daemon stopped, emulator shut down (my own AVD on port 5562, not the shared devices).

unknown is unit-covered only: a healthy device does not fail its own dumpsys or am get-current-user on demand, and faking one live would prove less than the three seeded causes do.

pnpm check:affected --run green. Fallow flagged two intermediate drafts (the first setAndroidPermission, then the first parser at cyclomatic 19); both were split rather than waived.

Touched files: 14 — production: src/platforms/android/settings.ts, settings-permission.ts (new), permission-grant-state.ts (new), src/commands/capture/settings.ts, src/commands/output-common.ts; tests: android/__tests__/{settings,settings-permission,permission-grant-state}.test.ts, src/commands/capture/index.test.ts, src/__tests__/test-file-size-ratchet.test.ts, test/integration/provider-scenarios/{android-lifecycle.test.ts,android-settings-contract.ts,android-world.ts}; docs: website/docs/docs/commands.md. Skills untouched.

Review follow-up (2026-08-19)

  • Softened the warning per review: it no longer asserts the app was killed. dumpsys granted=true matches any user profile while pm revoke acts on the current one, and the app need not have been running — this PR implements option (a) and never observes process death, so the message states the platform rule and leaves the consequence conditional. The pinned unit assertion, the docs line, and the settings help detail were updated with it; the live emulator run above was re-executed against the new text on the final build (deny while granted → the quoted Warning line, pidof empty, launcher resumed; relaunch → pid 26573; session closed).
  • Not fixed, deliberately: parseAndroidGrantedRuntimePermissions also matches the install permissions: section of the dump. It is masked in practice — pm revoke on a non-changeable install permission throws, so no success response can carry a warning derived from that section — and narrowing the parse to the runtime permissions: block would add scanner state for a case the command cannot reach. Say so if you'd rather have it scoped anyway.

Review follow-up 2 (2026-08-19) — state truth

Re-review found the prior-state read wrong in both directions: a failed or unparseable dumpsys collapsed to wasGranted: false (asserting the app was untouched when the state was unknown), and the granted=true scan matched the install permissions: section and every user's block (so another profile's grant could claim a kill that never happened). Fixed:

  • wasGranted: booleanpriorGrantState: 'granted' | 'not_granted' | 'unknown'; unknown carries the relaunch guidance, only not_granted is silent.
  • The read moved to src/platforms/android/permission-grant-state.ts, resolves the acting user with am get-current-user, and walks the dump's nesting instead of matching granted= anywhere.
  • Regressions for failed / unparseable / no-acting-user output, multi-user grants, the install-permission section, and post-Packages: sections — proven red against the old model (10 failures, + 'not_granted' - 'unknown').
  • Live multi-user proof on the device: user 11 granted, user 0 not, revoke as user 0 → not_granted, no warning, app alive (pid 6990).

Details and full evidence in this comment.

Review follow-up 3 (2026-08-19) — read and mutation addressed different users

Re-review found a product bug behind the reporting one: the state read scoped to am get-current-user, but the mutations ran bare pm grant / pm revoke / pm clear-permission-flags. PackageManagerShellCommand defaults those to UserHandle.USER_SYSTEM, so on a device whose foreground user is nonzero the command read one user's state and edited user 0.

This widens what #1796 was: settings permission deny|reset has been editing user 0 regardless of the acting user all along. On a single-user device (the common case, and every previous run in this PR) the two coincide, which is why the original symptom looked purely like a missing hint.

Fixed in 1a36c415's successor: the foreground user is resolved once and passed as --user <id> to pm grant, pm revoke, pm clear-permission-flags, and appops set, and the state read uses that same id. If it cannot be resolved, the mutation keeps the platform default and the state is reported unknown rather than guessed. Regressions pin the exact argv and call order for a nonzero foreground user, and the live before/after above was captured with the emulator's foreground user switched to 10.

Review follow-up 4 (2026-08-19) — no unscoped mutation path remains

The previous revision still issued bare pm/appops commands when am get-current-user did not answer, which is the #1796 defect on the failure path: those default to UserHandle.USER_SYSTEM, so a session running as user 10 had user 0 edited while the response reported only priorGrantState: unknown. It was also a fallback added without approval (AGENTS.md Hard Rule), and the docs' "every permission mutation names the foreground user" was false there.

Resolving the acting user is now a prerequisite: settings permission grant|deny|reset fails with COMMAND_FAILED ("Could not determine which Android user the session runs as, so no permission was changed") plus a hint naming adb -s <serial> shell am get-current-user, and issues no pm, appops, or clear-permission-flags call. The test that locked the fallback in is gone; its replacement asserts the empty mutation call list for all three actions. unknown now has exactly two causes — a failed dumpsys and a dump with no runtime block for that user.

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.32 MB 2.32 MB +2.9 kB
JS gzip 762.2 kB 763.3 kB +1.1 kB
npm tarball 885.6 kB 886.6 kB +972 B
npm unpacked 3.09 MB 3.09 MB +2.9 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.4 ms 25.5 ms +0.1 ms
CLI --help 65.8 ms 66.0 ms +0.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/registry.js +580 B +228 B

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-19 15:38 UTC

@thymikee

Copy link
Copy Markdown
Member Author

P1 state-truth blocker at exact 0350ce62: a failed or unparseable dumpsys package maps to an empty set, then the public response says wasGranted: false and emits no warning. That converts unknown into a false fact and recreates #1796’s silent-dead-app failure when the probe fails. The parser also scans every granted=true line in the full/multi-user dump rather than the current user’s target runtime permission, so another user’s grant can produce a false positive. Use a reliable current-user target-permission query, or model unknown explicitly (tri-state/omitted field plus conservative recovery warning), and add failed/unparseable and multi-user regressions. The production route, warning composition, human/JSON output, and live Pixel evidence otherwise look strong. PR remains draft.

@thymikee
thymikee force-pushed the fix/1796-android-revoke-hint branch from 0350ce6 to 148a6fe Compare August 19, 2026 06:26
@thymikee
thymikee marked this pull request as ready for review August 19, 2026 06:39
@thymikee

Copy link
Copy Markdown
Member Author

Out of draft. CI is green on the actual head 148a6fe6 (rebased onto current origin/main, so it carries #1860's corrected ratchet pin): 30/30 checks pass — Coverage, Integration Tests, and the Smoke lanes included.

Second commit 148a6fe6 applies the review finding: androidRevokedGrantedPermissionWarning no longer asserts the app was killed. It now reads <perm> was granted before this revoke, and Android kills an app when a granted permission is revoked: if <pkg> was running it is no longer. Relaunch it with open <pkg> --relaunch before the next interaction. — the prior-grant read proves neither that the app was running nor that the grant belonged to the current user profile (dumpsys reports granted=true for any user, pm revoke acts on the current one), and this PR implements option (a), so process death is never observed. The pinned unit assertion, commands.md, and the settings CLI help detail moved with the string, and a new assertion pins the conditional clause.

Re-verified end to end on the new text: red-then-green unit proof re-ran (Tests 6 failed | 28 passed against pre-fix production files → 34 passed), and the live emulator sequence was re-executed on the final build against emulator-5556 — deny-while-granted prints the quoted Warning line, pidof is empty, the launcher is topResumedActivity, open --relaunch restores the app (pid 26573), session closed and dev daemon stopped.

One pnpm check:affected --run in the middle of this went red on test/integration/provider-scenarios/ios-record-trace.test.ts (Test timed out in 5000ms). It is the documented contention shape, not this diff: the failing test alternated between the file's two cases across runs, both cases pass in isolation here (3 consecutive clean runs) and on a same-base branch, and the file is iOS-only while this change is Android plus one CLI formatter. The final gate run was clean (Test Files 317 passed (317), check:affected: all runnable checks passed), and GitHub's own Coverage/Integration lanes are green above.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 148a6fe6: still blocked on state truth. The warning wording is now appropriately conditional, but readAndroidGrantedRuntimePermissions() still maps a failed or unparseable dumpsys package to an empty set, so the public response asserts wasGranted: false and omits recovery guidance when state is actually unknown. The parser also scans every granted=true line across install-permission and all-user sections, so another profile/install grant can assert wasGranted: true for a current-user revoke that did not revoke a held runtime permission. Use a reliable current-user runtime query or model unknown explicitly with conservative guidance, and add failed/unparseable, multi-user, and install-section regressions. Warning composition, live Pixel evidence, and all 30 exact-head checks otherwise look strong.

@thymikee

Copy link
Copy Markdown
Member Author

Blocker accepted, and the "masked in practice" wave-through was wrong — the install-section scan is reachable, not just theoretically. Fixed in 1a36c415 (rebased onto current origin/main).

The new state model

wasGranted: boolean is replaced by a typed three-state field on the response:

priorGrantState: "granted" | "not_granted" | "unknown"
  • granted → warning naming open <app> --relaunch (unchanged wording).
  • unknown → the same relaunch guidance, without claiming what the state was: "Whether <perm> was granted before this revoke could not be read (adb did not report the acting user's runtime permission state), and Android kills an app when a granted permission is revoked: <pkg> may no longer be running. Relaunch it with open <pkg> --relaunch …" There is no longer any path that answers a bare not_granted on missing evidence.
  • not_granted → silent, which is now a claim the code has actually earned.

unknown is produced by all three real causes: dumpsys exiting non-zero, output with no runtime-permission block for the acting user, and an unresolvable acting user.

The read itself

The reader moved to its own module (src/platforms/android/permission-grant-state.tssettings.ts was at 488 lines, past the extract-before-adding tripwire) and does two things the old scan did not:

  1. Resolves the acting user (am get-current-user) — pm revoke without --user acts on the caller's user, so the state has to be read from that user's block.
  2. Walks the dump's nesting rather than matching granted= anywhere: top-level Packages:User <id>:runtime permissions:, membership decided by indentation. That excludes the install permissions: section, other users' blocks, and the later top-level sections (Queries:, Shared users:, Dexopt state:) which repeat User <id>: and can repeat grant lines.

On the fixture package the install section really does carry eight granted=true lines (MODIFY_AUDIO_SETTINGS, INTERNET, WAKE_LOCK, …), so the old parser's answer depended on which permission you asked about — this was luck, not masking.

Regressions

New permission-grant-state.test.ts (6) over a dump captured from the real device: acting-user scoping, install-permissions exclusion, missing block → unknown, unparseable → unknown, empty block → legitimately empty, and post-Packages: sections unable to reopen the scan. settings.test.ts adds the response-level cases: the three unknown causes, and a multi-user dump where user 10 holds the permission and user 0 does not.

Planted red — restoring the pre-fix model (global granted=true scan, failures collapsing to "nothing granted", no unknown):

Failed Tests 10
 FAIL permission-grant-state.test.ts > runtime grants are read from the requested user only
 FAIL permission-grant-state.test.ts > install permissions never answer for a runtime permission
 FAIL permission-grant-state.test.ts > a user with no runtime-permission block is unknown, not empty
 FAIL permission-grant-state.test.ts > unparseable output is unknown rather than not-granted
 FAIL permission-grant-state.test.ts > a dump without a Packages section is unknown
 FAIL permission-grant-state.test.ts > sections after Packages: cannot reopen the scan
 FAIL settings.test.ts > … reports unknown (not not_granted) when dumpsys fails
 FAIL settings.test.ts > … reports unknown (not not_granted) when dumpsys output is unparseable
 FAIL settings.test.ts > … reports unknown (not not_granted) when the acting user cannot be resolved
 FAIL settings.test.ts > … deny reads only the acting user's block
Tests  10 failed | 21 passed (31)

AssertionError: Expected values to be strictly equal:
+ actual - expected
+ 'not_granted'
- 'unknown'

Restored: 31 passed.

Live (Pixel 7 CI, API 36, emulator-5562, built CLI from this head)

A. reset while NOT granted      → "priorGrantState": "not_granted", no warnings; pid 6509 unchanged
B. grant, deny while GRANTED    → Warning: … if com.callstack.agentdevicelab was running it is no longer …
                                  pid after deny: ''      (process gone)
D. reset while GRANTED (json)   → "priorGrantState": "granted" + warnings[]

Multi-user, the case the review named — a secondary user created on the device, the permission granted there, revoked as user 0:

    User 0:
        android.permission.RECORD_AUDIO: granted=false, flags=[ USER_SENSITIVE_WHEN_GRANTED|USER_SENSITIVE_WHEN_DENIED]
    User 11:
        android.permission.RECORD_AUDIO: granted=true
--- acting user: 0
--- agent-device settings permission deny microphone → "priorGrantState": "not_granted"   (no warning)
--- app alive after the revoke: pid 6990

The old scan would have reported granted=true here and warned about a kill that provably did not happen. Secondary user removed, sessions closed, dev daemon stopped, emulator is my own (Pixel_7_CI on 5562, not the shared 5554/5580).

unknown is unit-covered only: a healthy device does not fail its own dumpsys on demand, and faking one live would prove less than the three seeded causes do.

pnpm check:affected --run green (318 test files). Fallow flagged the first parser draft at cyclomatic 19; it is now three small slice helpers (topLevelSection / nestedBlock / grant scan) and clean.

Docs and help moved with the field: commands.md documents all three states and that unknown ≠ "app was left alone", and the settings CLI detail says the same.

@thymikee

Copy link
Copy Markdown
Member Author

P1 at exact 1a36c415: the new reader scopes state to am get-current-user, but the production mutation still runs bare pm revoke. Android’s PackageManagerShellCommand defaults grant/revoke to UserHandle.USER_SYSTEM unless --user is supplied, so when the foreground user is nonzero this reads one user’s state and revokes user 0. The reset flag operations omit --user too. Make the read and mutation share one explicit user identity, and add a nonzero-current-user regression that pins the exact adb arguments and call order. The tri-state parser, install/other-user/post-Packages: exclusions, unknown handling, warning composition, output, docs, and planted-red evidence otherwise look clean. Exact-head CI is still running. The PR body’s Validation section also retains stale wasGranted/34-test evidence and should be refreshed after the fix.

@thymikee

Copy link
Copy Markdown
Member Author

P1 confirmed on hardware and fixed in 19254967 (+ 5e285ef7 for the provider lane). You were right, and it is worse than a reporting mismatch — I reproduced it on the emulator before touching any code.

The defect, proven on device

Pixel 7 CI / API 36, secondary user created, foreground switched to it (am switch-user 10), permission granted for both users, then the exact command production ran:

$ adb shell am get-current-user
10
$ adb shell pm revoke com.callstack.agentdevicelab android.permission.RECORD_AUDIO   # bare, as shipped
    User 0:  android.permission.RECORD_AUDIO: granted=false   ← edited
    User 10: android.permission.RECORD_AUDIO: granted=true    ← the running app, untouched

So settings permission deny|reset has been editing user 0 regardless of the acting user all along — the tri-state read I added just made the disagreement visible. On a single-user device the two coincide, which is why every previous run in this PR (and the original #1796 repro) looked purely like a missing hint. I have said this plainly in the PR body: it widens what #1796 actually was.

Fix

The foreground user is resolved once per permission command and passed explicitly to every mutation — pm grant, pm revoke, pm clear-permission-flags (both flags), and appops set — and the state read takes that same id, so the two halves cannot address different users by construction. When the id cannot be resolved, the mutation keeps the platform default and the state is reported unknown rather than guessed; there is no path that invents a user.

The regression you asked for

setAndroidSetting permission <deny microphone | reset camera | reset notifications> addresses the foreground user in every adb call asserts the entire calls array — exact argv, exact order — with the fake adb answering am get-current-user10:

['shell', 'am', 'get-current-user'],
['shell', 'dumpsys', 'package', 'com.example.app'],
['shell', 'pm', 'revoke', '--user', '10', 'com.example.app', 'android.permission.RECORD_AUDIO'],
['shell', 'pm', 'clear-permission-flags', '--user', '10', …, 'user-set'],
['shell', 'pm', 'clear-permission-flags', '--user', '10', …, 'user-fixed'],
['shell', 'appops', 'set', '--user', '10', 'com.example.app', 'POST_NOTIFICATION', 'default'],

plus grant addresses the foreground user too, deny reads the same user it revokes (user 10 granted, user 0 not → granted), and omits --user when the foreground user is unknown (→ platform default + unknown).

Planted red — restore the bare calls (userArgs = []), keeping the read scoped: 7 failures, including the argv diff - '--user', - '10' and the order assertion showing shell pm revoke com.example.app …. A response-shape assertion passes in that state, which is exactly your point.

Live multi-user evidence, post-fix

Same device, foreground still 10, through the built CLI:

--- agent-device settings permission deny microphone
    "permission": "android.permission.RECORD_AUDIO", "priorGrantState": "granted"
    warning: "… if com.callstack.agentdevicelab was running it is no longer. Relaunch it with open … --relaunch …"
    User 0:  android.permission.RECORD_AUDIO: granted=true    ← untouched
    User 10: android.permission.RECORD_AUDIO: granted=false   ← the foreground user's grant
    pid after: ''                                             ← app actually killed

The kill is the part the old code could never produce on this device: it was revoking a user whose app was not running. Secondary user removed, foreground restored to 0, session closed, daemon stopped, emulator shut down.

Provider lane

5e285ef7: the scripted ADB provider only answered the unscoped pm grant|revoke, and the Settings contract asserted the unscoped transcript entry — so the provider-integration lane could not see which user a mutation addressed. Both now expect am get-current-user and the --user-scoped call. That failure was real, not flaky: it reproduced deterministically and named the unscripted shell am get-current-user.

CI on 5e285ef7 is running; pnpm check:affected --run is green locally (318 test files, provider-integration and coverage included). The PR body's Validation section has been rewritten — the stale wasGranted/34-test text is gone.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 5e285ef7: clean. The mutation and prior-state read now share one resolved foreground user; full nonzero-user argv/order regressions and the provider transcript make the former bare-pm path red, while the parser remains constrained to that user's runtime block. The PR body includes live multi-user Pixel evidence, and CI is green so far (Coverage + Android/iOS smoke still running). Code review is ready for human review; merge readiness awaits those exact-head lanes.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 19, 2026
@thymikee

Copy link
Copy Markdown
Member Author

Size and design pass done in 47f2bbe7; the ratchet was already handled in 512c3908.

1. The ratchet

Fixed before this message, and the pin went down, not up: 1597 → 1559. I did not append to android-lifecycle.test.ts — I extracted assertAndroidSettingsContract (the whole settings-flow transcript check, 43 lines) into a sibling android-settings-contract.ts, so the over-tripwire file shrank by 38 lines while gaining the new --user assertions. The ratchet's own instruction ("extract instead of adding to a file over the tripwire") is satisfied literally, and its equality clause then required lowering the pin in the same PR, which is the edit you see. A new android-permission-revoke.test.ts would also have worked, but it would have left the 1,597-line monolith untouched; this way the debt actually goes down.

2. Size pass — measured

File before after Δ
src/platforms/android/settings.ts 505 265 −240
src/platforms/android/settings-permission.ts 238 +238 (new)
src/platforms/android/permission-grant-state.ts 134 120 −14
src/platforms/android/__tests__/settings.test.ts 619 126 −493
src/platforms/android/__tests__/settings-permission.test.ts 321 +321 (new)
src/platforms/android/__tests__/permission-grant-state.test.ts 132 93 −39
test/integration/provider-scenarios/android-lifecycle.test.ts 1597 1559 −38
test/integration/provider-scenarios/android-settings-contract.ts 54 +54 (new)

Net −211 lines across the touched set, and no file in it is over its tripwire any more: settings.ts was at 505, past the 500 "extract before adding behavior" line, which the earlier revisions had quietly crossed.

Deleted as dead or defensive:

  • androidPriorGrantState(grants, permission) — a wrapper for one map lookup with one production call site. Inlined as grants?.get(permission) ?? 'unknown'.
  • topLevelSection + the TOP_LEVEL_SECTION regex — the Packages: section is a nested block whose header sits at indent 0, so nestedBlock already answers it. Two constructs, one behaviour.
  • readDumpLines — single-use helper, inlined into the parse.
  • The userId === undefined ? undefined : await read(...) branch at the call site: the read now owns "no acting user, no state", which is where that decision belongs.
  • The grants map narrowed from ReadonlyMap<string, AndroidPriorGrantState> to ReadonlyMap<string, 'granted' | 'not_granted'>. unknown was never a map value — absence carries it — so the wider type was modelling a state that cannot occur, and the parser tests now read the map directly (?.get(x)'granted' | 'not_granted' | undefined), which is sharper than routing through a helper that flattens "no map" and "no entry" into one word.

Kept, and load-bearing despite looking defensive: Number.isInteger(parsed) && parsed >= 0 in readAndroidCurrentUserId. That is not a typed value — it is whatever the device shell printed, i.e. a trust boundary, and a garbage id would otherwise be spliced into --user. Comment added saying so. Likewise the two-step read (map before the revoke, lookup after) survives because photos only learns which permission it revoked by probing the device.

Tests: higher coverage, fewer cases. The permission tests moved to settings-permission.test.ts (1:1 with the new source module) and collapsed into four tables: exact argv+order (5 rows, now including deny notifications and grant), the tri-state (7 rows covering both multi-user directions and all three unknown causes), photos SDK resolution (2 rows), and argument rejection (2 rows). The old per-target flat.includes(...) notification tests are gone because the argv table asserts the entire call list for those same targets — strictly stronger. The parser file folded seven cases into two tables plus the two structural cases.

3. Every red-proof re-run after the consolidation

Revert Result
userArgs = [] (drop --user from every mutation) 7 red: all 5 argv rows + both photos rows
Pre-fix state model (global granted=true scan, absence → not_granted) 13 red across both files, incl. install-section, other-profile, and all four unknown-input rows
Drop NOT_REGULAR_FILE_HINT (#1853, re-run on current origin/main) 5 red, + 'Retry with --debug…' - 'agent-device only reads and writes regular files…'
Reword CONCURRENT_REPLACEMENT_HINT 2 red (unchanged from the earlier proof)

Nothing was weakened to save lines: the tri-state, the unknown handling, the install/other-user/post-Packages: exclusions, the argv-and-order pins and the warning wording all still fail under their own reverts.

pnpm check:affected --run green on 47f2bbe7 (320 test files). CI running.

One note on the previous head 512c3908: its single red was Smoke Tests failing at AgentDeviceRunnerUITests-Runner … Failed call to AXDisableAccessibilityOnTermination: kAXErrorCannotComplete — an iOS XCTest runner initialisation failure on the macOS runner, unrelated to an Android-only diff.

@thymikee

Copy link
Copy Markdown
Member Author

CI green on 47f2bbe7: 30/30 checks pass, including Coverage (where the test-file-size ratchet lives) and both Smoke lanes. The ratchet is satisfied by extraction, not by a raised pin — android-lifecycle.test.ts is 1559 lines against a pin lowered to 1559, down from 1597.

@thymikee

Copy link
Copy Markdown
Member Author

[P1] Do not mutate after losing the acting-user identity. When am get-current-user fails, this builds userArgs = [] and still issues bare pm/appops commands. Those target USER_SYSTEM (user 0), so a session running as user 10 again changes user 0 while reporting only priorGrantState: unknown—the core defect this PR is intended to eliminate. The fallback test in settings-permission.test.ts locks that behavior in, and the docs’ “Every permission mutation names the foreground user” claim is false on this path. The comments needed to defend this workaround are themselves the design smell: make resolving the acting user a prerequisite for any permission mutation, returning a structured failure and recovery hint if it cannot be resolved, or model it as a session-owned invariant. Do not keep the bare-command fallback. Exact-head CI is otherwise green; size remains below the heightened-review trigger. The body’s touched-file count is also stale: 14, not 8.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 19, 2026
settings permission deny|reset maps to pm revoke, and Android kills the
app's process whenever a runtime permission it currently holds is revoked,
so a grant -> deny/reset sequence silently left the session on the launcher
and the next selector failed with no hint. The revoke path now reads the
prior grant state from dumpsys package first and, when it was granted,
returns wasGranted: true plus a warning naming open <app> --relaunch; the
settings CLI output renders response warnings, and commands.md documents
the behavior next to the pm revoke mapping.

Closes #1796
Review finding: the warning asserted the app had been killed, inferred only
from the prior grant state. dumpsys reports granted=true for any user
profile while pm revoke acts on the current one, and the app need not have
been running, so the claim could be false. State the platform rule and make
the consequence conditional; the relaunch guidance is unchanged.
A failed or unparseable dumpsys read as "nothing granted", so the response
asserted the app was untouched when the state was simply unknown, and the
grant scan matched every granted=true line — install permissions and other
users' blocks included — so another profile's grant could claim a kill that
never happened. Both directions of the same defect.

The read now resolves the acting user (am get-current-user) and walks the
dump's nesting (Packages: > User <id>: > runtime permissions:), and reports
priorGrantState: granted | not_granted | unknown. unknown carries the same
relaunch guidance without claiming what the state was; only not_granted is
silent.
The tri-state read scoped state to am get-current-user, but the mutations
ran bare pm grant/revoke and clear-permission-flags.
PackageManagerShellCommand defaults those to UserHandle.USER_SYSTEM, so on
a device whose foreground user is nonzero the command read one user's state
and edited user 0 — leaving the running app's permission untouched while
reporting on a user it did not change.

Proven on a Pixel 7 / API 36 emulator with the foreground user switched to
10: a bare pm revoke flipped User 0 to granted=false and left User 10
granted=true.

The foreground user is now resolved once and passed as --user to pm
grant/revoke, pm clear-permission-flags, and appops set, and the state read
takes that same id. When it cannot be resolved the mutation keeps the
platform default and the state is reported unknown rather than guessed.
…enario

The scripted ADB provider answered only the unscoped pm grant/revoke form,
and the Settings contract asserted the unscoped transcript entry, so the
provider lane could not see which user a permission mutation addressed.
…olith

The user-scoped argv assertions pushed android-lifecycle.test.ts past its
size ratchet, whose instruction is to extract rather than grow a file over
the tripwire. assertAndroidSettingsContract moves to a sibling module and
the pin drops 1597 -> 1559.
Size/design pass on the #1796 change:
- settings.ts was 505 lines (past the 500 extract-before-adding tripwire);
  the permission family moves to settings-permission.ts and the dispatcher
  drops to 265.
- permission-grant-state.ts loses topLevelSection (a nestedBlock with an
  indent-0 header), its single-use line reader, and androidPriorGrantState
  (one map lookup at its only production call site).
- the grants map narrows to 'granted' | 'not_granted': unknown was never a
  value, absence is what carries it, so the tests read the map directly.
- the permission tests move to settings-permission.test.ts and consolidate
  into argv/tri-state/photos/rejection tables; the parser tests fold seven
  cases into two.

Every red-proof re-run after the consolidation: dropping --user reds 7
argv/photos cases, and the pre-fix state model reds 13 across both files.
The fallback issued bare pm/appops commands when am get-current-user did
not answer, which is the #1796 defect itself: those default to
UserHandle.USER_SYSTEM, so a session running as user 10 had user 0 edited
while the response reported only priorGrantState: unknown. It was also a
fallback added without approval, and the docs' claim that every mutation
names its user was false on that path.

Resolving the acting user is now a prerequisite: setAndroidSetting
permission fails with COMMAND_FAILED and a recovery hint, issuing no pm,
appops or clear-permission-flags call at all. The test that locked the
fallback in is replaced by one asserting the empty mutation call list for
grant, deny and reset.
@thymikee
thymikee force-pushed the fix/1796-android-revoke-hint branch from 47f2bbe to 0f5d1cc Compare August 19, 2026 15:08
@thymikee

Copy link
Copy Markdown
Member Author

Accepted without reservation — the fallback was the defect on its own failure path, and it was a fallback I added unilaterally, which AGENTS.md forbids. Fixed in 0f5d1cc3 (rebased onto current origin/main).

The refusal

Resolving the acting user is now a prerequisite of the whole permission family, not an input to it:

async function requireAndroidPermissionUser(device: DeviceInfo): Promise<number> {
  const userId = await readAndroidCurrentUserId(device);
  if (userId !== undefined) return userId;
  throw new AppError(
    'COMMAND_FAILED',
    'Could not determine which Android user the session runs as, so no permission was changed.',
    {
      deviceId: device.id,
      hint: `Check adb -s ${device.id} shell am get-current-user — if the device is still booting, retry once it reports a user. agent-device refuses to change permissions it cannot scope, because pm would silently apply them to user 0.`,
    },
  );
}

userArgs is consequently always ['--user', String(userId)] — the empty case is gone, and with it the comment that had to explain why issuing an unscoped command was acceptable. You were right that the comment was the smell: there was no way to write it honestly.

Knock-on simplifications: readAndroidRuntimePermissionGrants takes userId: number again (its undefined branch existed only to serve the fallback), and unknown now has exactly two causes — a failed dumpsys and a dump with no runtime block for that user. COMMAND_FAILED per ADR 0010 §2: a genuine runtime failure of a well-formed request, not a capability gap or bad input.

The replacement test

The old omits --user when the foreground user is unknown is deleted. Its replacement runs for all three mutating actions and asserts the empty mutation call list, not the response shape:

test.each(['grant', 'deny', 'reset'] as const)(
  'setAndroidSetting permission %s refuses to mutate when the acting user cannot be resolved',
  ...
        await assertRejectsAppError(..., {
          code: 'COMMAND_FAILED',
          message: /Could not determine which Android user/,
          hint: /am get-current-user/,
        });
        // The load-bearing assertion: the resolution attempt is the ONLY adb call. No pm, no
        // appops, no clear-permission-flags — nothing that could edit user 0's state.
        assert.deepEqual(calls, [['shell', 'am', 'get-current-user']]);

(The hint matcher is the one #1853 added — picked up by rebasing onto current main.)

Planted red — restore the reviewed-out fallback (userArgs = userId === undefined ? [] : … plus the grants ternary):

Failed Tests 3
 FAIL … > permission grant refuses to mutate when the acting user cannot be resolved
 FAIL … > permission deny  refuses to mutate when the acting user cannot be resolved
 FAIL … > permission reset refuses to mutate when the acting user cannot be resolved
AssertionError: Missing expected rejection.
 ❯ assertRejectsAppError src/__tests__/test-utils/app-error.ts:14:3

Restored: 20 passed. The other reverts still red as before (--user stripped → 7; pre-fix state model → 13).

Swept for any remaining unscoped path: all five pm grant|revoke|clear-permission-flags sites and the one appops set carry ...userArgs, and readAndroidCurrentUserId now has exactly one caller — the guard.

Docs

Both surfaces corrected, since the old wording was false on this path. commands.md now ends that bullet with: "Resolving that user is a prerequisite — if am get-current-user does not answer, settings permission fails with COMMAND_FAILED and changes nothing rather than applying the mutation to user 0." The settings CLI detail says the same in one clause.

Corrected count

14 files, not 8 — the body has been fixed and now enumerates them: 5 production, 8 test, 1 docs.

pnpm check:affected --run green post-rebase (324 test files). CI running on 0f5d1cc3; I'll confirm the exact-head result.

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 0f5d1cc3: clean. Resolving the acting Android user is now a prerequisite, so a failed am get-current-user returns a structured COMMAND_FAILED before any permission mutation can target user 0. The replacement regression covers grant, deny, and reset and proves the former bare-command fallback red. The shared-user mutation/read route, state parser, response/docs, provider transcript, live multi-user evidence, and size report all look sound. Code review is ready for human review; merge readiness awaits the in-progress Linux and iOS smoke lanes.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 19, 2026
@thymikee
thymikee merged commit d29dc22 into main Aug 19, 2026
29 of 30 checks passed
@thymikee
thymikee deleted the fix/1796-android-revoke-hint branch August 19, 2026 15:35
@thymikee

Copy link
Copy Markdown
Member Author

CI note on 0f5d1cc3: the one non-green lane was cancelled, not failed, and it never reached project code.

job: Smoke Tests (Linux workflow)   conclusion: cancelled   15:08:44 → 15:39:08
  success   Set up job
  success   Checkout
  cancelled Install Linux desktop dependencies     ← apt stalled ~30 min, hit the job timeout
  skipped   Setup toolchain
  skipped   Start Xvfb and D-Bus
  skipped   Run Linux replay smoke test
  skipped   Execute Linux replay smoke test

The smoke test itself was skipped, so nothing in this diff was exercised by the lane that went red. Everything test-bearing passed on the same head — CI, Android, iOS, macOS, Size, Test App Build Cache — and the Linux lane passed on both previous heads of this branch (47f2bbe7, 512c3908). I re-ran the workflow (attempt 2) rather than pushing a no-op commit; I'll confirm the result.

gh pr checks renders cancelled as fail, which is why the count read 30 pass / 1 fail.

@thymikee

Copy link
Copy Markdown
Member Author

Follow-up on the Linux lane, now that the PR is merged — the rerun (attempt 2) was cancelled at the identical step, 30 minutes again:

attempt2 conclusion: cancelled
  success   Set up job
  success   Checkout
  cancelled Install Linux desktop dependencies
  skipped   Setup toolchain / Xvfb / AT-SPI2 / Verify environment
  skipped   Run Linux replay smoke test

Two identical stalls is a pattern, so I checked whether it was specific to this branch. It is not — at the same moment, Linux runs on agent/wave4-get, test/1781-b1-daemon-leak-oracle and claude/1832-c3-residues were all sitting on that same step, and four main runs were cancelled in the same window.

Root cause is in the lane itself, not in any PR: .github/workflows/linux.yml runs a bare apt-get update/apt-get install (lines 48-60) with no retry and no per-step timeout, under the job's timeout-minutes: 30. A slow package mirror therefore consumes the entire job budget and surfaces as a red check on unrelated PRs. I have filed that separately as CI hardening — it wants a per-step timeout, a bounded retry, or the deps baked into a prebuilt image.

To be explicit about what this means for the merge: the Linux smoke test never executed on this change (the step was skipped both times), so it neither validated nor contradicted it. The lanes that did exercise the diff — CI, Android, iOS, macOS, Size, Test App Build Cache — all passed on 0f5d1cc3.

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

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

android: settings permission reset/deny kills the app when the permission was granted — no hint

1 participant