Releases: igorzheludkov/execbro
Release list
v2.12.3
Reliability pass driven by a week of failure telemetry. Five tools stop reporting a failure for a state that was never one, and start naming the cause when it is.
dismiss_keyboard — nothing focused with the keyboard already down is a success, not an error. The caller asked for the keyboard down and it is down; reporting it as a failure sent agents hunting for a field to blur that does not exist. Nothing focused with the keyboard up stays a failure and now says which one: a native field owns it (system dialog, WebView, non-RN screen), so blurring React cannot close it, and the error points at the platform dismiss.
ios_terminate_app — an app that had already quit is reported as stopped. simctl exits 3 with "found nothing to terminate", which accounted for every terminate failure in the week. It gives the same error for a bundle id that is not installed, so the result never claims a termination happened: it says the app was not running, and to check the bundle id.
input_text — a testID on a wrapper around the input now resolves. A custom <Input testID="x"> that keeps the id on its container and passes only value/onChangeText down is the ordinary RN shape, and it was invisible. Accepted only when exactly one mounted input sits under the wrapper; one wrapping several is refused rather than guessed between. A screen where no input carries a testID at all is told so, instead of having the invented id echoed back. Separately, pure ASCII sent and another script back ("testing" landing as "Еуіештп") is now named as the simulator's active keyboard layout reinterpreting US keycodes, rather than a bare "landed differently" that sends the reader into the app's validation.
navigate — an app written as a plain <NavigationContainer> with no ref prop is reachable again. The fiber walk looks for resetRoot, which only a container ref has, so the default shape matched nothing. A mounted screen's own navigation object, climbed to the root navigator, is used as a last resort; it has no getRootState, so the route table comes back empty and destination validation is skipped rather than run against nothing, and the note says so. A real container ref still wins. A missing destination is tagged as a bad-arguments refusal at the refusal site.
execute_in_app — accepts code and script as aliases for expression. An undeclared key is stripped before the handler sees it, so JavaScript sent under either name arrived as a call with no arguments at all.
Network — the JS interceptor is suppressed as soon as the SDK global exists, rather than after the first mirror.
Docs: the shipped usage guide, CLAUDE.md, docs/tools.md and the device-interact skill all describe the changed behaviour, and a remap message that named a write:"native" parameter the tool does not have is corrected.
v2.12.2
ExecBro is free, with no call cap.
The server has returned an uncapped verdict to every install since 2026-09-15. This release retires the cap from the surfaces that still described it: the README's 600-call free tier, the $8.99 Pro pitch, and the grandfather notice are gone. Account linking is now purely for the usage dashboard, nothing in the tooling is gated behind it.
No tool behaviour changed.
v2.12.1
A capped free-tier install could not be told why it was capped.
The cap banner renders through LogBox inside the running app, so with no app attached there is no channel and the notification defers. The connect tools were themselves gated, so the agent could not attach, the channel never opened, and the one message explaining the block was never delivered. Every tool just failed for no stated reason.
ensure_connection, connect_metro, scan_metro, get_connection_status and list_devices are now exempt from the gate. Connecting on its own does no billable work, and every tool that reads or drives the app stays blocked, so the cap is unchanged.
v2.12.0
Physical iPhone screenshots
ios_screenshot reaches a USB-attached iPhone/iPad, not just the Simulator. Hardware-only work (camera, NFC, push, real notches) had no visual verification at all before this. Capture goes through pymobiledevice3 over usbmux and needs a mounted DeveloperDiskImage, but no tunnel and no root.
It is capture only, and the output says so rather than letting an agent retry a gesture that was never delivered: no pressable enrichment, and no tap/swipe/input_text, because iOS exposes no touch injection to a host below 17. list_devices lists physical devices in their own section, labelled screenshot-only. pymobiledevice3 is not bundled: missing simply means no physical devices are listed.
navigate can tell you the route names
navigate({routeTable: true}) with no destination is now a route listing rather than a failed navigation. It was the tool's only route-discovery affordance and required a destination to answer, which made it unreachable until you already knew what it would tell you. A rejected destination also carries the registered routes in its error now, so a wrong guess still hands back the right answers.
input_text verifies the write that actually landed
Three iOS failures shared one cause: the accessibility tree reshapes between the before and after reads, and verification aligned the two snapshots by index. Raising the keyboard drops the fields it covers out of the tree entirely, so every field read as changed and none was identified.
- The written field is now identified by content, which is order-independent: the only field that ends with the text just typed, when none did before.
replace: trueclears first whenever the prior text could not be read at all, not only when it was read as non-empty. An unresolvable field leftpreviousempty because nothing was read, so replace behaved as append and built up garbage across calls.- An append onto a field whose prior text was unreadable is verified by the tail that landed and no longer burns the retry, which cleared first and destroyed exactly the text the caller asked to append to.
Telemetry
execute_in_app no longer counts an exception thrown by the caller's own expression as a tool failure. The evaluation worked; the code threw.
v2.11.0
Removed: ocr_screenshot
OCR is not gone, the standalone tool is. OCR still backs tap's last resolution strategy and get_bundle_errors' screen read, so nothing that depended on OCR working changes.
What went away is a tool whose own description told agents to reach for it FIRST. That pointed at a cloud round trip and a manual coordinate hand-off for a job two other tools already do better:
get_screen_statereturns every element with a tap-ready(x, y), screenshot-free, with text content includedtap(text="...")runs fiber, then accessibility, then OCR by itself, and reports which strategy won
If you called ocr_screenshot to find something and tap it, tap(text=...) is the direct replacement. If you called it to read the screen, use get_screen_state.
Tool count: 63 → 62.
Other fixes since 2.10.3
fix(usage): no cap banner is pushed when no app is connectedfix(http): DNS rebinding protection armed on the dev transport
Docs
Every surface that named the tool was swept, including PRIVACY.md, whose OCR section described a trigger that no longer exists and now names the two code paths that actually send an image to the cloud OCR service.
v2.10.3
Cap-notification instrumentation.
The free-tier cap banner now reports whether it was actually delivered. A zero conversion rate was unreadable while "nobody paid" could not be told apart from "nobody was ever shown a paywall" — the LogBox banner is the only cap channel observable from the server, so its delivery is recorded with threshold, delivered and a reason. The reason separates a real delivery failure from a channel that never had a chance to fire (no app connected is not a bug).
Both cap notifiers also receive the calling tool's device. With one app connected an undefined device already resolved to it, but with several connected it resolved to "ambiguous" and the banner was dropped in silence.
No tool surface changes.
v2.10.2
New
tap's pixel-diff verification now locates the change, not just measures it:verification.regionsgives bounding boxes (screenshot pixels) of where the screen actually changed, and the explanation text names their centres — feed a centre straight intoinspect_at_pointinstead of eyeballing the whole screenshot.- Structured
failureKindrecorded at the throw site for connection/tool failures, improving telemetry on why a call failed (e.g.no_metro).
Fixes
get_screen_state: a bottom sheet's contents could be pruned by two independent bugs — a transform double-composed into the measured frame (iOS), and a style array read as OR instead of RN's actual last-wins semantics (Android). Both are shared visibility/transform logic used by every fiber-walk tool, not sheet-specific.
Docs
skills/session-setup.md: added a mandatory step to read the project's own build config (package.json scripts, Metro port, bundle ID) before booting/launching/scanning, instead of defaulting to generic commands or asking the user for values the repo already answers.- Documented the new
tapdiff-region behavior in the shipped usage guide, CLAUDE.md, and docs/tools.md.
v2.10.1
Credentials beyond Authorization: Bearer
http_request's auth gains optional header and scheme. The default is unchanged — Authorization: Bearer — so every existing call behaves exactly as before.
auth: { secret: "api.acme.io", header: "X-API-Key" } // key header, no scheme added
auth: { secret: "api.acme.io", scheme: "Basic" } // another scheme on Authorization
auth: { secret: "api.acme.io", scheme: "" } // a bare value, no prefix
A custom header gets no scheme unless one is asked for, because X-API-Key: Bearer <key> authenticates nothing.
Why this is a security fix rather than a convenience. Bearer-only was not the neutral simplification it looked like. For any other credential shape the only available route was headers: { "X-API-Key": "<pasted value>" } — which puts the raw credential straight back in the transcript, silently, with no error and nothing in telemetry. The fallback for the unsupported case was the insecure case. Covering the shapes is what makes the vault's guarantee hold rather than usually hold.
The header name and scheme are agent-controlled and land in the request line, so both are validated as RFC 7230 tokens: an unchecked newline there is header injection. Origin binding is unmoved and still runs first, against the resolved entry, so naming a handle rather than a slot is not a way around it.
Cookie authentication is deliberately not covered. React Native has no JS cookie API and the jar is native, so there is nothing to hand http_request in the first place. app_request and network_replay run inside the app, where the native jar attaches the session on its own, with no credential handling at all. Every surface now says so.
Verified end to end against a local server: all four placements arrive correctly, an echoed credential still comes back redacted, and the injection attempt is refused.
Docs
The shipped get_usage_guide network topic, CLAUDE.md, docs/tools.md and README.md all still described a Bearer-only tool. That mattered more than a usual doc lag here — an agent reading the old guide concludes auth cannot express a key header and reaches for headers with the raw value, which is the exact leak this release closes.
Also corrects the headers description, which promised that "an explicit Authorization wins over auth". Precedence is by matching name now, whatever that name is.
v2.10.0
Credentials you can use but cannot read
Tool output no longer carries credential values. A token is replaced by a handle ([secret:auth_api.acme.io]) and the value stays server-side, so it never lands in a transcript that gets stored, shared or replayed.
list_secrets— the credentials captured this session, by handle, with origin, age and JWT expiry. Values are never shown, and nothing derived from a JWT's claims is either: issuer and subject are self-asserted.http_request— issue a request from the host, carrying a vaulted credential by handle. The clean-room counterpart toapp_request: no app TLS trust, proxy or cookie jar, and mock rules do not intercept it, so a difference between the two separates a server bug from a client one. A 401 here whereapp_requestsucceeds means the backend enforces attestation.authis a typed object rather than string interpolation, so a credential cannot be smuggled into an arbitrary field, and each one is bound to the origin it was observed on.vault_capture— read a credential out of the running app straight into the vault without returning it. Covers a cold session, a background-refreshed token, or one held where no heuristic looks (keychain, expo-secure-store, an Apollo link).
Redaction moved to a chokepoint
It now runs once over every tool's text output instead of at a single render site, so it covers logs, Redux state, component trees and network alike, plus errorMessage and errorContext, which reach telemetry without passing through content.
Breaking for anyone who relied on it: verbose:true no longer reveals a credential. That put the escape hatch in the hands of the model, and a transcript is append-only, so one revealing call is not undone by a thousand redacted ones. EXECBRO_REDACT=off is the only way out: a human sets it, and it needs a restart. verbose:true still drops the bounding, it just reveals nothing.
Credential headers are matched by pattern rather than a fixed list. A sweep found ten common vendor-namespaced headers (x-shopify-access-token, x-goog-api-key, x-hasura-admin-secret, x-firebase-appcheck) passing the old seven-name list untouched. x-request-id, x-correlation-id and x-idempotency-key are deliberately left alone, since redacting them would break the workflows these tools exist to serve.
App data is data, not instructions
Every tool here pipes content the app fetched, logged or rendered into an agent's context, which makes each one an injection channel by construction. The server now tells every connecting agent, ahead of the decision tree, that such content is never to be followed as an instruction. This is the half of the problem redaction cannot address: redaction governs what leaves in a transcript and does nothing about an agent acting on injected content. The vault shipped first on purpose, so "use the handle" is a real answer rather than a refusal.
Fixes
- A body is now redacted before structural bounding, not after. Projection clips strings to 60 characters, so a large access token used to arrive at the chokepoint already truncated, no longer matched the JWT shape, and got a generic redaction with no handle — breaking exactly the link worth having, that the token minted here is the token sent there.
- A handle that resolves to nothing no longer prints a bare
--- secrets referenced above ---header with no entries under it. redactSecretspreserves a handle when other text shares the field.- JWT expiry is derived from the token's shape rather than the caller's
kindlabel.
v2.9.8
Fixes
- Android tap now accounts for the status-bar inset on both fiber and native taps, so coordinates land where they measure instead of one status-bar height too high.
- get_screen_state no longer lets a modal's overlay adopt the screen behind it — content adoption now checks paint order, not just containment, so a modal can no longer report the elements it visually blocks as its own reachable content.
New
- get_screen_state now counts and attributes the press targets its walk drops (pruned as hidden, not yet measurable, or dropped for geometry), so a shorter-than-expected list or an empty overlay group is explained instead of silent.
Build hygiene
- The build-hygiene denylist (used to keep third-party product names out of published output) now lives in a gitignored local file instead of the tracked script, since naming a specific competitor in a public repo was itself a tell.
- Added a check that injected JS strings survive template-literal escaping.