Skip to content

feat(limrun): sync local iOS and Android app installs - #2

Closed
muvaf wants to merge 14 commits into
codex/limrun-native-runnerfrom
codex/limrun-sync-install
Closed

muvaf wants to merge 14 commits into
codex/limrun-native-runnerfrom
codex/limrun-sync-install

Conversation

@muvaf

@muvaf muvaf commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

Upstream review: callstack#4225. This fork PR preserves the isolated layer for review.

Summary

Use Limrun app sync for local iOS and Android install/reinstall, including changed binaries. The package now bundles the real delta encoder and its license notices. One session-owned basis cache replaces per-materialization caches; SDK work retains its source until completion. Install-only sync leaves apps stopped, and plain SDK failures receive typed errors. Asset URL installation remains supported.

This follows native XCTest replacement #4224; its own 38-file change uses released SDK 0.69.1. Scope includes replacing an event-loop polling limit in an Apple deadline test after CI mutation jobs exposed the race. GitHub requires this upstream PR to target main, so review the isolated sync diff. Merge callstack#4224 first, then rebase. CLI syntax is unchanged: agent-device install ./MyApp.app or agent-device install ./app.apk in a Limrun session.

Validation

The full affected gate passes at 8f01e5cd7. Head bafbb7556 adds only test cleanup and the deterministic deadline fixture; pnpm check:affected --run --base 8f01e5cd7 --head HEAD also passes. Both receipts have exactCommit: true. The linked mutation evidence covers the fixture change; CI reruns remain authoritative. Receipts, independent review and live evidence compare base a95049b9a, which fails changed-binary installs on both platforms, with the built final runtime. Remote rebuilds, stopped-state checks and installed-content checks pass; captured uploads show 5.7 KB iOS and 1.6 KB Android deltas. The earlier live run also verifies data removal and uninstall. Required GitHub checks remain CI obligations.

airowe and others added 4 commits November 5, 2026 06:53
…allstack#4223)

* fix(daemon): aim find focus at the parent-owned touch point

The find focus seam dispatches its own point instead of re-entering the
interaction leaf, so it skipped the touch-point decision the acting paths
run. A match fully tiled by interactive children now refuses before
dispatch, and a child owning the center moves the aim to free parent
space. Missing or zero-area rects get the typed invalid-bounds refusal.

Move resolveNodeTouchPoint to @agent-device/selectors/node-touch-point so
the daemon and command-runtime share one owner, and close the
parentOwnedTouchPoint cell of the find-focus-dispatch guarantee row.

Refs callstack#3734

* chore(gates): declare the selectors node-touch-point subpath

The package-boundaries enumeration lists every selectors subpath, so the new export has to be declared for the R11 layering rule to accept it.

* fix(contracts): describe the find focus touch point in its waivers

The offscreen and nonHittable waiver reasons still said the seam dispatches a rect center.

* fix(daemon): aim find focus and type at a nested text field

The touch-point rule treats a nested text field as a competitor to avoid,
so a match wrapping a field reported focus success beside the field and
the typed text then went wherever the focus already was. A sole enabled,
visible field now becomes the target, with the same touch-point, covered
and keyboard checks. Several fields refuse with AMBIGUOUS_MATCH and the
field candidates.

Refs callstack#3734

* style(test): format the find focus contract scenarios

* fix(daemon): keep find focus on the field the locator named

A disabled field promotes to its parent on iOS, and the parent's other
field then counted as the sole one, so find type wrote into a field the
locator never named. The located field now decides first: an enabled one
stays the target and a disabled one refuses with target_disabled, as does
a match holding only disabled fields.

Count only outermost on-screen typed fields, so a hosted text field, a
scrolled-away field and an untyped child no longer change the target. The
rule moves to the find description so MCP clients see it, and it names
web text boxes as unrecognized.

Refs callstack#3734

* fix(daemon): refuse a disabled field reached through promotion

