Skip to content

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

Open
thymikee wants to merge 5 commits into
mainfrom
fix/2962-proxy-lease-platform-axis
Open

thymikee wants to merge 5 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 +440 B
Package (unpacked) 4.85 MB 4.85 MB +440 B
Package (download) 1.45 MB 1.45 MB -247 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.3 ms 26.7 ms -0.6 ms
CLI --help 82.0 ms 78.4 ms -3.6 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.

2 issues found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/kernel/src/contracts.ts">

<violation number="1" location="packages/kernel/src/contracts.ts:101">
P2: Guard this lookup against inherited keys. `readRemoteConnectionState` accepts any string for `leaseBackend`, so `constructor` or `toString` returns a non-platform value that `boundConnectionPlatform` can forward as a platform.</violation>
</file>

<file name="src/cli/commands/connection-runtime.ts">

<violation number="1" location="src/cli/commands/connection-runtime.ts:335">
P3: This added `nextFlags.platform` assignment has no effect: `allocateOrReuseLease` never changes `nextState.platform` (its payload uses `state.platform ?? flags.platform` on the already-collapsed state), and the same expression is assigned again right after the lease call. Drop this line and keep the later assignment.</violation>
</file>

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

Fix all with cubic | Re-trigger cubic

};

export function platformForLeaseBackend(backend: LeaseBackend): PublicPlatform | undefined {
return PLATFORM_BY_LEASE_BACKEND[backend];

@cubic-dev-ai cubic-dev-ai Bot Sep 26, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Guard this lookup against inherited keys. readRemoteConnectionState accepts any string for leaseBackend, so constructor or toString returns a non-platform value that boundConnectionPlatform can forward as a platform.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/kernel/src/contracts.ts, line 101:

<comment>Guard this lookup against inherited keys. `readRemoteConnectionState` accepts any string for `leaseBackend`, so `constructor` or `toString` returns a non-platform value that `boundConnectionPlatform` can forward as a platform.</comment>

<file context>
@@ -83,6 +83,24 @@ export function leaseBackendForPlatform(
+};
+
+export function platformForLeaseBackend(backend: LeaseBackend): PublicPlatform | undefined {
+  return PLATFORM_BY_LEASE_BACKEND[backend];
+}
+
</file context>
Suggested change
return PLATFORM_BY_LEASE_BACKEND[backend];
return Object.hasOwn(PLATFORM_BY_LEASE_BACKEND, backend)
? PLATFORM_BY_LEASE_BACKEND[backend]
: undefined;
Fix with cubic

leaseBackend,
}),
};
nextFlags.platform = nextState.platform ?? nextFlags.platform;

@cubic-dev-ai cubic-dev-ai Bot Sep 26, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This added nextFlags.platform assignment has no effect: allocateOrReuseLease never changes nextState.platform (its payload uses state.platform ?? flags.platform on the already-collapsed state), and the same expression is assigned again right after the lease call. Drop this line and keep the later assignment.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/cli/commands/connection-runtime.ts, line 335:

<comment>This added `nextFlags.platform` assignment has no effect: `allocateOrReuseLease` never changes `nextState.platform` (its payload uses `state.platform ?? flags.platform` on the already-collapsed state), and the same expression is assigned again right after the lease call. Drop this line and keep the later assignment.</comment>

<file context>
@@ -321,6 +322,17 @@ async function materializeLeaseForCommand(options: {
+      leaseBackend,
+    }),
+  };
+  nextFlags.platform = nextState.platform ?? nextFlags.platform;
   const materializedLease = await allocateOrReuseLease(
     client,
</file context>
Fix with cubic

@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.

This branch has not been deployed

No deployments
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.

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

1 participant