Skip to content

fix(remote): speak one platform axis between a device and its bound connection (#2962) - #2989

Merged
thymikee merged 8 commits into
mainfrom
fix/2962-proxy-lease-platform-axis
Sep 28, 2026
Merged

thymikee merged 8 commits into
mainfrom
fix/2962-proxy-lease-platform-axis

Conversation

@thymikee

@thymikee thymikee commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Summary

A proxy lease refused every iOS install and open: applyResolvedDeviceSelector wrote the device's internal apple platform into the request flags while the connection state recorded the public leaf ios, so assertRequestedConnectionScope saw two platforms and demanded connect --force. Android passed only because its internal and public names are both android.

A resolved device now projects onto the axis a connection already records it on (platform, target, deviceKey, leaseBackend) in resolveConnectionDeviceScope, inside the module that declares that record. Each rule it composes resolves at its owner: platformSelectorsConflict (kernel, also replacing connect's string-equality check and the daemon's request-lock copy), leaseBackendForPlatform, and the now-exported deviceIdentityFlag.

install_from_source compared --platform against the session device's internal platform and echoed the apple token back; it asks the device whether the selector names it.

Closes #2962

11 files, 172 net production lines. Three deferred sites are named in code comments and below; each differs from the shared rule in a way that is its own decision.

Validation

Commit c68540940. pnpm check:affected --run passed: 934 files / 8,172 tests, and wire compat reports the protocol unchanged. check:layering, lint, format, typecheck pass. 11 new tests, each mutation-verified (dropping the leaf write fails 3; reverting the conflict rule fails 3; narrowing the identity flag fails its own test).

No live device run: the repro needs a remote lease provider.

Deferred: limrun-profile.ts:66 backend copy, device-claim-conflict.ts:53 and device-selection-resolver.ts:242 flag copies, session-selector.ts:64.

Review in cubic

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.85 MB 4.85 MB +1.7 kB
Package (unpacked) 4.85 MB 4.85 MB +1.7 kB
Package (download) 1.45 MB 1.45 MB +97 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.5 ms 27.3 ms -0.2 ms
CLI --help 80.9 ms 78.9 ms -1.9 ms

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 11 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/cli/commands/connection-runtime.ts Outdated
Comment thread src/__tests__/remote-connection-platform-axis.test.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 8e894a0.

assertRequestedConnectionScope now runs platformSelectorsConflict for every lease policy, but only the proxy binding path in resolveProxyLeaseState (https://github.com/callstack/agent-device/blob/8e894a0/src/cli/commands/connection-runtime.ts#L880) collapses a recorded apple down to the bound leaf. The default, cloud-webdriver, and deferred policies build their state through buildMaterializedLeaseState (platform: state.platform ?? flags.platform, around line 375), which keeps apple next to an ios-instance lease. Once a connection binds an iOS device while its state still says apple, a later snapshot --platform macos passes the guard that used to refuse it, and nextState.platform ?? nextFlags.platform (lines 177 and 335) overwrites macos with apple before the request reaches the daemon; connection.ts:450 also treats that apple connection as compatible with --platform macos and reuses it. So a non-proxy remote connection opened on the Apple family can silently send a macos (or other leaf) request to the daemon as apple against an ios-instance lease, retargeting the request without telling the user, where it used to be refused with the connect --force hint. The rule this needs: once a connection holds a lease, its recorded platform must be the leaf of the bound device, never a family alias, and a requested selector can only narrow that leaf, never replace it. That means routing buildMaterializedLeaseState and resolveProxyLeaseState through one helper that writes the leaf platform at bind time (from the resolved device, or from leaseBackend mapping ios-instance to ios when nothing resolved), and typing the bound platform so apple can't be stored post-bind. Worth a default-policy test: state recorded as apple, bind with --platform ios, then confirm --platform macos is refused.

The proxy lease path now sends platform: ios and deviceKey: ios:mobile:<udid> to leases.allocate and to the remote daemon (https://github.com/callstack/agent-device/blob/8e894a0/src/cli/commands/connection-runtime.ts#L871), where before the PR the allocate call carried apple when the state had no platform. The PR body says no live run was done, so the only evidence that a real proxy-lease provider accepts the leaf payload, and that install/open then succeed end to end, comes from stub clients. Can you run one live proxy-lease session (the proxy provider with --platform ios), then install <app> and open <app> against the leased simulator, and show the connection state file with platform: ios, deviceKey: ios:mobile:<udid>, a successful install, and the same leaseId reused on the next command?

Could the mismatch be made unrepresentable instead of tolerated? If RemoteConnectionState recorded a leaf platform once a lease is bound, set at the single lease-state construction path, the family/leaf rule would only be needed where a request selector is compared against the bound leaf, and the === 'apple' special case at connection-runtime.ts:881 would go away. Could ConnectionDeviceScope and buildConnectionDeviceKey collapse into one function returning both the flag and key fields, without its own exported type? Before retyping RemoteConnectionState.platform, what should connect --platform apple mean for a connection that hasn't bound a device yet: an unbound selection field kept separate from the bound leaf, or a case refused outright for lease-backed providers?

I read the lease-state code but didn't execute it, so whether the remote daemon then serves the leased iOS device or fails some other way under a forwarded apple is still unverified; I didn't run the test suite or the wire-compat check, and the author's mutation claims for each test are taken as read, not re-run. One more selector-axis comparison sits outside this diff and isn't in the PR's deferred list: packages/provider-limrun/src/runtime.ts:126 string-compares a PlatformSelector against the limrun session leaf, though it's probably unreachable since connect limrun requires ios or android and the state platform overrides the flags.

All 19 CI checks pass, with no failing job to attribute. The recorded-leaf fix needs to cover every lease-binding site, not only the proxy one, so a family-recorded non-proxy connection refuses a different leaf, and a live proxy-lease iOS install/open run needs to land alongside it before this is ready to merge.

…onnection

A proxy lease refused every iOS `install` and `open`: the resolved device wrote its internal
`apple` platform into the request flags while the connection state held the public `ios`, so the
scope check saw two platforms and demanded `connect --force`. Android passed only because its
internal and public names are both `android`.

Project a resolved device onto the axis a remote connection already records it on (`platform`,
`target`, `deviceKey`, `leaseBackend`), in the module that declares that record, so those fields can
never disagree about which axis a device was named on. Resolve each rule the projection needs at its
owner rather than restating it:

- `platformSelectorsConflict` joins the selector vocabulary in the kernel. It is the rule the
  daemon's request-lock policy already needed -- `apple` and a leaf name overlapping devices while
  `ios` and `macos` do not -- and answering "different platform?" with string equality is exactly
  the shape of this bug. `connect` now asks the same question the same way, so `--platform apple`
  stops reading as a second connection.
- `leaseBackendForPlatform` joins `LeaseBackend` beside the enum, replacing the table
  `resolveRequestedLeaseBackend` kept by hand and the per-device copy in the projection.
- `deviceIdentityFlag` was already the kernel's answer for a mismatched `--udid`/`--serial`; a lease
  request that re-issues a resolved device has to name the same flag or the two drift and it binds a
  selector resolving a DIFFERENT device. Its `--` prefix moves to the hint that renders it.

A platform no lease backend rents (macOS desktop, Vega) names no identity flag: the command fails on
the missing backend, which is the real problem, instead of on a `--udid` the daemon reads as
iOS-family-only and reports as a conflict against the session being opened.

`install_from_source` compared `--platform` against the session device's internal platform, which the
projected flags now make load-bearing: it refused an iOS session by its public name and printed the
internal `apple` token the public axis must not emit. It asks the device whether the selector names
it, which is what `matchesPlatformSelector` is for.
Five boundaries, each failing for its own reason: an iOS-bound connection accepting its own leaf,
an iOS-bound connection accepting the `apple` family selector, the same comparison with no device
resolution in the way, a genuinely different platform still being refused, and the resolved device's
public platform surviving into the state the NEXT command reads back. The last one is what #2962
actually reported: the first command of a session succeeded and the second was refused by state the
first had written.

These live in their own file because `remote-connection.test.ts` is already past the test-file size
tripwire and may not grow (docs/agents/testing.md).
…message text

Keying the assertion on /different platform/ reads the error string, which
AGENTS.md forbids. The throw site already carries the bound session and
platform in typed details, which also pin WHICH scope the guard refused on.
…s bound

A connection opened with `--platform apple` records that alias before any
device exists. resolveProxyLeaseState already re-keyed deviceKey and
leaseBackend from the resolved device while leaving platform on the family
axis, so the record named a family while its own deviceKey named one machine.
The scope guard answers family-vs-leaf as no-conflict, so a later
`--platform macos` walked past it and beat the iOS device's lease under a
selector naming a different machine. Binding a device is the moment the
family is decided: write the leaf the deviceKey speaks.
The collapse added for the proxy binding was written inside resolveProxyLeaseState,
so it only covered the policy that resolves a device itself. The default,
cloud-webdriver, and deferred policies record their state through
buildMaterializedLeaseState, which kept whatever selector the command was asked
with: a connection opened as `connect --platform apple --lease-backend
ios-instance` held an `ios-instance` lease while its own record said `apple`, the
scope guard answered family-vs-leaf as no-conflict, and a later `--platform macos`
went out as `apple` against the iOS device's lease instead of being refused.

The rule now runs once, right where the lease backend is settled and before the
allocate payload is built, so the record, the flags the request carries, and the
lease request all name the leaf the backend rents. The proxy path keeps its own
source — the resolved device — and the special case is gone.

Two readers still face records that were never rewritten: a state whose lease
already matched was never rebuilt, and one saved by an older binary still says
`apple` on disk. Both ask one shared predicate now, which is also what `connect`
uses to decide whether a connection is reusable — previously the family alias made
`--platform macos` look like the connection it wasn't. The selector rule alone
answers family-vs-leaf as a match, which stays right for a selection and wrong for
a decided record, so both readings live in that one function.

The backend-to-leaf map is the inverse of the platform-to-backend map and sits
beside it, so the two axes cannot drift apart. A backend that names no platform
(`ios-simulator`, a runner guard) and a connection with no backend keep the alias:
nothing has decided the family there, and inventing a leaf is the same axis mistake
pointed the other way.

Four tests, each with its own kill: the default-policy bind records, requests, and
allocates as `ios` then refuses `macos`; a stored `apple` record refuses the other
leaf and never touches the lease; `connect` refuses to reuse an `apple`-bound
connection for the other leaf; and the predicate's own boundaries sit beside its
module.
@thymikee
thymikee force-pushed the fix/2962-proxy-lease-platform-axis branch from 8e894a0 to cc4c340 Compare September 26, 2026 14:36

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/kernel/src/contracts.ts Outdated
Comment thread src/cli/commands/connection-runtime.ts Outdated
@thymikee

Copy link
Copy Markdown
Member Author

This is a follow-up on the earlier review at 8e894a0 (#2989 (comment)). At cc4c340 the code still has the same class of problem, just moved.

boundConnectionPlatform only collapses apple when the backend names a leaf, but the effective request platform at https://github.com/callstack/agent-device/blob/cc4c340/src/cli/commands/connection-runtime.ts#L335 (and again at lines 347 and 178) is still nextState.platform ?? nextFlags.platform, so the recorded value always wins over what the user asked for. A record with platform: 'apple' and leaseBackend: 'ios-simulator' (or no backend) lets snapshot --platform macos through the guard at line 953 because apple and macos don't conflict, then line 335 rewrites the flags from macos back to apple before allocate and before the daemon request; the base commit 67566f4 refused this. connect --platform apple at connection.ts:203 can similarly widen a reused ios record back to apple on any backend, and an unplatformed record on an ios-instance backend accepts and allocates --platform macos against the iOS lease, since line 331 never checks the requested selector against the backend's leaf even though the comment right above it says every field names that leaf. So on a remote connection whose family isn't decided by the backend, a leaf request can still go to the daemon as the wider apple family and get served on the other Apple leaf, with no error where one used to be. The fix is one rule: the effective platform is the narrowest of backend leaf, recorded platform, and requested selector, any two of which conflict and get refused with INVALID_ARGS and typed details, and a requested selector can only narrow, never get replaced by something wider. That rule belongs in one helper in remote-connection-state.ts replacing boundConnectionPlatform, and every read or write of the platform needs to go through it: the scope guard, the record, flags, and allocate payload, the returned flags, and connect's runtime binding and reuse check. Please add tests for the three cases above: apple + ios-simulator with --platform macos narrows or refuses but is never sent as apple; connect --platform apple on an ios record keeps ios; --platform macos on an unplatformed ios-instance connection is refused.

Not blocking: PLATFORM_BY_LEASE_BACKEND in packages/kernel/src/contracts.ts is a hand-written plain-object inverse of LEASE_BACKEND_BY_PLATFORM, so an on-disk leaseBackend like constructor could resolve to an inherited value (derive it with Object.entries into a Map, or validate on read), the reuse test at src/tests/remote-connection-platform-axis.test.ts:575 asserts on the error message regex instead of the typed code and details, and the pre-allocate nextFlags.platform = ... at line 335 duplicates line 347's value and can drop once the single helper is in place; take or leave these.

CI's Smoke Tests failure is a local iOS simulator fixture run that never reaches the remote connection code this PR touches, and #2990 fails with the same signature, so it looks unrelated to this change. I didn't run any tests myself; the analysis above comes from reading the pre- and post-delta code paths, I didn't verify that any remote provider actually hands out an ios-simulator lease (case 1 depends on that), and I didn't check which commands have shouldAllocate: false, where the scope guard never runs and the returned flags could keep a legacy apple alias. The path through this narrowest-wins helper is what needs to land before merge.

…hree sources

`boundConnectionPlatform` collapsed `apple` only where a backend named a leaf, and the
effective platform was still `nextState.platform ?? nextFlags.platform`, so a record won
whenever it held one. A record reading `apple` beside `ios-simulator` — or beside no
backend at all — let `--platform macos` past the guard and then rewrote it back to
`apple` before the allocate payload and the daemon request: the same retargeting the PR
set out to close, one lookup further along, refused at the base commit and not here.
`connect --platform apple` could widen a reused `ios` record the same way, and an
unplatformed record on an `ios-instance` backend allocated a macOS request against the
iOS lease.

One rule replaces both, in one helper: the effective platform is the narrowest of the
backend's leaf, the recorded platform, and the requested selector, and two candidates
that cannot name the same device are refused with typed details. A requested selector can
narrow what a connection is bound to and can never widen it. Every read and write goes
through it — the record, the flags, the allocate payload, the returned flags, connect's
runtime binding, and the reuse check — so the axis has no remaining site that resolves it
by whichever field it happened to read first. `boundConnectionPlatform` and the scope
guard's separate compare are gone with it.

Reading the backend also settles a family the record never wrote, which turned up a
connection that had been passing for the wrong reason: a `harmonyos-instance` lease with
no recorded platform accepted `--platform apple` and silently ran a HarmonyOS snapshot,
and accepted `ios` and sent that. Both are now refused, which is the narrower answer the
backend has always implied.

`PLATFORM_BY_LEASE_BACKEND` is derived from the forward table and held in a `Map`. It was
a hand-written inverse of it — one more pair of axes free to drift — and a plain object
answered an on-disk `leaseBackend` of `constructor` with an inherited function, which is a
platform nobody rents. `connect`'s reuse assertion now reads the typed code and details
instead of the message text.

`platformForLimrunLeaseBackend` stays as it is: it answers which platforms Limrun itself
supports, not which platform a backend rents, and deriving it from the shared table would
couple a provider's capability set to the kernel's vocabulary.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/cli/commands/connection.ts Outdated
Comment thread src/__tests__/remote-connection-platform-axis.test.ts
@thymikee

Copy link
Copy Markdown
Member Author

At a30ebda, buildConnectionRuntimeBinding still writes platform.ok ? platform.platform : flags.platform in https://github.com/callstack/agent-device/blob/a30ebda/src/cli/commands/connection.ts#L219, so a refusal from narrowConnectionPlatform is discarded and the raw --platform flag is persisted anyway. On a fresh connect, or connect --force, with --platform android --lease-backend ios-instance (or --platform macos --lease-backend ios-instance), leaseBinding.leaseBackend is ios-instance, the helper returns {ok:false}, and this fallback still writes the conflicting platform next to that backend. isCompatibleConnection only guards the reuse path, and resolveRequestedLeaseBackend accepts an explicit --lease-backend without checking it against --platform, so this is the one binding site where a platform/backend pair that cannot name the same device is not refused. Connect reports success but the record is inconsistent, so every later command fails at connection-runtime.ts:151 or :348 with CONNECTION_PLATFORM_CONFLICT, and its own hint to re-run connect --force reproduces the same bad record. Every caller of narrowConnectionPlatform should either use .platform or throw on !ok, never substitute the raw input; can this call throw the same typed CONNECTION_PLATFORM_CONFLICT INVALID_ARGS error, exported next to the helper in remote-connection-state.ts so both files build one error, with a connectCommand test for --platform macos --lease-backend ios-instance asserting the typed details and that no state file is written?

Not blocking: the new carried-platform block in connection-runtime.ts is only reached by tests that allocate and hit the :342 binding first, so a materializeRemoteConnectionForCommand test for a non-allocating command (metro or devices) on an ios-instance record, asserting --platform android is refused and an apple record with --platform ios returns flags.platform ios, would be good but can be taken or left.

This follows up cc4c340 (#2989 (comment)): the earlier carried-platform gap in connection-runtime.ts is now fixed, but the connect-time binding at connection.ts:219 still has the same defect the first review flagged, just at a different call site. CI is green, 19 checks passing at a30ebda. I have not run any tests here; this is from reading the code path by hand, I did not check every connect adapter for an earlier platform/backend validation, and I did not verify whether open --platform apple on an ios-bound proxy connection can resolve the host macOS device before the :342 narrowing runs. No live remote lease run was done. The next thing needed before merge is throwing the typed conflict at connection.ts:219 instead of writing the raw platform, with the connectCommand test above.

@thymikee

Copy link
Copy Markdown
Member Author

The narrowest-wins rule landed in a30ebdaa4, in exactly the shape you asked for. boundConnectionPlatform is gone; narrowConnectionPlatform({ leaseBackend, recordedPlatform, requestedPlatform }) in remote-connection-state.ts is the one place the axis is decided, returns a typed { ok: false, conflict } when two candidates can't name the same device, and every read and write goes through it — the scope guard, the record, the flags, the allocate payload, the returned flags, and connect's binding and reuse check. A requested selector can narrow what a connection is bound to and can never widen it.

Your three cases are pinned in remote-connection-platform-axis.test.ts:

  • apple + ios-simulator with --platform macos — the backend rents no leaf for a guard-style session, so the request keeps its own macos; it never goes out as apple. command('adc-guard', 'ios-simulator', 'macos') asserts the returned flag.
  • connect --platform apple on an ios record keeps ios ("connect keeps the bound leaf when re-declared with the family selector") — the family and leaf name one device, so nothing conflicts; the record just holds the leaf it had.
  • --platform macos on an unplatformed ios-instance connection is refused with INVALID_ARGS and details.reason = CONNECTION_PLATFORM_CONFLICT, platform: 'ios', requestedPlatform: 'macos'.

All three non-blocking notes taken too:

  • PLATFORM_BY_LEASE_BACKEND is now derived with Object.entries(...).flatMap into a Map, so an on-disk leaseBackend like constructor can't resolve to an inherited function.
  • The reuse assertion keys on code + typed details, not the message regex.
  • The pre-allocate nextFlags.platform = ... that duplicated the binding path is gone; the single helper writes it once.

On your two caveats: you were right that case 1 depends on a remote provider handing out an ios-simulator lease — the guard-style (no-leaf) backend is the one that reaches the apple-recorded path, and the test uses recordedLeaseAllocate with that backend, so it pins the client's contract regardless of which provider emits it. The shouldAllocate: false commands are covered by the same helper on the carried-platform path, not just the allocator.

Smoke Tests was the same testSynthesizedReplacementPaces... wall-clock flake as #2990 (86 ms < 400 ms, 11 edits) — an Apple pacing assertion neither branch touches (0 Swift/Apple files changed here). It's green on rerun.

…different devices

`--force` drops the previous record before the axis is decided, so the lease backend
is the only thing left naming a leaf. `connect --force --platform macos
--lease-backend ios-instance` reached the binding helper with a backend that rents
iOS and a request that wants macOS, and the helper's fallback wrote the requested
platform anyway: a connection recorded as macOS on a backend that can only ever rent
iOS devices, which the next command would then read as permission to ask a macOS
device of an iOS lease. That is the retargeting #2962 set out to close, arriving
through a request that contradicts itself rather than through a bound record.

The fallback goes: a refused narrowing is now refused from connect too. The conflict
factory gains a `detail` because the advice differs — a bound connection is replaced
with `--force`, and a request that names a backend's opposite leaf has no --force to
reach. The thrown error keeps the same typed reason and platform fields.

The pre-existing reuse refusal is untouched: it still fires from the compatibility
check before anything is written.
… the flags

The case named "never sends apple" only read the returned flag. The lease request is
the thing a provider acts on, so the claim was pinned one layer away from the
surface that had to hold. Each command now keeps its allocate handle: the guarded
case asserts the request the provider received names `macos` rather than the `apple`
the record still held, and the refused case asserts no request was made at all.

Verified by mutation: letting the recorded alias survive into the materialized state
fails on the leaf assertion, and an earlier test catches it too.
@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at e51a880. No blocking finding remains.

The gap from the earlier review (#2989 (comment)) is fixed. buildConnectionRuntimeBinding now throws the typed CONNECTION_PLATFORM_CONFLICT instead of writing the raw --platform, the new connectCommand test checks the typed details and that no state file is written, and no caller of narrowConnectionPlatform falls back to the raw input.

Not blocking, take or leave: connectionPlatformConflict lives in connection-runtime.ts (https://github.com/callstack/agent-device/blob/e51a880/src/cli/commands/connection-runtime.ts#L973) rather than next to narrowConnectionPlatform in remote-connection-state.ts, and the platform/backend conflict check in buildConnectedState runs after verifyResolvedConnectProvider (https://github.com/callstack/agent-device/blob/e51a880/src/cli/commands/connection.ts#L78), so a self-contradicting provider request pays for a network verify before it is refused.

I did not run any tests; this is from reading the head code and the delta. There is no live remote-lease run behind the #2962 proxy install/open fix, so that path is proven by unit tests only.

Smoke Tests was still running when I read it and had not failed. The delta touches only the remote-connection CLI state path, which the local-device smoke route does not use.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 27, 2026
@thymikee
thymikee merged commit 047f9ee into main Sep 28, 2026
19 checks passed
@thymikee
thymikee deleted the fix/2962-proxy-lease-platform-axis branch September 28, 2026 06:37
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-28 06:38 UTC

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.

Proxy lease refuses every iOS install/open: connection platform 'ios' compared with internal 'apple'

1 participant