The disabled check only read the located node, so a same-rect wrapper over
a disabled field was still focused, and a label inside one was promoted
past it to an enabled sibling. find type then wrote into another field and
reported success. The field decision now walks from the located node to the
promoted one, and the nearest field on that path is the named field.

The find help, selector docs and nonHittable waiver now state that a named
text field stays the target.

Refs callstack#3734

* fix(selectors): count only measurable fields and read password facts

A field with no usable bounds counted as on-screen, so it could take the
focus or turn a single field into an ambiguity before the touch point could
reject it. A password fact on an unrecognized type was ignored, so focus
went to the container instead.

Say in the help and docs that a named field wins over its neighbours and
that target_disabled is a reason, and record why the nested-field refusal
follows find's refusal contract for printed refs.

Refs callstack#3734

* docs(selectors): split the find focus rule and name the covered refusal

Refs callstack#3734

* docs(selectors): qualify which fields refuse find focus

Refs callstack#3734

* docs(selectors): name the covered refusal's reason

Refs callstack#3734
…tack#4211)

* fix(ios): deliver simulator edge swipes as screen-edge touches

XCTest synthesized touches carry no digitizer edge mask, so on an iOS
simulator gesture swipe left-edge and right-edge never started a
UIScreenEdgePanGestureRecognizer or the navigation edge pop gesture.

Edge swipe presets now plan a screen-edge contact. On an iOS simulator
the runner dispatches it as one HID touch with the edge mask, through
the touchscreen service of the app's display. Other gestures and
physical devices keep their current paths.

Fixes callstack#4208

* fix(ios): report an edge touch whose HID event cannot be created

A frame that failed to build was dropped while the gesture still
reported success. Fail before touch-down, lift early after a failed
move, and return the reason instead.

* fix(ios): send simulator edge touches as the system's locked edge swipe

The edge recognizer on a rotated panel, such as the iPhone Duo inner
panel, never began: FromEdgeFlat/Tip touches carry no swipe direction,
and a fast first move left the edge region in one frame. A hand swipe
on the simulator carries SwipeLocked and the native swipe direction on
every frame and starts slowly.

Send the same mask, ease the contact in along the planned path, and use
the display's oldest touchscreen service when a window adds another.

* fix(ios): pick the touchscreen sender only from readable registry IDs

A service whose registry ID could not be read still counted as a match,
so a later valid service could leave the sender at 0.

* fix(ios): end every simulator edge contact with a reserved terminal event

A touch-up that could not be built after touch-down left UIKit with an
active finger. The edge contact owner now builds a cancel event before
touch-down and, after touch-down, sends the touch-up or that cancel on
every exit. Unit tests fail each HID constructor in turn and check what
reaches the event system.

* fix(ios): report the edge swipe schedule the runner ran

Edge flings ran an eased HID schedule but reported endpoint-hold. The
runner execution policy now resolves the contact and its schedule
together and returns the schedule as executionProfile. The daemon keeps
a reported schedule and infers the planned profile only when a backend
reports none. ADR 0013 records edge-ease-in and the reporting rule.

* fix(ios): keep the gesture schedule the backend reports

Address review: a backend-reported executionProfile is what ran, so the
daemon keeps any reported string and reports the planned profile only
when a backend reports none. Drop the TS schedule list that existed
only to filter it. The cancel test also checks that touch-down and each
move before the cancel touched.

* refactor(ios): keep the edge contact owner private to its file

Address review: the contact function and HID table were exported only so
unit tests could inject faults. Make them static and compile the test
facade in the same file under AGENT_DEVICE_RUNNER_UNIT_TESTS, so the
packaged runner ships no fault-injection interface.

* refactor(ios): keep the edge contact test facade out of the packaged runner

