Skip to content

fix(describe): stop ordering a simulator reboot on every externally-booted read - #583

Draft
latekvo wants to merge 3 commits into
mainfrom
fix/issue-521-degraded-hint
Draft

fix(describe): stop ordering a simulator reboot on every externally-booted read#583
latekvo wants to merge 3 commits into
mainfrom
fix/issue-521-degraded-hint

Conversation

@latekvo

@latekvo latekvo commented Jul 28, 2026

Copy link
Copy Markdown
Member

Fixes #521.

The report holds, and is worse than described

Reproduced against an unmodified origin/main tool-server driving a simulator booted the way a developer boots one (plain xcrun simctl boot, no argent involvement), iOS 18.6:

===== describe call #1 =====   ===== #2 =====   ===== #3 =====
hint: "This simulator was not booted through argent — system dialogs and native
modals may not appear. You MUST call boot-device with force=true now to restart
the simulator and apply full accessibility settings before continuing."

Identical on every call, as reported. The part that makes it more than a wording nit is what came back alongside it — a complete, working tree:

ROOT  AXGroup (0.000, 0.000, 1.000, 1.000)
  AXStaticText "19:51"  (0.139, 0.023, 0.102, 0.023)
  AXGroup "Maps" value="Widget"  (0.082, 0.096, 0.395, 0.206)
  ...

And the caveat's own claim does not hold either. Triggering a real system permission alert on that same externally-booted sim (simctl privacy reset location com.apple.Maps + launch):

  AXGroup "Allow “Maps” to use your location?"  (0.193, 0.367, 0.614, 0.296)
  AXButton "Allow Once"  (0.193, 0.524, 0.614, 0.046)
  AXButton "Allow While Using App"  (0.193, 0.570, 0.614, 0.046)
  AXButton "Don’t Allow"  (0.193, 0.617, 0.614, 0.046)

The system dialog reads fine, buttons and all. So an agent obeying "You MUST ... now" restarts the simulator — killing the Metro session, dev client and staged app state — to fix nothing.

Root cause

AXServiceApi.degraded is !entitlementBypassActive, read from IgnoreAXServerEntitlements. That pref is only writable pre-boot, so boot-device is the only thing that ever sets it and any externally-booted sim reads as degraded for its whole life. The pref bypasses the AX-server entitlement check introduced in iOS 26.5 — on earlier runtimes it is irrelevant to whether the read works. The flag describes how the sim was booted, never whether describe succeeded, and the hint conflated the two.

Change

Resolve the caveat against what the accessibility read produced:

  • Blind read — the reboot has something to gain, so the actionable guidance stays, on every call, and now names what the reboot costs.
  • Populated read — the read is its own proof no reboot is needed. States the limitation instead of ordering anything, once per device per server lifetime.

The per-device gate is applied at the describe tool boundary only. await-ui-element, the flow runner and await-screen-idle poll describeIos directly, treat a present hint on an empty tree as "this emptiness is untrustworthy" (isBlindRead), and fold it into one terminal timeout note — they keep every copy, so a hidden wait still cannot resolve off a blind read.

The native-devtools fallback can fill a tree the accessibility read left empty, and that split has to key on the accessibility read rather than on the tree that comes back. Its elements are the injected app's own view hierarchy: no SpringBoard chrome, no system dialog, and no evidence the accessibility subsystem works.

Found doing exactly that on a fresh iOS 18.6 sim. Describing within seconds of simctl boot leaves the accessibility read blind for the rest of that boot (3/3; it survives a tool-server restart, and only boot-device force=true recovers it). With the Maps location prompt on screen in that state, the fallback returned the app's own 20 elements with the dialog absent — and resolving against that tree picked the standing note, which the per-device gate then deleted, so from the second call the agent had a tree silently missing a modal blocking the screen and no caveat at all. The note it briefly carried says "everything else reads normally", which nothing there did. Keying on the accessibility read makes both wordings true of the state that produces them.

I skipped the suppression env var / flag the issue floated as a "maybe" — once the caveat is a single non-directive line per device, there is little left to suppress, and it seemed better not to add config surface for it. Happy to add it if you'd rather have the switch.

Verification

E2E against the branch build, same externally-booted sim:

elements hint
describe #1 14 standing caveat, no MUST/now
describe #2-4 14 (none)
after simctl shutdown (blind) ×2 0 blind caveat, both times
after boot-device force=true ×2 28 (none)IgnoreAXServerEntitlements = 1

Unit: 6 new tests in describe-tool.test.ts. Each was mutation-checked — restoring the MUST wording, removing the dedup, collapsing the blind/standing split, keying the gate globally instead of per device, and re-resolving the caveat against the native-devtools tree each fail exactly one test and nothing else.

Full gate: vitest run 4715 passed / 1 skipped, tsc --build, typecheck:tests, eslint --max-warnings 0, prettier --check, test:scripts 59 passed.

…ooted read

`degraded` is set from the pre-boot accessibility prefs, which only
boot-device can write, so any simulator the developer booted themselves
(`xcrun simctl boot`, Xcode, `expo run:ios`) reads as degraded for its
whole life. describe turned that into "You MUST call boot-device with
force=true now" on every single call.

Both halves of that are wrong. The flag says how the sim was booted, not
whether the read worked: `IgnoreAXServerEntitlements` bypasses a check
that exists on iOS 26.5+, so on earlier runtimes the tree comes back
complete. On an externally-booted iOS 18.6 sim describe returns the full
tree including the Maps location prompt with all three buttons — while
ordering an agent to force-reboot and take the developer's Metro session,
dev client and app state with it.

Resolve the caveat against the tree actually returned. An empty tree
keeps the actionable re-boot guidance, now naming what the reboot costs.
A populated tree gets a statement of the limitation instead of an order,
emitted once per device per server lifetime rather than dozens of times
a session.

The per-device gate sits at the `describe` tool boundary only.
await-ui-element and the flow runner poll describeIos directly, read a
present `hint` on an empty tree as "this emptiness is untrustworthy"
(isBlindRead), and fold it into a single terminal note — so they keep
every copy, and a `hidden` wait can still never resolve off a blind read.

Closes #521
latekvo added 2 commits July 30, 2026 19:51
…blind AX read

The caveat resolved against the tree being returned, so a blind accessibility
read whose native-devtools fallback came back populated was downgraded to the
standing note - and the per-device gate then dropped it entirely from the
second call on.

Those elements are the injected app's own view hierarchy. They carry no
SpringBoard chrome and no system dialog at all, so a populated tree from that
source is no evidence the accessibility subsystem is working. Reproduced on a
fresh iOS 18.6 simulator whose AX read went blind for the whole boot (only
`boot-device force=true` recovers it): with the Maps location prompt on screen,
describe returned the app's 20 elements with the dialog absent, and from the
second call carried no caveat at all - while the standing note it briefly
carried says "everything else reads normally", which nothing here did.

Resolving against the accessibility tree instead makes both wordings true of
the state that produces them: the standing note is now only reachable from a
populated AX read, and this path keeps the blind-read hint that names the
reboot, repeated per call the way every other blind read is.
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.

describe repeats a 'MUST call boot-device with force=true' order on every call for externally booted simulators

1 participant