Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion packages/kernel/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { DaemonError } from './errors.ts';

export type { AppErrorCode } from './errors.ts';
export { defaultHintForCode, normalizeError } from './errors.ts';
import type { PlatformSelector } from './device.ts';
import type { PlatformSelector, PublicPlatform } from './device.ts';

const SESSION_RUNTIME_PLATFORMS = ['ios', 'android', 'harmonyos'] as const;
export type SessionRuntimePlatform = (typeof SESSION_RUNTIME_PLATFORMS)[number];
Expand Down Expand Up @@ -58,6 +58,49 @@ const LEASE_BACKENDS = [
'harmonyos-instance',
] as const;
export type LeaseBackend = (typeof LEASE_BACKENDS)[number];

// Which lease backend rents a device on each platform the remote lease layer can hold. `ios-simulator`
// is a backend-specific runner guard rather than something a platform selector names, and the
// platforms with no remote lease backend (`vega`, `linux`, `web`) — and the macOS desktop host — map
// to no backend at all, so a request for one fails on the missing backend instead of renting a
// device no provider owns. Keyed on the `--platform` selector axis: callers holding a `DeviceInfo`
// project it with `publicPlatformString` first, which is the axis #2962 mixed up.
const LEASE_BACKEND_BY_PLATFORM: Partial<Record<PlatformSelector, LeaseBackend>> = {
ios: 'ios-instance',
android: 'android-instance',
harmonyos: 'harmonyos-instance',
};

/**
* Maps a platform to the lease backend that rents it. The CLI reads it for `--platform`/
* `--lease-backend` resolution and the remote connection reads it for the device it just resolved.
* Both previously keyed their own copy off a platform axis, which is where #2962 started; a further
* copy in `connect limrun` validation is tracked for follow-up.
*/
export function leaseBackendForPlatform(
platform: PlatformSelector | undefined,
): LeaseBackend | undefined {
return platform === undefined ? undefined : LEASE_BACKEND_BY_PLATFORM[platform];
}

/**
* The public leaf platform a lease backend rents devices on — the inverse of
* {@link leaseBackendForPlatform} for the backends that name a platform rather than a runner guard.
*
* A connection binds a platform at the same moment it binds a lease, and the lease is the stronger
* evidence: it names the backend that is actually holding the device. `ios-simulator` maps to no
* leaf because it is a runner/process guard below device leases, not a platform a selector names.
*/
const PLATFORM_BY_LEASE_BACKEND: Partial<Record<LeaseBackend, PublicPlatform>> = {
'ios-instance': 'ios',
'android-instance': 'android',
'harmonyos-instance': 'harmonyos',
};

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

}

const DAEMON_SERVER_MODES = ['socket', 'http', 'dual'] as const;
export type DaemonServerMode = (typeof DAEMON_SERVER_MODES)[number];
const DAEMON_TRANSPORT_PREFERENCES = ['auto', 'socket', 'http'] as const;
Expand Down
55 changes: 46 additions & 9 deletions packages/kernel/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,31 @@ export function matchesPlatformSelector(
return device.platform === selector;
}

/**
* Whether two `--platform` selections can name the SAME device.
*
* Selectors name a platform on one of two axes: the collapsed `apple` family, or an Apple leaf
* (`ios`/`macos`) — plus the non-Apple platforms, which have one axis each. Equality of the two
* strings is therefore not the question: `apple` and `ios` name overlapping devices while `ios` and
* `macos` do not. The `apple` selector is only equivalent to a leaf, never to a non-Apple platform.
*
* Comparing selectors by string instead was the shape behind #2962, where a remote connection bound
* to the public `ios` was compared with an `apple`-axis value and every iOS install was refused.
* Any caller that decides "this request targets a different platform than the one already bound"
* has to answer it on both axes, which is why this lives beside the selectors rather than in one
* caller.
*/
export function platformSelectorsConflict(
requested: PlatformSelector | undefined,
bound: PlatformSelector | undefined,
): boolean {
if (!requested || !bound) return false;
if (requested === bound) return false;
if (requested === 'apple') return !isApplePlatform(bound);
if (bound === 'apple') return !isApplePlatform(requested);
return true;
}

export function resolveApplePlatformName(
platformOrTarget: ApplePlatform | DeviceTarget | undefined,
appleOs?: AppleOS,
Expand Down Expand Up @@ -415,14 +440,27 @@ function deviceIdentityMistakenForNameHint(
if (!flag) return undefined;
return (
`${deviceName} is the id of ${JSON.stringify(identityMatch.name)}, not its name. ` +
`Did you mean ${flag} ${deviceName}?`
`Did you mean --${flag} ${deviceName}?`
);
}

/** The identity flag that can actually resolve a device on this platform, if one exists. */
function deviceIdentityFlag(platform: Platform): '--udid' | '--serial' | undefined {
if (isApplePlatform(platform)) return '--udid';
if (isSerialAddressablePlatform(platform)) return '--serial';
export type DeviceIdentityFlag = 'udid' | 'serial';

/**
* Which flag carries a device identity on a platform: `udid` addresses Apple devices, `serial`
* addresses the serial-addressable ones. Resolution rejects the wrong pairing
* (`assertSelectorFlagMatchesPlatform`), and a caller that resolved a device and has to re-issue it
* as flags — a remote lease request that must bind the device it just picked — has to name the same
* flag, or the two drift and the request binds a selector that resolves a DIFFERENT device.
*
* Two sites still spell the pairing out inline; each differs from this rule in a way that is its own
* decision, so they are tracked as follow-ups rather than folded in here.
*/
export function deviceIdentityFlag(
platform: Platform | PublicPlatform,
): DeviceIdentityFlag | undefined {
if (isApplePlatform(platform)) return 'udid';
if (isSerialAddressablePlatform(platform)) return 'serial';
return undefined;
}

Expand Down Expand Up @@ -472,10 +510,9 @@ function throwAmbiguousDeviceSelection(candidates: DeviceInfo[]): never {

function buildAmbiguousDeviceHint(candidates: DeviceInfo[]): string {
const first = candidates[0];
const identitySelector =
first && isSerialAddressablePlatform(first.platform)
? `--serial ${first.id}`
: `--udid ${first?.id ?? '<id>'}`;
const identitySelector = first
? `--${deviceIdentityFlag(first.platform) ?? 'udid'} ${first.id}`
: `--udid <id>`;
return (
`Select the intended device explicitly, for example ${identitySelector} ` +
`or --device ${JSON.stringify(first?.name ?? '<name>')}. ` +
Expand Down
Loading
Loading