Address review: the packager copies Objective-C sources verbatim, so the
AGENT_DEVICE_RUNNER_UNIT_TESTS block in RunnerSimulatorEdgeTouch.m
shipped and the package leak guard rejected it. Move the contact owner
into a header whose declarations all have internal linkage, include it
from the runner and from the test facade in UnitTests/, and keep the
shipped source free of the unit-test macro.

* fix(ios): select a simulator touchscreen that reports no Transport

Reported on iOS 18.1 and 26.5 simulators: the display's touchscreen
service passed the usage, display and registry checks but read no
Transport, so edge swipes failed with "no simulator touchscreen HID
service". The oldest CoreDevice touchscreen still wins. Without one,
the display's only remaining candidate is used, and several are refused
as ambiguous with how each Transport read (missing, other type, other
value). The no-candidate error now names the stage that rejected the
touchscreens. Unit tests cover the reported service, CoreDevice
precedence, duplicates, ambiguity and the error.

* fix(ios): use a touchscreen without Transport only as the display's sole one

Address review: the fallback accepted a sole candidate whatever its
Transport read. Only a missing Transport has been reported, so a
touchscreen with a Transport of another value or type is no longer a
candidate, and the display refuses to pick a sender while one is
present without a CoreDevice touchscreen. The runner logs the selected
service and its properties as AGENT_DEVICE_RUNNER_EDGE_TOUCHSCREEN so a
host run shows which sender an edge touch used.

* chore: retrigger CI
…callstack#4183)

* feat(lease): name the iOS simulator mode in a proxy lease device spec

A backend in front of the daemon (simlock) can lease an iOS simulator slim
or full, but a proxy allocate could not ask for one: the device spec had
no key for it, so every lease got the backend's default. Apple Pay and
Wallet (PassKit) flows need a full simulator on an otherwise-slim farm.

`deviceMode` joins osVersion, imageTag and physical as a device-spec key: the
CLI's --device-mode, the SDK's leases.allocate and MCP send it on the allocate,
and a daemon refuses it with device-spec-unsupported like the others.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(lease): cover the CLI and MCP half of the device-mode route

The open --device-mode route had no test: deleting the spec key from the
materialized flags, the non-by-spec allocate or the connect proxy profile left
the suite green. Extend the open-by-spec, re-allocate, profile and MCP cases and
iterate LEASE_DEVICE_SPEC_KEYS. The two hand-written key lists in the CLI become
loops over the same constant, so the next key needs no edit there.

lease.device does not report deviceMode; the docs now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(lease): refuse a grant that reports another device mode than requested

lease.device did not report deviceMode, so a backend could lease a slim
simulator for a full request with no error. The lease can now report the
granted mode, and the CLI proxy connection releases a new lease that reports
another mode than the request named, and fails with device-spec-unsupported.

A lease that reports no mode is still accepted, because no simlock reply reports
one yet and refusing it would break valid full leases. The docs state that
the mode is then unconfirmed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(lease): say a refused grant that reports reuse is not released

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(lease): refuse only a slim grant for a full request

Comparing the modes for equality would release a valid lease once a backend
reports the mode: simlock's ADR 0007 makes full a guarantee and slim best effort,
so a slim request can be granted a full simulator and that is never an error.
Only slim granted for full breaks the request.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(lease): qualify the release claim for a reused allocation

A refused allocation that reports reuse is not tracked for release, so it has no
release and no details.released.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(lease): give the device mode its own iOS allocation case

deviceMode was sent on an Android allocation. The forwarding held, but the request
was not a valid one, so the mode moves to an iOS fixture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(lease): exclude reused allocations from the help's release claim

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
…k#4220)

* fix(cli): give cloud device leases a 10 minute TTL

Limrun and Revyl leases fell to the daemon's 60 second default, so a session
ended after one minute without a command. A new holdsCloudDevice capability
selects the cloud device lease TTL, shared with cloud WebDriver leases.

Co-Authored-By: Claude <[email protected]>

* test(cli): cover the cloud device lease TTL in the policy

Co-Authored-By: Claude <[email protected]>

* test(cli): expect the cloud device capability in provider declarations

Co-Authored-By: Claude <[email protected]>

* fix(cli): one cloud lease TTL rule, validated capability, daemon ceiling clamp

Co-Authored-By: Claude <[email protected]>

* docs(limrun): state the observed instance inactivity window

Co-Authored-By: Claude <[email protected]>

* fix(cli): share the five-minute remote device lease TTL with cloud device leases

Co-Authored-By: Claude <[email protected]>

* refactor(cli): one five-minute idle TTL for every remote device lease

Co-Authored-By: Claude <[email protected]>

* docs(limrun): state the three minute instance inactivity timeout

Co-Authored-By: Claude <[email protected]>

* fix(limrun): create instances with the remote lease idle window as inactivity timeout

Limrun's API default is 3 minutes, so a created instance ended before its lease.

Co-Authored-By: Claude <[email protected]>

* test(limrun): expect the inactivity timeout in the Android create input

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
@muvaf
muvaf force-pushed the codex/limrun-sync-install branch from a95049b to a6a2a7c Compare November 5, 2026 10:35
@muvaf
muvaf force-pushed the codex/limrun-native-runner branch from 0ae3dc2 to 442a6ce Compare November 5, 2026 10:35
…callstack#4232)

Every snapshot round trip attached its own `bridgeProcess.wait.then(...)` to notice a bridge exit.
The daemon keeps one bridge per Simulator and, while it keeps using it, that bridge does not exit,
so `wait` stays pending for the bridge's whole life. A reaction on a pending promise cannot be
removed, so each round trip's closure context stayed reachable until the bridge exited: the frame
decoder with its buffered response chunks, the request frame, and the parsed envelope. Profiling a
real daemon measured about 74 KB retained per snapshot, linear and unbounded.

The bridge process now carries one exit reaction. A per-process registry in the transport holds the
round trips that are waiting, a round trip joins it when it starts and leaves it in `finish`, and
the one reaction tells whoever is in the set. An exit still reaches an in-flight round trip as the
`process-crash` from `bridgeProcessExited`, and a failed status read as the error from
`asSnapshotSourceError`. A settled round trip is no longer reachable from the process, so the
`settled` guards in the two exit handlers are gone.

The registry entry is dropped once the status settles. A round trip that starts after the exit
registers on the settled promise, as before, and fails as a process crash on its next turn instead
of waiting out its deadline.

transport.test.ts drives `roundTripSnapshotBridge` against a fake process whose exit status counts
every reaction registered on it. It pins one reaction across 21 round trips, the exit and the failed
status read of an in-flight round trip, an exit before a round trip starts, and silence toward a
round trip that already settled.

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
@muvaf
muvaf force-pushed the codex/limrun-native-runner branch from 442a6ce to 7e18ad0 Compare November 5, 2026 12:42
muvaf and others added 8 commits November 5, 2026 18:35
…callstack#4224)

* refactor(apple): expose runner preparation and shared cancellation helpers

* feat(limrun): replace iOS AX automation with the shared XCTest runner
…kend runner (callstack#4184)

* feat(screenshot): select the XCTest runner capture with --capture-backend runner

On iOS simulators with a Dynamic Island, simctl io screenshot draws the
island into every capture (no --mask policy removes it), while XCUIScreen,
which Maestro and other XCTest tools capture with, leaves it out. A visual
baseline made by such a tool therefore cannot be matched by agent-device's
default capture. The runner capture already existed for the Maestro port
behind an internal flag.

screenshot --capture-backend runner (SDK and MCP: captureBackend: 'runner')
selects it on Apple targets. It is refused on other platforms and with
--pixel-density, since the runner encodes the device density. The Maestro
port now uses the public flag; flags.maestro.screenshotCaptureBackend is
removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(screenshot): refuse a runner capture on a surface the helper owns

The screenshot admission accepted --capture-backend runner on every Apple
surface, but runAppleScreenshot returns the macOS helper capture before it
reaches the runner branch, so the request reported success with another
backend. The Apple binding now declares runner support from the selected
surface and app backend, and assertScreenshotCaptureSupport refuses it.

--normalize-status-bar was dropped the same way, so it is refused with the
runner like --pixel-density. The MCP input gains its schema description.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(screenshot): make every capture owner declare its capture backends

An owner that declared nothing accepted --capture-backend runner, so a provider
iOS session such as Limrun reported success with a provider screenshot. Every
screenshot binding now has to declare its backends and its own explanation, and
the existing admission check refuses a backend the owner does not list. The
daemon's Apple-only check moves into that one place.

Maestro's comparison capture asked for the runner through the public flag, which
would now fail on a provider. It goes back to its internal
maestro.screenshotCaptureBackend, read as a preference: the runner where the
owner declares it, otherwise the owner's default capture, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(screenshot): keep Maestro's runner preference on the daemon path

The daemon passes an own captureBackend key, undefined for a Maestro request, and
it overrode the preference in the binding, so a local iOS comparison capture fell
back to simctl and drew the Dynamic Island back in. An explicit backend still wins.

The preference moves off the shared ScreenshotOptions onto the binding input, since
the binding strips it before the interactor. The daemon-level Android refusal case
is back, now at the capture owner, and WebDriver's refusal has a case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(screenshot): check the destination of a preferred-backend capture

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(screenshot): align explicit runner capture with the default under a runner-backed provider

Under a tool provider whose simulator screenshots go through the runner, the
default capture asked the runner for fullscreen, but an explicit
--capture-backend runner did not. The default now lives in the runner capture, so
both routes share it. An explicit fullscreen value still wins, and captures outside
such a provider are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(screenshot): keep a macOS runner capture out of the provider fullscreen default

The default checked only device.kind, so a macOS device shaped as a simulator got
fullscreen under a runner-backed provider. It now uses the same predicate as the
capture path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(screenshot): name what the macOS fullscreen case checks

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
callstack#4218)

* fix(daemon): settle orphaned recording records when a recording starts

A device that restarts mid-recording can leave its screen recording
record open with a pending cleanup when the daemon's own recovery ran
while the device was still booting. Only daemon start and a device
claim reconciliation retried it, and a recovered record stop cannot
finish it without a playable artifact, so once the claim was gone
every later record start on that device was refused with
cleanup-unconfirmed.

The start preflight now lists the records it refuses on, and record
start runs the exact-owner recovery for the ones whose session the
daemon no longer holds, so the settle set cannot drift from the
refusal. A recovered stop and this settle share one recovery-control
helper. A record whose cleanup cannot be confirmed, or whose session
is still held, still refuses the start, and the refusal names that
session.

* fix(capture-kit): keep the start-time settle on the start device

A start refuses on a non-completed record at its own session path even
when that record names another device. The settle recovered that
record through the start device, so a recording still running on the
other device could have its only record completed from the wrong one.

The settle now recovers only records on the start device. A record
that another device left under the same session name still refuses,
and the refusal names that device: stop it in that session, or start
under another session name.

* fix(capture-kit): finish an orphaned record before a start cleans it

The start-time settle ran exact-owner recovery, which force-cleans any
capture it can reattach. On an iOS simulator that discarded a video a
record stop in the old session could still export, so a new start on
the same simulator lost the earlier recording.

The settle now does what the record's own stop would: it finishes the
record, so the export lands at its output path and a later record stop
in that session returns it. Only a record that cannot finish, such as
an Android recording whose device restarted, falls back to exact-owner
recovery.

* docs(command-surface): say the start-time fallback discards a recording

The record start help and the changeset described the fallback for a
record that cannot finish only as the exact-owner recovery a daemon
restart runs. Say that this recovery discards what the recording
left, so a caller knows a start can delete an unfinished recording.

* refactor(capture-kit): tag start conflicts with their device

listDurableCaptureStartConflicts already decided whether each record
is on the start device, then dropped that fact, so the start-time
settle and the refusal wording each compared device identities again.
Return onStartDevice with each record and read it in both places, so
what the settle skips and what the refusal calls another device can
not drift apart. Compare with sameDeviceIdentity.

The record help said only closing the session disposes what a failed
record stop keeps. That holds only while the daemon holds the session;
once it does not, the next record start on that device settles it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
…llstack#4182)

* refactor(apple): share the snapshot bridge reader with the runner build

Split request validation out of the guest bridge server into SnapshotBridgeRequest.m and name the
reader translation units once (SHARED_SNAPSHOT_BRIDGE_READER_FILENAMES). The guest bridge compiles
them with its socket server; the runner fingerprint and its CI cache key now cover them, because
the runner compiles the same reader. A host that serves the reader for other work can turn off the
watchdog that ends the guest process (SNAPSHOT_BRIDGE_WATCHDOG_ENDS_PROCESS).

* refactor(apple): separate the bridge source pipeline from its transport

createSnapshotBridgeSource owns bounds, depth hints, acquisition, and the recovery record; a
transport supplies only how one accepted envelope arrives. The local guest process is the first
transport. acceptSnapshotBridgeEnvelope is the one acceptance rule for a bridge reply.

* feat(apple): serve the Simulator AX bridge from the XCTest runner

A provider iOS Simulator, such as Limrun, cannot run the guest bridge process: its simctl spawn is
restricted and it forwards no socket. The runner now compiles the same reader and answers the
snapshotBridge command off its journal and off the XCTest main thread. It abandons a read that
outlives its budget instead of ending itself. The Apple runner runtime routes captures through the
shared snapshot route with a runner channel, so a provider Simulator gets the local tree, recovery,
depth cut, producer, generation circuit, and typed XCTest fallback.

The runner names the app generation itself (target) and refuses another process (stale_target).
The route refreshes a cached target before it trusts an open circuit, so a relaunch re-enables the
bridge; a failed refresh keeps the cached generation. Only the runner's foreground read can prove
that no system surface covers a provider Simulator, so that read starts a runner that is not up
yet, and the host-side reads now go out together. Runner commands answered off the queue carry an
explicit queued: false trait, so the bridge read is never charged as queued work.

* fix(limrun): require an Xcode 26 runner that embeds XCTest

Limrun simulators run iOS 26. An Xcode 27 simulator build embeds no XCTest
frameworks and resolves them from the build machine, so it cannot start on a
remote simulator. The release workflow now builds with Xcode 26, the package
script refuses a runner without XCTest.framework, and the Limrun runner
products check refuses such a runner before upload.

* fix(apple): fence the provider surface proof to one runner session

Co-Authored-By: Claude <[email protected]>

* refactor(snapshot): remove the dead limrun-ios-tree producer

Co-Authored-By: Claude <[email protected]>

* test(apple): guard the snapshot bridge refusal codes against emitter drift

Co-Authored-By: Claude <[email protected]>

* docs(adr): record that observe-only captures bypass the bridge route

Co-Authored-By: Claude <[email protected]>

* fix(apple): give a tree that changed during a bridge read capture scope

The bridge reader re-reads a node whose children the first read withheld.
When a scrolling list recycles its cells between the two reads, the
re-read returns fewer children, and the runtime reported every such
reader error as snapshot-tree-malformed. The route then retired the app
generation, so one scroll moved every later capture of the app to XCTest
(seen on Bluesky's feed). The reader errors are now a typed enum, and the
runtime maps each one to its refusal: a changed tree is
snapshot-tree-changed with capture scope, and a foreground change during a
continuation is foreground-owner-changed.

Co-Authored-By: Claude <[email protected]>

* refactor(apple): prove surface absence on the runner channel as on the guest channel

callstack#4172 moved the guest channel's presence check into the bridge request: when
no runner can answer the foreground read, the route names the surface host
executables, and the reader checks the process table in place. The runner
channel now takes the same path instead of a constant 'unknown' presence
probe. The runner forwards the executables to the shared reader and names the
Simulator it runs in. A sandbox that refuses the process table answers
system-surface-host-unverified, so the capture uses XCTest and says why.

The surface-host refusal codes join the refusal fixture, and the runner
request fixture pins a describe that carries the executables.

Co-Authored-By: Claude <[email protected]>

* fix(apple): keep a malformed continuation re-read as snapshot-tree-malformed

A continuation whose children were not an array shared the tree-changed
code, so its capture-scoped refusal never opened the generation circuit.
Only a child-count shrink is tree-changed now.

Co-Authored-By: Claude <[email protected]>

* chore(apple): list the request validator among the packed bridge assets

Co-Authored-By: Claude <[email protected]>

* docs(apple): correct provider names, refusal codes, and the process-table rejection

Co-Authored-By: Claude <[email protected]>

* fix(apple): type a bridge refusal before reading the target it names

Co-Authored-By: Claude <[email protected]>

* fix(apple): hold a runner-served envelope to the bound with its frame header

The guest bridge and the host decoder count the 4-byte length prefix
against maxResponseBytes; the runner did not, so an envelope could pass
the runner and fail the host. The default bound is the reader's own
frame bound, and the reader request adds only the fields the host sent.

Co-Authored-By: Claude <[email protected]>

* refactor(apple): require describe identity and a session id by type

A describe request cannot omit the identity it is checked against, and
the foreground read always carries the runner session it expects.
Adds the observe-only admission and runner budget regression tests.

Co-Authored-By: Claude <[email protected]>

* fix(apple): refuse a JSON boolean where the reader needs a number

Co-Authored-By: Claude <[email protected]>

* fix(limrun): bundle the Swift Testing interop library when the SDK ships it

Co-Authored-By: Claude <[email protected]>

* test(apple): narrow the runner bridge request union in the route tests

Co-Authored-By: Claude <[email protected]>

* test(apple): run the shared reader tests on the iOS Simulator only

The runner compiles the shared reader for the iOS Simulator only, so the
macOS runner unit-test build could not link SnapshotBridgeHandleRequest.

Co-Authored-By: Claude <[email protected]>

* perf(apple): settle the surface foreground proof in registry order

The host reads still go out together, but a decisive first host now
answers without waiting for the reads after it.

Co-Authored-By: Claude <[email protected]>

* fix(apple): let the off-simulator runner carry its own bridge refusal

Off the iOS Simulator the default envelope bound was 0, so the runner's
runner-bridge-unavailable refusal came back as response-too-large when a
request named no bound.

Co-Authored-By: Claude <[email protected]>

* fix(ios): preserve runner bridge refusal evidence

* test(apple): match the native bridge version macros exactly

The wire-vocabulary guard matched SNAPSHOT_BRIDGE_PROTOCOL_VERSION 1 as a
substring, so a native protocol version of 10 still passed. It now matches
each whole #define line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(apple): name a missing Simulator by leaving its key out

The runner sent an absent SIMULATOR_UDID as an empty string, and its
bridge handler re-detected that with a second copy of the reader's
64-character UDID bound. The request builder is the only place that
knows the UDID is absent, so it now leaves simulatorUdid out, and the
handler refuses the surface proof with system-surface-host-unverified
when the key is missing. The reader keeps the only UDID validation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
@muvaf
muvaf force-pushed the codex/limrun-sync-install branch from bafbb75 to 12c2b67 Compare November 5, 2026 20:10
@muvaf
muvaf force-pushed the codex/limrun-sync-install branch from 12c2b67 to d2c9b9f Compare November 5, 2026 20:59
@muvaf muvaf closed this Nov 6, 2026
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.

7 participants