From 32710ca0d417edf66d13763d74a7e606e578295e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 21:53:17 +0200 Subject: [PATCH 1/8] fix(remote): speak one platform axis between a device and its bound connection 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. --- packages/kernel/src/contracts.ts | 25 ++++++ packages/kernel/src/device.ts | 55 ++++++++++-- src/cli/commands/connection-runtime.ts | 50 ++++------- src/cli/commands/connection.ts | 20 ++++- src/core/__tests__/device.test.ts | 20 +++++ .../handlers/__tests__/install-source.test.ts | 76 ++++++++++++++++- .../handlers/session-app-source-deployment.ts | 10 ++- src/daemon/request-lock-policy.ts | 13 +-- .../__tests__/remote-connection-state.test.ts | 84 +++++++++++++++++++ src/remote/remote-connection-state.ts | 63 +++++++++++++- 10 files changed, 354 insertions(+), 62 deletions(-) diff --git a/packages/kernel/src/contracts.ts b/packages/kernel/src/contracts.ts index 1ef172702a..8d79e0e714 100644 --- a/packages/kernel/src/contracts.ts +++ b/packages/kernel/src/contracts.ts @@ -58,6 +58,31 @@ 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> = { + 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]; +} + 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; diff --git a/packages/kernel/src/device.ts b/packages/kernel/src/device.ts index 4aeaa40641..5f435f138d 100644 --- a/packages/kernel/src/device.ts +++ b/packages/kernel/src/device.ts @@ -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, @@ -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; } @@ -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 ?? ''}`; + const identitySelector = first + ? `--${deviceIdentityFlag(first.platform) ?? 'udid'} ${first.id}` + : `--udid `; return ( `Select the intended device explicitly, for example ${identitySelector} ` + `or --device ${JSON.stringify(first?.name ?? '')}. ` + diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 961560e226..4dcba7f8ac 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -9,18 +9,19 @@ import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; import { readRemoteConfigFile } from '../../remote/remote-config-core.ts'; import { deviceFieldsFromPublicPlatform, - isIosFamily, - publicPlatformString, + platformSelectorsConflict, resolveDevice, type DeviceInfo, } from '@agent-device/kernel/device'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import { + buildConnectionDeviceKey, buildRemoteConnectionDaemonState, buildRemoteConnectionRequestMetadata, hashRemoteConfigFile, mergeRemoteConnectionRequestMetadata, readRemoteConnectionState, + resolveConnectionDeviceScope, writeRemoteConnectionState, type RemoteConnectionState, type RemoteConnectionRequestMetadata, @@ -30,6 +31,7 @@ import type { BatchStep } from '@agent-device/contracts/client'; import { AppError } from '@agent-device/kernel/errors'; import { isSessionRuntimePlatform, + leaseBackendForPlatform, type LeaseBackend, type SessionRuntimeHints, } from '@agent-device/kernel/contracts'; @@ -690,11 +692,7 @@ async function releaseAcquiredLeaseOnWriteFailure( } export function resolveRequestedLeaseBackend(flags: CliFlags): LeaseBackend | undefined { - if (flags.leaseBackend) return flags.leaseBackend; - if (flags.platform === 'android') return 'android-instance'; - if (flags.platform === 'ios') return 'ios-instance'; - if (flags.platform === 'harmonyos') return 'harmonyos-instance'; - return undefined; + return flags.leaseBackend ?? leaseBackendForPlatform(flags.platform); } function requireRequestedLeaseBackend(flags: CliFlags, command: string): LeaseBackend { @@ -870,15 +868,14 @@ async function resolveProxyLeaseState(options: { ); } const device = await resolveSelectedDevice(options.client, options.flags); - const deviceKey = buildProxyDeviceKey(device); + const scope = resolveConnectionDeviceScope(device); return { state: { ...options.state, - deviceKey, - leaseBackend: - options.state.leaseBackend ?? options.leaseBackend ?? leaseBackendForDevice(device), - platform: options.state.platform ?? device.platform, - target: options.state.target ?? device.target, + deviceKey: buildConnectionDeviceKey(scope), + leaseBackend: options.state.leaseBackend ?? options.leaseBackend ?? scope.leaseBackend, + platform: options.state.platform ?? scope.platform, + target: options.state.target ?? scope.target, updatedAt: new Date().toISOString(), }, device, @@ -886,15 +883,11 @@ async function resolveProxyLeaseState(options: { } function applyResolvedDeviceSelector(flags: CliFlags, device: DeviceInfo): void { - flags.platform = device.platform; - flags.target = device.target ?? flags.target; - if (isIosFamily(device)) { - flags.udid = device.id; - return; - } - if (device.platform === 'android' || device.platform === 'harmonyos') { - flags.serial = device.id; - } + const scope = resolveConnectionDeviceScope(device); + flags.platform = scope.platform; + flags.target = scope.target ?? flags.target; + if (scope.identityFlag === 'udid') flags.udid = scope.id; + if (scope.identityFlag === 'serial') flags.serial = scope.id; } async function resolveSelectedDevice( @@ -929,17 +922,6 @@ async function resolveSelectedDevice( ); } -function buildProxyDeviceKey(device: DeviceInfo): string { - return `${publicPlatformString(device)}:${device.target ?? 'mobile'}:${device.id}`; -} - -function leaseBackendForDevice(device: DeviceInfo): LeaseBackend | undefined { - if (isIosFamily(device)) return 'ios-instance'; - if (device.platform === 'android') return 'android-instance'; - if (device.platform === 'harmonyos') return 'harmonyos-instance'; - return undefined; -} - function assertRequestedConnectionScope( state: RemoteConnectionState, flags: CliFlags, @@ -952,7 +934,7 @@ function assertRequestedConnectionScope( { session: state.session, leaseBackend: state.leaseBackend }, ); } - if (state.platform && flags.platform && state.platform !== flags.platform) { + if (platformSelectorsConflict(flags.platform, state.platform)) { throw new AppError( 'INVALID_ARGS', 'Active remote connection is already bound to a different platform. Re-run connect --force to replace it.', diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index bbef4ee2f2..bf25b06303 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -16,6 +16,7 @@ import { type RemoteConnectionRequestMetadata, } from '../../remote/remote-connection-state.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { platformSelectorsConflict } from '@agent-device/kernel/device'; import { connectProviderNamesForError, connectionProviderCapabilities, @@ -424,14 +425,31 @@ function optionalConnectionFieldsMatch( state: RemoteConnectionState, options: Parameters[1], ): boolean { + if (!platformSelectionMatches(state.platform, options.flags.platform)) return false; const fieldsMatch = [ [state.leaseBackend, options.desiredLeaseBackend], - [state.platform, options.flags.platform], [state.target, options.flags.target], ].every(([left, right]) => right === undefined || left === right); return fieldsMatch && remoteConnectionLeaseIdentityMatches(state, options.connection); } +/** + * Whether a `connect --platform` names the platform the active connection is already bound to. + * + * The comparison goes through the selector rule rather than string equality: `--platform apple` + * names the same devices as the `ios` a profile recorded, and treating those as two connections + * forced a needless `--force` (#2962). A connection that recorded no platform stays bound to + * nothing, so a request that names one still counts as a different connection. + */ +function platformSelectionMatches( + bound: RemoteConnectionState['platform'], + requested: CliFlags['platform'], +): boolean { + if (requested === undefined) return true; + if (bound === undefined) return false; + return !platformSelectorsConflict(requested, bound); +} + function isSameDaemonState( a: RemoteConnectionState['daemon'], b: RemoteConnectionState['daemon'], diff --git a/src/core/__tests__/device.test.ts b/src/core/__tests__/device.test.ts index a4b0f306b6..88379e7ab3 100644 --- a/src/core/__tests__/device.test.ts +++ b/src/core/__tests__/device.test.ts @@ -4,6 +4,7 @@ import { isPlatform, isTvOsDevice, matchesPlatformSelector, + platformSelectorsConflict, PLATFORMS, resolveApplePlatformName, resolveAppleSimulatorSetPathForSelector, @@ -50,6 +51,25 @@ test('matchesPlatformSelector resolves apple selector across Apple platforms', ( assert.equal(matchesPlatformSelector({ platform: 'vega' }, 'android'), false); }); +// #2962: a connection bound to `ios` was compared with the internal `apple` by string inequality, +// so every iOS remote install was refused. Selectors name a platform on two axes, and deciding +// "different platform" has to account for both. +test('platformSelectorsConflict reads selectors on both the family and leaf axes', () => { + assert.equal(platformSelectorsConflict('ios', 'apple'), false); + assert.equal(platformSelectorsConflict('apple', 'ios'), false); + assert.equal(platformSelectorsConflict('macos', 'apple'), false); + assert.equal(platformSelectorsConflict('ios', 'ios'), false); + assert.equal(platformSelectorsConflict('apple', 'apple'), false); + assert.equal(platformSelectorsConflict('ios', 'macos'), true); + assert.equal(platformSelectorsConflict('apple', 'android'), true); + assert.equal(platformSelectorsConflict('android', 'apple'), true); + assert.equal(platformSelectorsConflict('android', 'ios'), true); + assert.equal(platformSelectorsConflict('harmonyos', 'harmonyos'), false); + // A missing selector binds nothing, so it cannot conflict with anything. + assert.equal(platformSelectorsConflict(undefined, 'ios'), false); + assert.equal(platformSelectorsConflict('ios', undefined), false); +}); + test('isPlatform accepts exactly the canonical PLATFORMS tuple', () => { for (const platform of PLATFORMS) { assert.equal(isPlatform(platform), true); diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index 274a3f8848..ca21dfbcc4 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -52,13 +52,16 @@ function makeSession(device: DeviceInfo): SessionState { return { name: 'default', createdAt: Date.now(), actions: [], device }; } -function makeRequest(source: NonNullable['installSource']): DaemonRequest { +function makeRequest( + source: NonNullable['installSource'], + flags?: DaemonRequest['flags'], +): DaemonRequest { return { token: 't', session: 'default', command: 'install_source', positionals: [], - flags: {}, + flags: flags ?? {}, meta: { installSource: source }, }; } @@ -307,6 +310,75 @@ test('install_source returns the typed iOS artifact identity supplied by its run }); }); +// The session's device carries the INTERNAL `apple` platform while `--platform` names the PUBLIC +// leaf, and a remote command's device resolution writes that leaf into the flags of every install +// it dispatches (#2962). Comparing the two axes by string equality refused the install of the very +// session it targeted, and printed the internal `apple` token the public axis is not allowed to +// emit (ADR 0009). +test('install_source accepts the public leaf selector of an Apple session it is bound to', async () => { + const store = makeStore(); + const session = makeSession({ + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }); + store.set(session.name, session); + const runtime = createSourceRuntime( + session.device, + async () => ({ + installablePath: '/tmp/App.app', + bundleId: 'com.example.app', + appName: 'App', + cleanup: async () => {}, + }), + async () => ({}) as never, + ); + + const response = await handleInstallFromSourceDeploymentCommand({ + req: makeRequest({ kind: 'path', path: '/tmp/App.app' }, { platform: 'ios' }), + sessionName: session.name, + sessionStore: store, + inspectFacts: runtime.inspectFacts, + bindDevice: runtime.bindDevice, + }); + + expect(response.ok).toBe(true); +}); + +test('install_source still refuses a leaf selector that names a different platform than the session', async () => { + const store = makeStore(); + const session = makeSession({ + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + booted: true, + }); + store.set(session.name, session); + const runtime = createSourceRuntime( + session.device, + async () => ({ installablePath: '/tmp/App.app', cleanup: async () => {} }), + async () => ({}) as never, + ); + + const response = await handleInstallFromSourceDeploymentCommand({ + req: makeRequest({ kind: 'path', path: '/tmp/App.app' }, { platform: 'android' }), + sessionName: session.name, + sessionStore: store, + inspectFacts: runtime.inspectFacts, + bindDevice: runtime.bindDevice, + }); + + expect(response).toMatchObject({ ok: false, error: { code: 'INVALID_ARGS' } }); + if (response.ok) return; + expect(response.error.message).toContain('bound to ios'); + expect(response.error.message).not.toContain('apple'); +}); + function createSourceRuntime( device: DeviceInfo, materializeAppSource: PlatformRuntimeOperations['materializeAppSource'], diff --git a/src/daemon/handlers/session-app-source-deployment.ts b/src/daemon/handlers/session-app-source-deployment.ts index 4ce8400339..c1ee5a432c 100644 --- a/src/daemon/handlers/session-app-source-deployment.ts +++ b/src/daemon/handlers/session-app-source-deployment.ts @@ -4,7 +4,11 @@ import type { } from '@agent-device/contracts/app-deployment-runtime'; import type { CommandFlags } from '@agent-device/contracts/command'; import { readyMaterializeAndDeployAppUse } from '@agent-device/contracts/app-deployment-runtime-plan'; -import { isIosFamily } from '@agent-device/kernel/device'; +import { + isIosFamily, + matchesPlatformSelector, + publicPlatformString, +} from '@agent-device/kernel/device'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { cleanupRetainedMaterializedPaths, @@ -127,10 +131,10 @@ async function resolveInstallDevice( flags: DaemonRequest['flags'] | undefined, ): Promise { const requestedPlatform = normalizePlatform(flags?.platform); - if (session && requestedPlatform && session.device.platform !== requestedPlatform) { + if (session && requestedPlatform && !matchesPlatformSelector(session.device, requestedPlatform)) { throw new AppError( 'INVALID_ARGS', - `install_from_source requested platform ${requestedPlatform}, but session is bound to ${session.device.platform}`, + `install_from_source requested platform ${requestedPlatform}, but session is bound to ${publicPlatformString(session.device)}`, ); } if (!session && !requestedPlatform) { diff --git a/src/daemon/request-lock-policy.ts b/src/daemon/request-lock-policy.ts index 105233cda0..86c43f7719 100644 --- a/src/daemon/request-lock-policy.ts +++ b/src/daemon/request-lock-policy.ts @@ -9,7 +9,7 @@ import { type SessionSelectorConflictKey, } from './session-selector.ts'; import { - isApplePlatform, + platformSelectorsConflict, publicPlatformString, type PlatformSelector, } from '@agent-device/kernel/device'; @@ -216,17 +216,6 @@ function listFreshSessionConflicts( return conflicts; } -function platformSelectorsConflict( - requested: PlatformSelector | undefined, - locked: PlatformSelector | undefined, -): boolean { - if (!requested || !locked) return false; - if (requested === locked) return false; - if (requested === 'apple') return !isApplePlatform(locked); - if (locked === 'apple') return !isApplePlatform(requested); - return true; -} - function appendFreshSessionTargetConflict( conflicts: SessionSelectorConflict[], flags: CommandFlags, diff --git a/src/remote/__tests__/remote-connection-state.test.ts b/src/remote/__tests__/remote-connection-state.test.ts index 96587bca70..0ba0166f7f 100644 --- a/src/remote/__tests__/remote-connection-state.test.ts +++ b/src/remote/__tests__/remote-connection-state.test.ts @@ -2,10 +2,13 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import type { DeviceInfo } from '@agent-device/kernel/device'; import { mkdtempForTest } from '../../__tests__/test-utils/tmp-dir.ts'; import { + buildConnectionDeviceKey, buildRemoteConnectionDaemonState, hashRemoteConfigFile, + resolveConnectionDeviceScope, resolveRemoteConnectionDefaults, writeRemoteConnectionState, type RemoteConnectionState, @@ -18,6 +21,87 @@ import { const FAKE_DAEMON_TOKEN = 'test-not-a-real-daemon-token'; +const IOS_SIMULATOR: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'SIM-001', + name: 'iPhone 16', +}; + +const ANDROID_EMULATOR: DeviceInfo = { + platform: 'android', + target: 'mobile', + kind: 'emulator', + id: 'emulator-5554', + name: 'Pixel 8', +}; + +const MACOS_HOST: DeviceInfo = { + platform: 'apple', + appleOs: 'macos', + target: 'desktop', + kind: 'device', + id: 'HOST-MAC', + name: 'Mac', +}; + +const VEGA_VVD: DeviceInfo = { + platform: 'vega', + target: 'mobile', + kind: 'emulator', + id: 'vvd-1', + name: 'Vega VVD', +}; + +// #2962: a resolved device wrote its internal `apple` platform into the fields a remote connection +// records, which speaks the public leaf, and the scope check refused every iOS install and open. +test('resolveConnectionDeviceScope names a device on the public platform axis, never the internal one', () => { + assert.equal(resolveConnectionDeviceScope(IOS_SIMULATOR).platform, 'ios'); + assert.equal(resolveConnectionDeviceScope(MACOS_HOST).platform, 'macos'); + assert.equal(resolveConnectionDeviceScope(ANDROID_EMULATOR).platform, 'android'); +}); + +test('resolveConnectionDeviceScope pairs each device with the backend and identity flag that address it', () => { + assert.deepEqual(resolveConnectionDeviceScope(IOS_SIMULATOR), { + platform: 'ios', + target: 'mobile', + leaseBackend: 'ios-instance', + identityFlag: 'udid', + id: 'SIM-001', + }); + assert.equal(resolveConnectionDeviceScope(ANDROID_EMULATOR).leaseBackend, 'android-instance'); + assert.equal(resolveConnectionDeviceScope(ANDROID_EMULATOR).identityFlag, 'serial'); + // A platform no lease backend rents cannot be bound by a remote connection, so it names no + // identity flag either: the command fails on the missing backend rather than on a `--udid` the + // daemon reads as iOS-family-only and reports as a conflict against the session being opened. + assert.deepEqual(resolveConnectionDeviceScope(MACOS_HOST), { + platform: 'macos', + target: 'desktop', + leaseBackend: undefined, + identityFlag: undefined, + id: 'HOST-MAC', + }); + assert.equal(resolveConnectionDeviceScope(VEGA_VVD).leaseBackend, undefined); + assert.equal(resolveConnectionDeviceScope(VEGA_VVD).identityFlag, undefined); +}); + +test('buildConnectionDeviceKey keys a device by its public platform and defaulted target', () => { + assert.equal( + buildConnectionDeviceKey(resolveConnectionDeviceScope(IOS_SIMULATOR)), + 'ios:mobile:SIM-001', + ); + assert.equal( + buildConnectionDeviceKey(resolveConnectionDeviceScope(MACOS_HOST)), + 'macos:desktop:HOST-MAC', + ); + assert.equal( + buildConnectionDeviceKey(resolveConnectionDeviceScope({ ...IOS_SIMULATOR, target: undefined })), + 'ios:mobile:SIM-001', + ); +}); + test('buildRemoteConnectionDaemonState does not persist the daemon auth token', () => { const daemon = buildRemoteConnectionDaemonState({ daemonBaseUrl: 'https://daemon.example.test', diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index 29dbba7fa2..bebaecd17b 100644 --- a/src/remote/remote-connection-state.ts +++ b/src/remote/remote-connection-state.ts @@ -3,10 +3,22 @@ import fs from 'node:fs'; import path from 'node:path'; import { resolveRemoteConfigPath, resolveRemoteConfigProfile } from './remote-config-core.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { + deviceIdentityFlag, + publicPlatformString, + type DeviceIdentityFlag, + type DeviceInfo, + type DeviceTarget, + type PublicPlatform, +} from '@agent-device/kernel/device'; import { publishFileSync } from '@agent-device/host-kit/file'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { CliFlags } from '@agent-device/contracts/command'; -import type { LeaseBackend, SessionRuntimeHints } from '@agent-device/kernel/contracts'; +import { + leaseBackendForPlatform, + type LeaseBackend, + type SessionRuntimeHints, +} from '@agent-device/kernel/contracts'; import { leaseScopeFromOptions, leaseScopeToCommandFlags, @@ -47,6 +59,55 @@ export type RemoteConnectionRequestMetadata = Pick< 'leaseProvider' | 'deviceKey' | 'clientId' >; +/** + * A resolved device projected onto the axes a remote connection records it on. + * + * A `DeviceInfo` carries the INTERNAL platform axis (`apple`, with `appleOs` as the OS + * discriminant), while every field above — `platform`, `target`, `deviceKey`, `leaseBackend` — + * speaks the PUBLIC leaf axis (`ios`/`macos`, ADR 0009). This is where the two axes meet, so those + * fields can never disagree about which axis a device was named on. Reading `device.platform` + * directly instead was #2962: an iOS device recorded `apple` while the connection held `ios`, and + * the scope check compared the two and refused every iOS install and open on a proxy lease. + * + * Each rule it composes stays with its owning module; this answers only "which device, named how, + * rented by whom". + */ +export type ConnectionDeviceScope = Readonly<{ + platform: PublicPlatform; + /** The target as the device records it; `undefined` leaves an existing selection untouched. */ + target: DeviceTarget | undefined; + /** The lease backend that rents this device, or `undefined` when no backend leases it. */ + leaseBackend: LeaseBackend | undefined; + /** + * The flag that names this device to a request, or `undefined` when it names none. + * + * Only a device a backend can rent gets one. A platform with no lease backend cannot be bound by + * a remote connection at all, so its identity is never sent — and for the macOS desktop host it + * must not be: the daemon's own selector rule reads `--udid` as an iOS-family selector and would + * report a conflict against the session this very command is opening. Such a device fails on the + * missing backend, which names the real problem, instead of on a selector it could never use. + */ + identityFlag: DeviceIdentityFlag | undefined; + id: string; +}>; + +export function resolveConnectionDeviceScope(device: DeviceInfo): ConnectionDeviceScope { + const platform = publicPlatformString(device); + const leaseBackend = leaseBackendForPlatform(platform); + return { + platform, + target: device.target, + leaseBackend, + identityFlag: leaseBackend ? deviceIdentityFlag(platform) : undefined, + id: device.id, + }; +} + +/** The `deviceKey` for a resolved device: its identity on the public platform and target axes. */ +export function buildConnectionDeviceKey(scope: ConnectionDeviceScope): string { + return `${scope.platform}:${scope.target ?? 'mobile'}:${scope.id}`; +} + type RemoteConnectionDefaults = { flags: Partial; runtime?: SessionRuntimeHints; From 60a0e913d1a2b4dda37da223dceec59bb0be7dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 25 Sep 2026 21:59:03 +0200 Subject: [PATCH 2/8] test(remote): pin each platform-axis boundary against its own regression 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). --- .../remote-connection-platform-axis.test.ts | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 src/__tests__/remote-connection-platform-axis.test.ts diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts new file mode 100644 index 0000000000..4e398bf216 --- /dev/null +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -0,0 +1,322 @@ +// The platform axis a remote connection is named on (#2962). +// +// A `DeviceInfo` carries the INTERNAL `apple` platform while everything a connection records or +// sends — `--platform`, the connection state, the proxy device key, the lease request — speaks the +// PUBLIC leaf (`ios`/`macos`, ADR 0009). Comparing the two axes by string equality refused every +// iOS install and open on a proxy lease and demanded `connect --force` for a connection that had +// not changed. These pin both directions: the family and leaf selectors name the same devices, and +// a genuinely different platform is still refused. +// +// Kept out of `remote-connection.test.ts`, which is already over the test-file size tripwire and +// may not grow (docs/agents/testing.md). + +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { + connectionWorkspace, + createTestClient, + recordedLeaseAllocate, + seedConnectionState, +} from './remote-connection.fixtures.ts'; +import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import { readRemoteConnectionState } from '../remote/remote-connection-state.ts'; + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +test('proxy install against an iOS-bound connection is not refused as a platform change', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-proxy-ios-bound-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-proxy', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + platform: 'ios', + leaseBackend: 'ios-instance', + }, + }); + const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'install', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform: 'ios', + }, + client: createTestClient({ + listDevices: async () => [ + { + platform: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'SIM-001', + name: 'iPhone 16', + booted: true, + identifiers: { udid: 'SIM-001' }, + ios: { udid: 'SIM-001' }, + }, + ], + allocate: allocate.stub, + }), + }); + + assert.equal(materialized.flags.leaseId, 'ios-lease-1'); + assert.equal(allocate.request?.deviceKey, 'ios:mobile:SIM-001'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +// The connection records a leaf; `--platform apple` names the same devices. Comparing the two by +// string equality refused the request and demanded `connect --force` for a connection that had not +// changed at all (#2962). +test('proxy install with the apple family selector matches an ios-bound connection', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-proxy-apple-selector-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-proxy', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + platform: 'ios', + leaseBackend: 'ios-instance', + }, + }); + const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); + + await materializeRemoteConnectionForCommand({ + command: 'install', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform: 'apple', + }, + client: createTestClient({ + listDevices: async () => [ + { + platform: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'SIM-001', + name: 'iPhone 16', + booted: true, + identifiers: { udid: 'SIM-001' }, + ios: { udid: 'SIM-001' }, + }, + ], + allocate: allocate.stub, + }), + }); + + assert.equal(allocate.request?.deviceKey, 'ios:mobile:SIM-001'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +// The comparison itself, without a device resolution in the way: the connection bound `ios` from +// `connect`, and `--platform apple` names those same devices. String inequality refused the command +// and told the user to reconnect (#2962), while `apple` versus a non-Apple platform must still +// refuse. +test('remote command with the apple family selector matches an ios-bound connection', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-apple-selector-scope-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-apple', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + leaseId: 'apple-lease-1', + leaseBackend: 'ios-instance', + platform: 'ios', + }, + }); + const heartbeats: string[] = []; + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'snapshot', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-apple', + platform: 'apple', + }, + client: createTestClient({ + heartbeat: async (request) => { + heartbeats.push(request.leaseId); + return { + leaseId: request.leaseId, + tenantId: 'acme', + runId: 'run-123', + backend: 'ios-instance', + }; + }, + }), + }); + + assert.deepEqual(heartbeats, ['apple-lease-1']); + assert.equal(materialized.flags.leaseId, 'apple-lease-1'); + assert.equal(materialized.flags.platform, 'ios'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('proxy install against a differently-bound platform is still refused', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-proxy-platform-conflict-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-proxy', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + platform: 'ios', + leaseBackend: 'ios-instance', + }, + }); + + await assert.rejects( + async () => + await materializeRemoteConnectionForCommand({ + command: 'install', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform: 'android', + }, + client: createTestClient(), + }), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /different platform/.test(error.message), + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +// The resolved device's internal platform is the collapsed `apple`, while the lease request, the +// connection state, and `--platform` all speak the public leaf `ios`. Writing the internal value +// into the connection state is what #2962 reported: it refused the next command of the same +// session, whose bound platform was read back out of that state. +test('proxy install records the public platform and the next command reuses that scope', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-proxy-ios-install-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-proxy', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + }, + }); + const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); + const client = createTestClient({ + listDevices: async () => [ + { + platform: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'SIM-001', + name: 'iPhone 16', + booted: true, + identifiers: { udid: 'SIM-001' }, + ios: { udid: 'SIM-001' }, + }, + ], + allocate: allocate.stub, + }); + const install = () => + materializeRemoteConnectionForCommand({ + command: 'install', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform: 'ios', + }, + client, + }); + + const materialized = await install(); + assert.equal(materialized.flags.leaseId, 'ios-lease-1'); + assert.equal(materialized.flags.platform, 'ios'); + assert.equal(materialized.flags.udid, 'SIM-001'); + assert.equal(allocate.request?.platform, 'ios'); + assert.equal(allocate.request?.deviceKey, 'ios:mobile:SIM-001'); + + const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); + assert.equal(state?.platform, 'ios'); + assert.equal(state?.deviceKey, 'ios:mobile:SIM-001'); + + // The second command reads its bound platform back out of that state. + const reused = await install(); + assert.equal(reused.flags.platform, 'ios'); + assert.equal(reused.flags.leaseId, 'ios-lease-1'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); From 9fb87f30f2f6e9be2fe76780d46352ad32b0f938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 08:34:15 +0200 Subject: [PATCH 3/8] test(remote): refuse the platform-change guard on typed details, not 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. --- src/__tests__/remote-connection-platform-axis.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index 4e398bf216..196b696a65 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -243,7 +243,8 @@ test('proxy install against a differently-bound platform is still refused', asyn (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && - /different platform/.test(error.message), + error.details?.session === 'adc-proxy' && + error.details?.platform === 'ios', ); fs.rmSync(tempRoot, { recursive: true, force: true }); }); From 0c95f56a109b7c4f06a36f77b84d3f9dca046932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 08:34:44 +0200 Subject: [PATCH 4/8] fix(remote): collapse a recorded apple family alias when a device gets 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. --- .../remote-connection-platform-axis.test.ts | 88 +++++++++++++++++++ src/cli/commands/connection-runtime.ts | 8 +- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index 196b696a65..098029df95 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -321,3 +321,91 @@ test('proxy install records the public platform and the next command reuses that assert.equal(reused.flags.leaseId, 'ios-lease-1'); fs.rmSync(tempRoot, { recursive: true, force: true }); }); + +// A connection opened with the `apple` family selector records that alias before any device is +// bound. Once a command resolves one specific device, the record must collapse to that device's +// leaf: keeping the alias would let a later command name the OTHER leaf of the same family — +// macOS against an iOS-bound lease — pass the scope guard, because family and leaf never +// conflict, and take the device's lease under a selector that names a different machine. +test('a connection opened on the apple family collapses to the bound device leaf', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-proxy-apple-family-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-proxy', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'proxy-client-1', + leaseProvider: 'proxy', + clientId: 'client-1', + platform: 'apple', + leaseBackend: 'ios-instance', + }, + }); + const allocate = recordedLeaseAllocate({ leaseId: 'ios-lease-1', backend: 'ios-instance' }); + const client = createTestClient({ + listDevices: async () => [ + { + platform: 'ios', + target: 'mobile', + kind: 'simulator', + id: 'SIM-001', + name: 'iPhone 16', + booted: true, + identifiers: { udid: 'SIM-001' }, + ios: { udid: 'SIM-001' }, + }, + { + platform: 'macos', + target: 'desktop', + kind: 'device', + id: 'MAC-1', + name: 'Mac', + booted: true, + identifiers: {}, + }, + ], + allocate: allocate.stub, + }); + const install = (platform: 'apple' | 'ios' | 'macos') => + materializeRemoteConnectionForCommand({ + command: 'install', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'proxy', + runId: 'proxy-client-1', + session: 'adc-proxy', + platform, + }, + client, + }); + + const materialized = await install('ios'); + assert.equal(materialized.flags.leaseId, 'ios-lease-1'); + assert.equal(materialized.flags.udid, 'SIM-001'); + + const state = readRemoteConnectionState({ stateDir, session: 'adc-proxy' }); + assert.equal(state?.platform, 'ios'); + assert.equal(state?.deviceKey, 'ios:mobile:SIM-001'); + + // macOS shares the `apple` family with the bound iOS simulator, so only the collapsed leaf + // above — not the family alias — refuses this request before it touches the lease. + await assert.rejects( + async () => await install('macos'), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.details?.session === 'adc-proxy' && + error.details?.platform === 'ios', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 4dcba7f8ac..72f3e66107 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -874,7 +874,13 @@ async function resolveProxyLeaseState(options: { ...options.state, deviceKey: buildConnectionDeviceKey(scope), leaseBackend: options.state.leaseBackend ?? options.leaseBackend ?? scope.leaseBackend, - platform: options.state.platform ?? scope.platform, + // A recorded `apple` was a family selection made before any device was bound. Once this + // command binds one, the record must name the leaf its own `deviceKey` speaks; keeping the + // alias would let a later leaf on the same family pass the scope guard against this device. + platform: + options.state.platform === 'apple' + ? scope.platform + : (options.state.platform ?? scope.platform), target: options.state.target ?? scope.target, updatedAt: new Date().toISOString(), }, From cc4c3405c827c6f63a495fa191d9f05eb955ea42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 26 Sep 2026 16:31:44 +0200 Subject: [PATCH 5/8] fix(remote): decide the platform family wherever a lease gets bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/kernel/src/contracts.ts | 20 +- .../remote-connection-platform-axis.test.ts | 181 ++++++++++++++++++ src/cli/commands/connection-runtime.ts | 32 +++- src/cli/commands/connection.ts | 21 +- .../__tests__/remote-connection-state.test.ts | 58 ++++++ src/remote/remote-connection-state.ts | 50 +++++ 6 files changed, 333 insertions(+), 29 deletions(-) diff --git a/packages/kernel/src/contracts.ts b/packages/kernel/src/contracts.ts index 8d79e0e714..87cd0f872a 100644 --- a/packages/kernel/src/contracts.ts +++ b/packages/kernel/src/contracts.ts @@ -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]; @@ -83,6 +83,24 @@ export function leaseBackendForPlatform( 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> = { + 'ios-instance': 'ios', + 'android-instance': 'android', + 'harmonyos-instance': 'harmonyos', +}; + +export function platformForLeaseBackend(backend: LeaseBackend): PublicPlatform | undefined { + return PLATFORM_BY_LEASE_BACKEND[backend]; +} + 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; diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index 098029df95..792e467e5a 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -19,6 +19,7 @@ import { recordedLeaseAllocate, seedConnectionState, } from './remote-connection.fixtures.ts'; +import { connectCommand } from '../cli/commands/connection.ts'; import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; import { AppError } from '@agent-device/kernel/errors'; import { readRemoteConnectionState } from '../remote/remote-connection-state.ts'; @@ -409,3 +410,183 @@ test('a connection opened on the apple family collapses to the bound device leaf ); fs.rmSync(tempRoot, { recursive: true, force: true }); }); + +// The same rule on the policies that never resolve a device themselves. A `connect --platform apple +// --lease-backend ios-instance` records the alias next to a backend that already rents only iOS +// devices, and this is the one command that turns it into a leaf: without the collapse the next +// `--platform macos` passed the guard, and its request went to the daemon as `apple` against the +// `ios-instance` lease instead of being refused here. +test('a default-policy connection collapses its recorded apple alias when the lease is bound', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-default-apple-binding-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-default', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-9', + leaseBackend: 'ios-instance', + platform: 'apple', + }, + }); + const allocate = recordedLeaseAllocate({ leaseId: 'default-lease-1', backend: 'ios-instance' }); + const command = (platform: 'apple' | 'ios' | 'macos') => + materializeRemoteConnectionForCommand({ + command: 'snapshot', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-9', + session: 'adc-default', + platform, + }, + client: createTestClient({ allocate: allocate.stub }), + }); + + const materialized = await command('apple'); + assert.equal(materialized.flags.leaseId, 'default-lease-1'); + assert.equal(materialized.flags.platform, 'ios', 'the request names the leaf it leased on'); + assert.equal(allocate.request?.platform, 'ios', 'so does the allocate payload'); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-default' })?.platform, + 'ios', + 'and so does the record the next command is guarded against', + ); + + await assert.rejects( + async () => await command('macos'), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.details?.platform === 'ios', + 'a second leaf of the same family has to be refused, not retargeted', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +// A state file written before the collapse existed records `apple` next to the backend that settled +// it, and a record that already matches its lease is never rewritten — so this connection stays as +// it was saved. The guard has to read the leaf that record owes, or the second leaf of the family +// still walks past it and the command goes out as `apple` on an `ios-instance` lease. +test('a stored apple record refuses the other leaf even though nothing rewrote it', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-apple-legacy-record-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-legacy', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-9', + leaseId: 'legacy-lease-1', + leaseBackend: 'ios-instance', + deviceKey: 'ios:mobile:SIM-001', + platform: 'apple', + }, + }); + const heartbeats: string[] = []; + const command = (platform: 'macos') => + materializeRemoteConnectionForCommand({ + command: 'snapshot', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-9', + session: 'adc-legacy', + platform, + }, + client: createTestClient({ + heartbeat: async (request) => { + heartbeats.push(request.leaseId); + return { + leaseId: request.leaseId, + tenantId: 'acme', + runId: 'run-9', + backend: 'ios-instance', + }; + }, + }), + }); + + await assert.rejects( + async () => await command('macos'), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.details?.platform === 'ios', + ); + assert.deepEqual(heartbeats, [], 'the lease was never touched by the refused request'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +// `connect` asks the same question a command does — is this the connection already bound? — and it +// answers it before writing anything. A record saved as `apple` next to the `ios-instance` backend +// that decided it counted as compatible with `--platform macos`, because a family and a leaf never +// conflict, so the new selector was accepted onto the iOS device's connection with no `--force`. +test('connect refuses to reuse an apple-bound connection for the other leaf', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-apple-reuse-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-apple-reuse', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-9', + leaseId: 'apple-lease-1', + leaseBackend: 'ios-instance', + platform: 'apple', + }, + }); + const connect = (platform: 'apple' | 'ios' | 'macos') => + connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-9', + session: 'adc-apple-reuse', + platform, + leaseBackend: 'ios-instance', + }, + client: createTestClient(), + }); + + await assert.rejects( + async () => await connect('macos'), + /A different remote connection is already active/, + "the other leaf of the family can't ride along on this lease", + ); + await connect('ios'); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-apple-reuse' })?.platform, + 'ios', + 'the leaf the backend rents is what the reconnected record carries', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 72f3e66107..47a51781b1 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -15,6 +15,7 @@ import { } from '@agent-device/kernel/device'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import { + boundConnectionPlatform, buildConnectionDeviceKey, buildRemoteConnectionDaemonState, buildRemoteConnectionRequestMetadata, @@ -321,6 +322,17 @@ async function materializeLeaseForCommand(options: { preliminaryLeaseBackend ?? requireRequestedLeaseBackend(nextFlags, command); assertRequestedConnectionScope(state, nextFlags, leaseBackend); + // Binding a lease is the moment the platform family is decided, so every field this command + // records or sends from here on — the allocate payload, the flags the request carries, the state + // written below — names the leaf the backend rents, never the `apple` alias asked for. + nextState = { + ...nextState, + platform: boundConnectionPlatform({ + platform: nextState.platform ?? nextFlags.platform, + leaseBackend, + }), + }; + nextFlags.platform = nextState.platform ?? nextFlags.platform; const materializedLease = await allocateOrReuseLease( client, nextState, @@ -874,13 +886,7 @@ async function resolveProxyLeaseState(options: { ...options.state, deviceKey: buildConnectionDeviceKey(scope), leaseBackend: options.state.leaseBackend ?? options.leaseBackend ?? scope.leaseBackend, - // A recorded `apple` was a family selection made before any device was bound. Once this - // command binds one, the record must name the leaf its own `deviceKey` speaks; keeping the - // alias would let a later leaf on the same family pass the scope guard against this device. - platform: - options.state.platform === 'apple' - ? scope.platform - : (options.state.platform ?? scope.platform), + platform: scope.platform, target: options.state.target ?? scope.target, updatedAt: new Date().toISOString(), }, @@ -940,11 +946,19 @@ function assertRequestedConnectionScope( { session: state.session, leaseBackend: state.leaseBackend }, ); } - if (platformSelectorsConflict(flags.platform, state.platform)) { + // A record saved before the collapse existed, or one whose lease already matched so nothing + // rewrote it, still names the `apple` family beside the backend that decided it. The guard reads + // the leaf that record owes; a connection that recorded no platform is bound to none, so a + // selector cannot conflict with it. + const boundPlatform = boundConnectionPlatform({ + platform: state.platform, + leaseBackend: state.leaseBackend, + }); + if (platformSelectorsConflict(flags.platform, boundPlatform)) { throw new AppError( 'INVALID_ARGS', 'Active remote connection is already bound to a different platform. Re-run connect --force to replace it.', - { session: state.session, platform: state.platform }, + { session: state.session, platform: boundPlatform }, ); } if (state.target && flags.target && state.target !== flags.target) { diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index bf25b06303..c234c2eb2c 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -11,12 +11,12 @@ import { readRemoteConnectionState, remoteConnectionLeaseIdentityMatches, removeRemoteConnectionState, + connectionPlatformMatchesSelection, writeRemoteConnectionState, type RemoteConnectionState, type RemoteConnectionRequestMetadata, } from '../../remote/remote-connection-state.ts'; import { AppError } from '@agent-device/kernel/errors'; -import { platformSelectorsConflict } from '@agent-device/kernel/device'; import { connectProviderNamesForError, connectionProviderCapabilities, @@ -425,7 +425,7 @@ function optionalConnectionFieldsMatch( state: RemoteConnectionState, options: Parameters[1], ): boolean { - if (!platformSelectionMatches(state.platform, options.flags.platform)) return false; + if (!connectionPlatformMatchesSelection(state, options.flags.platform)) return false; const fieldsMatch = [ [state.leaseBackend, options.desiredLeaseBackend], [state.target, options.flags.target], @@ -433,23 +433,6 @@ function optionalConnectionFieldsMatch( return fieldsMatch && remoteConnectionLeaseIdentityMatches(state, options.connection); } -/** - * Whether a `connect --platform` names the platform the active connection is already bound to. - * - * The comparison goes through the selector rule rather than string equality: `--platform apple` - * names the same devices as the `ios` a profile recorded, and treating those as two connections - * forced a needless `--force` (#2962). A connection that recorded no platform stays bound to - * nothing, so a request that names one still counts as a different connection. - */ -function platformSelectionMatches( - bound: RemoteConnectionState['platform'], - requested: CliFlags['platform'], -): boolean { - if (requested === undefined) return true; - if (bound === undefined) return false; - return !platformSelectorsConflict(requested, bound); -} - function isSameDaemonState( a: RemoteConnectionState['daemon'], b: RemoteConnectionState['daemon'], diff --git a/src/remote/__tests__/remote-connection-state.test.ts b/src/remote/__tests__/remote-connection-state.test.ts index 0ba0166f7f..5f72fb4565 100644 --- a/src/remote/__tests__/remote-connection-state.test.ts +++ b/src/remote/__tests__/remote-connection-state.test.ts @@ -5,7 +5,9 @@ import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { mkdtempForTest } from '../../__tests__/test-utils/tmp-dir.ts'; import { + boundConnectionPlatform, buildConnectionDeviceKey, + connectionPlatformMatchesSelection, buildRemoteConnectionDaemonState, hashRemoteConfigFile, resolveConnectionDeviceScope, @@ -184,3 +186,59 @@ test('resolveRemoteConnectionDefaults falls back to the environment token', asyn assert.equal(defaults?.flags.daemonAuthToken, FAKE_DAEMON_TOKEN); }); + +// The rule that decides whether a recorded platform is still a family selection: the backend is what +// settles it. With none, the alias is kept rather than guessed — a connection that named no backend +// can legitimately serve either Apple leaf. +test('boundConnectionPlatform collapses apple only when a backend names the leaf', () => { + const bound = boundConnectionPlatform({ platform: 'apple', leaseBackend: undefined }); + assert.equal(bound, 'apple'); + // A backend is what decides it, including one whose platform the backend does not name. + assert.equal( + boundConnectionPlatform({ platform: 'apple', leaseBackend: 'ios-simulator' }), + 'apple', + 'a runner-guard backend names no platform to collapse to', + ); + assert.equal( + boundConnectionPlatform({ platform: 'apple', leaseBackend: 'android-instance' }), + 'android', + ); + // A leaf is already decided, whichever backend it leased on. + assert.equal( + boundConnectionPlatform({ platform: 'ios', leaseBackend: 'ios-instance' }), + 'ios', + 'a leaf passes through untouched', + ); +}); + +// The reuse question `connect` asks: is this the same connection? A record that still names the +// `apple` family beside an `ios-instance` backend must not answer yes to `--platform macos`, which +// is how a stored alias let a macOS request reuse an iOS device's connection. The selector rule +// alone says family-vs-leaf is no conflict, so the collapse has to be part of this answer too. +test('connectionPlatformMatchesSelection refuses the other leaf of a bound apple record', () => { + const boundIos = { platform: 'apple', leaseBackend: 'ios-instance' } as const; + assert.equal(connectionPlatformMatchesSelection(boundIos, 'macos'), false); + assert.equal( + connectionPlatformMatchesSelection(boundIos, 'apple'), + true, + 'naming the family still matches the connection it named', + ); + assert.equal( + connectionPlatformMatchesSelection(boundIos, 'ios'), + true, + 'and so does the leaf the backend rents', + ); + assert.equal( + connectionPlatformMatchesSelection(boundIos, undefined), + true, + 'a request that names no platform asks for no platform', + ); + // The other direction of #2962: an unbound record is bound to nothing, so a request naming a + // platform is a different connection rather than a match. + assert.equal(connectionPlatformMatchesSelection({ platform: undefined }, 'ios'), false); + assert.equal( + connectionPlatformMatchesSelection({ platform: 'apple' }, 'macos'), + true, + 'with no backend, nothing has decided the family and the alias stands', + ); +}); diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index bebaecd17b..1f11307d3a 100644 --- a/src/remote/remote-connection-state.ts +++ b/src/remote/remote-connection-state.ts @@ -5,6 +5,7 @@ import { resolveRemoteConfigPath, resolveRemoteConfigProfile } from './remote-co import { AppError } from '@agent-device/kernel/errors'; import { deviceIdentityFlag, + platformSelectorsConflict, publicPlatformString, type DeviceIdentityFlag, type DeviceInfo, @@ -16,6 +17,7 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { CliFlags } from '@agent-device/contracts/command'; import { leaseBackendForPlatform, + platformForLeaseBackend, type LeaseBackend, type SessionRuntimeHints, } from '@agent-device/kernel/contracts'; @@ -108,6 +110,54 @@ export function buildConnectionDeviceKey(scope: ConnectionDeviceScope): string { return `${scope.platform}:${scope.target ?? 'mobile'}:${scope.id}`; } +/** + * The platform a command records once a lease is bound: the leaf of the device the lease holds. + * + * `apple` is a family selection a caller makes before any device exists, and a family never + * conflicts with a leaf — so a connection left recording `apple` accepts a later `--platform macos` + * against the iOS device its own lease is paying for, and forwards that request to the daemon as + * `apple` on an `ios-instance` lease (#2962's shape, one axis wider). Binding a lease decides the + * family, so the alias collapses here and every later comparison is leaf-to-leaf. + * + * A backend that names no platform — `ios-simulator`, a runner guard below device leases — and a + * connection with no backend at all keep the selector as named: nothing has decided the family yet, + * and inventing a leaf would be the same axis mistake in the other direction. + */ +export function boundConnectionPlatform( + evidence: Readonly<{ + platform?: CliFlags['platform']; + leaseBackend?: LeaseBackend; + }>, +): CliFlags['platform'] { + if (evidence.platform !== 'apple' || !evidence.leaseBackend) return evidence.platform; + return platformForLeaseBackend(evidence.leaseBackend) ?? evidence.platform; +} + +/** + * Whether a `--platform` selector names the platform a connection is bound to. + * + * Both halves are needed and neither is enough alone. The selector rule answers family-vs-leaf as a + * match, which is right for a fresh selection — `--platform apple` and a recorded `ios` name the same + * devices — and wrong for a record whose backend already decided the family. A record saved before + * the collapse existed, or one whose lease already matched so nothing rewrote it, still says `apple` + * next to an `ios-instance` backend; comparing that alias against `--platform macos` calls the + * connection reusable and sends a macOS request against an iOS device's lease. + */ +export function connectionPlatformMatchesSelection( + state: Readonly<{ + platform?: CliFlags['platform']; + leaseBackend?: LeaseBackend; + }>, + requested: CliFlags['platform'], +): boolean { + if (requested === undefined) return true; + if (state.platform === undefined) return false; + return !platformSelectorsConflict( + requested, + boundConnectionPlatform({ platform: state.platform, leaseBackend: state.leaseBackend }), + ); +} + type RemoteConnectionDefaults = { flags: Partial; runtime?: SessionRuntimeHints; From a30ebdaa45777bb1a211a6a87174648e1e51a832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 27 Sep 2026 12:35:29 +0200 Subject: [PATCH 6/8] fix(remote): decide the platform axis once, as the narrowest of its three sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- packages/kernel/src/contracts.ts | 21 ++- .../remote-connection-harmonyos.test.ts | 92 +++++++++--- .../remote-connection-platform-axis.test.ts | 139 +++++++++++++++++- src/cli/commands/connection-runtime.ts | 90 ++++++++---- src/cli/commands/connection.ts | 19 ++- .../__tests__/remote-connection-state.test.ts | 76 +++++++--- src/remote/remote-connection-state.ts | 84 +++++++---- 7 files changed, 410 insertions(+), 111 deletions(-) diff --git a/packages/kernel/src/contracts.ts b/packages/kernel/src/contracts.ts index 87cd0f872a..9c18499d09 100644 --- a/packages/kernel/src/contracts.ts +++ b/packages/kernel/src/contracts.ts @@ -90,15 +90,20 @@ export function leaseBackendForPlatform( * 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. + * + * Derived from the forward table rather than written beside it, so the two axes cannot drift, and held + * in a `Map` because a `leaseBackend` reaching here can be any string an older binary left on disk: a + * plain object would answer `constructor` and friends with an inherited function, which is a platform + * nobody rents. */ -const PLATFORM_BY_LEASE_BACKEND: Partial> = { - 'ios-instance': 'ios', - 'android-instance': 'android', - 'harmonyos-instance': 'harmonyos', -}; - -export function platformForLeaseBackend(backend: LeaseBackend): PublicPlatform | undefined { - return PLATFORM_BY_LEASE_BACKEND[backend]; +const PLATFORM_BY_LEASE_BACKEND = new Map( + Object.entries(LEASE_BACKEND_BY_PLATFORM).flatMap(([platform, backend]) => + backend === undefined ? [] : [[backend, platform as PublicPlatform] as const], + ), +); + +export function platformForLeaseBackend(backend: string): PublicPlatform | undefined { + return PLATFORM_BY_LEASE_BACKEND.get(backend); } const DAEMON_SERVER_MODES = ['socket', 'http', 'dual'] as const; diff --git a/src/__tests__/remote-connection-harmonyos.test.ts b/src/__tests__/remote-connection-harmonyos.test.ts index f35b4a0923..3ecfc8e8bb 100644 --- a/src/__tests__/remote-connection-harmonyos.test.ts +++ b/src/__tests__/remote-connection-harmonyos.test.ts @@ -26,10 +26,77 @@ test('HarmonyOS platform resolves to its proxy lease backend', () => { ).toBe('harmonyos-instance'); }); -test.each(['apple', 'harmonyos', 'ios', 'android'] as const)( - 'stored Harmony runtime compatibility respects %s selection', +test('stored Harmony runtime compatibility keeps the HarmonyOS runtime for a HarmonyOS selection', async () => { + const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-runtime-'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'runtime-compat', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'proxy', + runId: 'compat-run', + leaseId: 'compat-existing', + leaseBackend: 'harmonyos-instance', + runtime: { platform: 'harmonyos', launchUrl: 'demo://open' }, + }, + }); + const materialized = await materializeRemoteConnectionForCommand({ + command: 'snapshot', + client: createTestClient(), + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + session: 'runtime-compat', + platform: 'harmonyos', + }, + }); + expect(materialized.runtime?.platform).toBe('harmonyos'); +}); + +/** + * A backend that rents HarmonyOS devices is the device this connection holds, even when the record + * never wrote a platform, so any other family is refused rather than sent along the existing lease + * (#2962). Before the backend was read, `apple` silently ran as a HarmonyOS snapshot and `ios` went + * out as an `ios` snapshot — both against a `harmonyos-instance` lease. + */ +async function expectRejectedSelection( + stateDir: string, + remoteConfigPath: string, + platform: 'apple' | 'ios' | 'android', +): Promise { + await expect( + materializeRemoteConnectionForCommand({ + command: 'snapshot', + client: createTestClient(), + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + session: 'runtime-compat', + platform, + }, + }), + ).rejects.toMatchObject({ + code: 'INVALID_ARGS', + details: { + reason: 'CONNECTION_PLATFORM_CONFLICT', + platform: 'harmonyos', + requestedPlatform: platform, + }, + }); +} + +test.each(['apple', 'ios', 'android'] as const)( + 'a harmonyos-instance lease refuses a %s selection', async (platform) => { - const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-runtime-'); + const { stateDir, remoteConfigPath } = connectionWorkspace('harmonyos-runtime-reject-'); fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); seedConnectionState({ stateDir, @@ -44,24 +111,7 @@ test.each(['apple', 'harmonyos', 'ios', 'android'] as const)( runtime: { platform: 'harmonyos', launchUrl: 'demo://open' }, }, }); - const materialized = await materializeRemoteConnectionForCommand({ - command: 'snapshot', - client: createTestClient(), - flags: { - json: true, - help: false, - version: false, - stateDir, - remoteConfig: remoteConfigPath, - session: 'runtime-compat', - platform, - }, - }); - if (platform === 'apple' || platform === 'harmonyos') { - expect(materialized.runtime?.platform).toBe('harmonyos'); - } else { - expect(materialized.runtime).toBeUndefined(); - } + await expectRejectedSelection(stateDir, remoteConfigPath, platform); }, ); diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index 792e467e5a..ec97674d93 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -577,9 +577,16 @@ test('connect refuses to reuse an apple-bound connection for the other leaf', as client: createTestClient(), }); + // `connect` asks whether this is the same connection before it binds anything, so it refuses with + // its own "different connection, needs --force" rather than the platform conflict a command that + // does bind raises. Both refuse; only one names the axis. await assert.rejects( async () => await connect('macos'), - /A different remote connection is already active/, + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.details?.session === 'adc-apple-reuse' && + error.details?.remoteConfig === remoteConfigPath, "the other leaf of the family can't ride along on this lease", ); await connect('ios'); @@ -590,3 +597,133 @@ test('connect refuses to reuse an apple-bound connection for the other leaf', as ); fs.rmSync(tempRoot, { recursive: true, force: true }); }); + +// `connect` writes the platform it is asked for, which let a reused connection widen its own record +// back to the family: an `ios` lease re-declared with `--platform apple` recorded `apple`, and the +// next command could then ask for any Apple leaf and be served on the iOS device that lease holds. +// The family and the leaf name the same device here, so nothing conflicts — the record simply has to +// keep the leaf it already had. +test('connect keeps the bound leaf when re-declared with the family selector', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-apple-redeclare-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + seedConnectionState({ + stateDir, + state: { + session: 'adc-redeclare', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-9', + leaseId: 'ios-lease-1', + leaseBackend: 'ios-instance', + platform: 'ios', + }, + }); + + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-9', + session: 'adc-redeclare', + platform: 'apple', + leaseBackend: 'ios-instance', + }, + client: createTestClient(), + }); + + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-redeclare' })?.platform, + 'ios', + 'the family selector restates the connection, it does not widen it', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +// The two shapes where nothing in the record settles the Apple leaf, so the answer has to come from +// the backend or the request and never from the wider of the two. +// +// - A runner-guard backend (`ios-simulator`) rents no platform, so the requested leaf is the only +// leaf on the table and the recorded alias must not win it. +// - A backend that rents iOS instances has decided the leaf even when the record never wrote one, and +// that is a device this request cannot have. +test('an undecided record narrows to the requested leaf or refuses, and never sends apple', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-undecided-leaf-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + const command = ( + session: string, + leaseBackend: 'ios-simulator' | 'ios-instance', + platform: 'apple' | 'macos', + ) => + materializeRemoteConnectionForCommand({ + command: 'snapshot', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-9', + session, + platform, + }, + client: createTestClient({ + allocate: recordedLeaseAllocate({ leaseId: 'undecided-lease-1', backend: leaseBackend }) + .stub, + }), + }); + + seedConnectionState({ + stateDir, + state: { + session: 'adc-guard', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-9', + leaseBackend: 'ios-simulator', + platform: 'apple', + }, + }); + const narrowed = await command('adc-guard', 'ios-simulator', 'macos'); + assert.equal( + narrowed.flags.platform, + 'macos', + 'a runner guard rents no leaf, so the request keeps its own', + ); + + seedConnectionState({ + stateDir, + state: { + session: 'adc-unplatformed', + remoteConfigPath, + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-9', + leaseBackend: 'ios-instance', + }, + }); + await assert.rejects( + async () => await command('adc-unplatformed', 'ios-instance', 'macos'), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.details?.reason === 'CONNECTION_PLATFORM_CONFLICT' && + error.details?.platform === 'ios' && + error.details?.requestedPlatform === 'macos', + 'a backend that rents iOS is a bound device, alias or no alias', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 47a51781b1..ad37d94cb0 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -9,13 +9,12 @@ import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; import { readRemoteConfigFile } from '../../remote/remote-config-core.ts'; import { deviceFieldsFromPublicPlatform, - platformSelectorsConflict, resolveDevice, type DeviceInfo, } from '@agent-device/kernel/device'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import { - boundConnectionPlatform, + narrowConnectionPlatform, buildConnectionDeviceKey, buildRemoteConnectionDaemonState, buildRemoteConnectionRequestMetadata, @@ -145,6 +144,21 @@ export async function materializeRemoteConnectionForCommand(options: { acquiredLeaseForCleanup = materializedLease.acquiredLeaseForCleanup; } + // A command that allocates no lease still returns flags and a record on the platform axis, and the + // record can carry an alias the request asked to narrow. Same rule as the binding path above, so a + // connection whose backend rents one leaf cannot serve another leaf simply because this command + // never reached the allocator. + const carriedPlatform = narrowConnectionPlatform({ + leaseBackend: nextState.leaseBackend, + recordedPlatform: nextState.platform, + requestedPlatform: nextFlags.platform, + }); + if (!carriedPlatform.ok) { + throw connectionPlatformConflict(state, nextState.leaseBackend, carriedPlatform); + } + nextState = { ...nextState, platform: carriedPlatform.platform }; + nextFlags.platform = carriedPlatform.platform; + const runtimePreparation = await prepareRuntimeForCommand({ command, flags: nextFlags, @@ -321,18 +335,23 @@ async function materializeLeaseForCommand(options: { nextState.leaseBackend ?? preliminaryLeaseBackend ?? requireRequestedLeaseBackend(nextFlags, command); - assertRequestedConnectionScope(state, nextFlags, leaseBackend); - // Binding a lease is the moment the platform family is decided, so every field this command - // records or sends from here on — the allocate payload, the flags the request carries, the state - // written below — names the leaf the backend rents, never the `apple` alias asked for. - nextState = { - ...nextState, - platform: boundConnectionPlatform({ - platform: nextState.platform ?? nextFlags.platform, - leaseBackend, - }), - }; - nextFlags.platform = nextState.platform ?? nextFlags.platform; + assertRequestedConnectionBackend(state, leaseBackend); + // One decision for one axis. Whichever of the backend, the record, and the request names the + // narrowest platform, that is what this command records, sends, allocates, and returns; two that + // cannot name the same device are refused here rather than resolved later by whoever read first. + const platform = narrowConnectionPlatform({ + leaseBackend, + recordedPlatform: nextState.platform, + requestedPlatform: nextFlags.platform, + }); + if (!platform.ok) { + throw connectionPlatformConflict(state, leaseBackend, platform); + } + // Read after the platform is settled: a request that asks for another Apple leaf also asks for a + // different target, and the platform is the reason it is being refused. + assertRequestedConnectionTarget(state, nextFlags); + nextState = { ...nextState, platform: platform.platform }; + nextFlags.platform = platform.platform; const materializedLease = await allocateOrReuseLease( client, nextState, @@ -344,7 +363,6 @@ async function materializeLeaseForCommand(options: { const lease = materializedLease.lease; nextFlags.leaseId = lease.leaseId; nextFlags.leaseBackend = leaseBackend; - nextFlags.platform = nextState.platform ?? nextFlags.platform; nextFlags.target = nextState.target ?? nextFlags.target; if (leaseStateMatches(nextState, lease, leaseBackend)) { return { @@ -934,9 +952,29 @@ async function resolveSelectedDevice( ); } -function assertRequestedConnectionScope( +function connectionPlatformConflict( + state: RemoteConnectionState, + leaseBackend: LeaseBackend | undefined, + conflict: Readonly<{ + boundPlatform?: CliFlags['platform']; + requestedPlatform?: CliFlags['platform']; + }>, +): AppError { + return new AppError( + 'INVALID_ARGS', + 'Active remote connection is already bound to a different platform. Re-run connect --force to replace it.', + { + session: state.session, + leaseBackend, + platform: conflict.boundPlatform, + requestedPlatform: conflict.requestedPlatform, + reason: 'CONNECTION_PLATFORM_CONFLICT', + }, + ); +} + +function assertRequestedConnectionBackend( state: RemoteConnectionState, - flags: CliFlags, requestedLeaseBackend: LeaseBackend, ): void { if (state.leaseBackend && state.leaseBackend !== requestedLeaseBackend) { @@ -946,21 +984,9 @@ function assertRequestedConnectionScope( { session: state.session, leaseBackend: state.leaseBackend }, ); } - // A record saved before the collapse existed, or one whose lease already matched so nothing - // rewrote it, still names the `apple` family beside the backend that decided it. The guard reads - // the leaf that record owes; a connection that recorded no platform is bound to none, so a - // selector cannot conflict with it. - const boundPlatform = boundConnectionPlatform({ - platform: state.platform, - leaseBackend: state.leaseBackend, - }); - if (platformSelectorsConflict(flags.platform, boundPlatform)) { - throw new AppError( - 'INVALID_ARGS', - 'Active remote connection is already bound to a different platform. Re-run connect --force to replace it.', - { session: state.session, platform: boundPlatform }, - ); - } +} + +function assertRequestedConnectionTarget(state: RemoteConnectionState, flags: CliFlags): void { if (state.target && flags.target && state.target !== flags.target) { throw new AppError( 'INVALID_ARGS', diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index c234c2eb2c..84f5ce205d 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -12,6 +12,7 @@ import { remoteConnectionLeaseIdentityMatches, removeRemoteConnectionState, connectionPlatformMatchesSelection, + narrowConnectionPlatform, writeRemoteConnectionState, type RemoteConnectionState, type RemoteConnectionRequestMetadata, @@ -162,7 +163,7 @@ function buildConnectedState(options: { : null; const now = new Date().toISOString(); const leaseBinding = buildConnectionLeaseBinding(flags, previous, connectionMetadata); - const runtimeBinding = buildConnectionRuntimeBinding(flags, previous, now); + const runtimeBinding = buildConnectionRuntimeBinding(flags, previous, now, leaseBinding); return { version: 1, session: context.session, @@ -194,13 +195,27 @@ function buildConnectionLeaseBinding( }; } +/** + * Writes what a reused connection is bound to on the platform axis. + * + * `--platform apple` on a connection whose backend rents iOS instances is the family being named + * again, not a request to widen the record back to the family: once a lease is bound the leaf is the + * truth, and a record that loses it lets the next command ask for any Apple leaf and be served on + * this one (#2962). The lease binding is asked first because the backend is what decides a family. + */ function buildConnectionRuntimeBinding( flags: CliFlags, previous: RemoteConnectionState | null, now: string, + leaseBinding: Pick, ): Pick { + const platform = narrowConnectionPlatform({ + leaseBackend: leaseBinding.leaseBackend, + recordedPlatform: previous?.platform, + requestedPlatform: flags.platform, + }); return { - platform: flags.platform ?? previous?.platform, + platform: platform.ok ? platform.platform : flags.platform, target: flags.target ?? previous?.target, runtime: previous?.runtime, metro: previous?.metro, diff --git a/src/remote/__tests__/remote-connection-state.test.ts b/src/remote/__tests__/remote-connection-state.test.ts index 5f72fb4565..0f94afa38e 100644 --- a/src/remote/__tests__/remote-connection-state.test.ts +++ b/src/remote/__tests__/remote-connection-state.test.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { mkdtempForTest } from '../../__tests__/test-utils/tmp-dir.ts'; import { - boundConnectionPlatform, + narrowConnectionPlatform, buildConnectionDeviceKey, connectionPlatformMatchesSelection, buildRemoteConnectionDaemonState, @@ -187,27 +187,63 @@ test('resolveRemoteConnectionDefaults falls back to the environment token', asyn assert.equal(defaults?.flags.daemonAuthToken, FAKE_DAEMON_TOKEN); }); -// The rule that decides whether a recorded platform is still a family selection: the backend is what -// settles it. With none, the alias is kept rather than guessed — a connection that named no backend -// can legitimately serve either Apple leaf. -test('boundConnectionPlatform collapses apple only when a backend names the leaf', () => { - const bound = boundConnectionPlatform({ platform: 'apple', leaseBackend: undefined }); - assert.equal(bound, 'apple'); - // A backend is what decides it, including one whose platform the backend does not name. - assert.equal( - boundConnectionPlatform({ platform: 'apple', leaseBackend: 'ios-simulator' }), - 'apple', - 'a runner-guard backend names no platform to collapse to', +// The one rule for the platform axis. A backend that rents a leaf platform has decided the family +// whether or not the record ever wrote it; a backend that names none (`ios-simulator`, a runner guard) +// decides nothing and must not invent a leaf; and a wider `apple` never beats a leaf that is already +// known, which is what stops a recorded alias from rewriting a narrower request. +test('narrowConnectionPlatform takes the narrowest candidate and refuses a real conflict', () => { + assert.deepEqual( + narrowConnectionPlatform({ leaseBackend: 'ios-instance', recordedPlatform: 'apple' }), + { ok: true, platform: 'ios' }, + 'the backend decides a family the record never collapsed', ); - assert.equal( - boundConnectionPlatform({ platform: 'apple', leaseBackend: 'android-instance' }), - 'android', + assert.deepEqual( + narrowConnectionPlatform({ + leaseBackend: 'ios-simulator', + recordedPlatform: 'apple', + requestedPlatform: 'macos', + }), + { ok: true, platform: 'macos' }, + 'a runner-guard backend invents no leaf, so the requested one narrows the alias', ); - // A leaf is already decided, whichever backend it leased on. - assert.equal( - boundConnectionPlatform({ platform: 'ios', leaseBackend: 'ios-instance' }), - 'ios', - 'a leaf passes through untouched', + assert.deepEqual( + narrowConnectionPlatform({ + leaseBackend: undefined, + recordedPlatform: 'apple', + requestedPlatform: undefined, + }), + { ok: true, platform: 'apple' }, + 'with nothing to decide from, the family selection stands', + ); + assert.deepEqual( + narrowConnectionPlatform({ + leaseBackend: 'ios-instance', + recordedPlatform: undefined, + requestedPlatform: 'macos', + }), + { ok: false, boundPlatform: 'ios', requestedPlatform: 'macos' }, + 'an unplatformed record still cannot rent an iOS backend for macOS', + ); + assert.deepEqual( + narrowConnectionPlatform({ + // A `leaseBackend` reaching here can be any string an older binary left on disk, and the + // backend-to-leaf table used to be a plain object, where these resolved to inherited + // Object members — a platform nobody rents. + leaseBackend: 'constructor' as never, + recordedPlatform: 'apple', + requestedPlatform: undefined, + }), + { ok: true, platform: 'apple' }, + 'an unrecognized backend decides nothing rather than resolving to an inherited property', + ); + assert.deepEqual( + narrowConnectionPlatform({ + leaseBackend: 'android-instance', + recordedPlatform: 'ios', + requestedPlatform: undefined, + }), + { ok: false, boundPlatform: 'android', requestedPlatform: undefined }, + 'a record that disagrees with its own backend is refused by itself, not resolved by whichever field was read first', ); }); diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index 1f11307d3a..5e5342fe5c 100644 --- a/src/remote/remote-connection-state.ts +++ b/src/remote/remote-connection-state.ts @@ -111,37 +111,67 @@ export function buildConnectionDeviceKey(scope: ConnectionDeviceScope): string { } /** - * The platform a command records once a lease is bound: the leaf of the device the lease holds. + * The one platform a connection command runs as: the narrowest of what its lease backend rents, what + * its record already holds, and what the command asked for. * - * `apple` is a family selection a caller makes before any device exists, and a family never - * conflicts with a leaf — so a connection left recording `apple` accepts a later `--platform macos` - * against the iOS device its own lease is paying for, and forwards that request to the daemon as - * `apple` on an `ios-instance` lease (#2962's shape, one axis wider). Binding a lease decides the - * family, so the alias collapses here and every later comparison is leaf-to-leaf. + * `apple` is a family selection a caller makes before any device exists, and a family never conflicts + * with a leaf. Left as three separate compares that is exactly where a request gets retargeted: a + * record saying `apple` beside an `ios-instance` lease accepts `--platform macos`, and the recorded + * value then overwrites the asked-for one, so a macOS request goes out as `apple` against the iOS + * device the lease is paying for (#2962). One rule covers all of it — a requested selector may narrow + * what a connection is bound to and can never widen it, and two candidates that cannot name the same + * device are a conflict rather than something to pick a winner from. * - * A backend that names no platform — `ios-simulator`, a runner guard below device leases — and a - * connection with no backend at all keep the selector as named: nothing has decided the family yet, - * and inventing a leaf would be the same axis mistake in the other direction. + * The backend is asked first because it is the evidence a device exists behind: a backend that rents + * iOS instances has decided the family whether or not the record ever wrote it. A backend that names + * no platform — `ios-simulator`, a runner guard below device leases — decides nothing, and inventing a + * leaf there would be the same axis mistake in the other direction. */ -export function boundConnectionPlatform( +export function narrowConnectionPlatform( evidence: Readonly<{ - platform?: CliFlags['platform']; leaseBackend?: LeaseBackend; + recordedPlatform?: CliFlags['platform']; + requestedPlatform?: CliFlags['platform']; }>, -): CliFlags['platform'] { - if (evidence.platform !== 'apple' || !evidence.leaseBackend) return evidence.platform; - return platformForLeaseBackend(evidence.leaseBackend) ?? evidence.platform; +): + | Readonly<{ ok: true; platform: CliFlags['platform'] }> + | Readonly<{ + ok: false; + /** What the connection is bound to, independent of the request that was refused. */ + boundPlatform?: CliFlags['platform']; + requestedPlatform?: CliFlags['platform']; + }> { + const backendPlatform = evidence.leaseBackend + ? platformForLeaseBackend(evidence.leaseBackend) + : undefined; + const bound = [backendPlatform, evidence.recordedPlatform].filter( + (candidate): candidate is NonNullable => candidate !== undefined, + ); + // The bound side settles itself first: a record and a backend that disagree about which device they + // hold is a conflict no request can be served under, whatever it asked for. + if (bound.some((left) => bound.some((right) => platformSelectorsConflict(left, right)))) { + return { ok: false, boundPlatform: bound[0], requestedPlatform: evidence.requestedPlatform }; + } + if ( + platformSelectorsConflict(evidence.requestedPlatform, bound[0] ?? evidence.recordedPlatform) + ) { + return { ok: false, boundPlatform: bound[0], requestedPlatform: evidence.requestedPlatform }; + } + const candidates = [...bound, evidence.requestedPlatform].filter( + (candidate): candidate is NonNullable => candidate !== undefined, + ); + return { + ok: true, + platform: candidates.find((candidate) => candidate !== 'apple') ?? candidates[0], + }; } /** - * Whether a `--platform` selector names the platform a connection is bound to. + * Whether a `--platform` selector can be served by an existing connection. * - * Both halves are needed and neither is enough alone. The selector rule answers family-vs-leaf as a - * match, which is right for a fresh selection — `--platform apple` and a recorded `ios` name the same - * devices — and wrong for a record whose backend already decided the family. A record saved before - * the collapse existed, or one whose lease already matched so nothing rewrote it, still says `apple` - * next to an `ios-instance` backend; comparing that alias against `--platform macos` calls the - * connection reusable and sends a macOS request against an iOS device's lease. + * A reuse decision, not a rewrite: the same narrowing the request path applies decides this too, so a + * record saved before a backend decided its family cannot make a connection look reusable for a leaf + * it does not hold. */ export function connectionPlatformMatchesSelection( state: Readonly<{ @@ -150,12 +180,12 @@ export function connectionPlatformMatchesSelection( }>, requested: CliFlags['platform'], ): boolean { - if (requested === undefined) return true; - if (state.platform === undefined) return false; - return !platformSelectorsConflict( - requested, - boundConnectionPlatform({ platform: state.platform, leaseBackend: state.leaseBackend }), - ); + if (requested !== undefined && state.platform === undefined) return false; + return narrowConnectionPlatform({ + leaseBackend: state.leaseBackend, + recordedPlatform: state.platform, + requestedPlatform: requested, + }).ok; } type RemoteConnectionDefaults = { From 65abe3315c9f1c352eab037e4262589efcb71b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 27 Sep 2026 14:10:26 +0200 Subject: [PATCH 7/8] fix(remote): refuse a forced connect whose backend and platform name different devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--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. --- .../remote-connection-platform-axis.test.ts | 47 +++++++++++++++++++ src/cli/commands/connection-runtime.ts | 43 ++++++++++++----- src/cli/commands/connection.ts | 21 ++++++++- 3 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index ec97674d93..b7b24311fb 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -727,3 +727,50 @@ test('an undecided record narrows to the requested leaf or refuses, and never se ); fs.rmSync(tempRoot, { recursive: true, force: true }); }); + +// `--force` drops the previous record before the platform is decided, so the backend is the only +// thing left naming a leaf. A connect that pairs an `ios-instance` backend with `--platform macos` +// is a contradiction on the request itself, not a reuse of a bound connection, and it must not be +// written out as a connection whose record says macOS while its backend can only ever rent iOS — +// the next command on that record would then ask a macOS device of an iOS lease. +test('connect --force refuses a backend and platform that name different devices', async () => { + const { tempRoot, stateDir, remoteConfigPath } = connectionWorkspace( + 'agent-device-connect-force-conflict-', + ); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + + await assert.rejects( + async () => + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-9', + session: 'adc-force-conflict', + force: true, + platform: 'macos', + leaseBackend: 'ios-instance', + }, + client: createTestClient(), + }), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.details?.reason === 'CONNECTION_PLATFORM_CONFLICT' && + error.details?.platform === 'ios' && + error.details?.requestedPlatform === 'macos', + 'a backend that rents iOS cannot be asked for macOS, --force or not', + ); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-force-conflict' }), + null, + 'the refused connect wrote no connection state', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index ad37d94cb0..ea980bde11 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -154,7 +154,13 @@ export async function materializeRemoteConnectionForCommand(options: { requestedPlatform: nextFlags.platform, }); if (!carriedPlatform.ok) { - throw connectionPlatformConflict(state, nextState.leaseBackend, carriedPlatform); + throw connectionPlatformConflict({ + session: state.session, + leaseBackend: nextState.leaseBackend, + boundPlatform: carriedPlatform.boundPlatform, + requestedPlatform: carriedPlatform.requestedPlatform, + detail: 'bound-connection', + }); } nextState = { ...nextState, platform: carriedPlatform.platform }; nextFlags.platform = carriedPlatform.platform; @@ -345,7 +351,13 @@ async function materializeLeaseForCommand(options: { requestedPlatform: nextFlags.platform, }); if (!platform.ok) { - throw connectionPlatformConflict(state, leaseBackend, platform); + throw connectionPlatformConflict({ + session: state.session, + leaseBackend, + boundPlatform: platform.boundPlatform, + requestedPlatform: platform.requestedPlatform, + detail: 'bound-connection', + }); } // Read after the platform is settled: a request that asks for another Apple leaf also asks for a // different target, and the platform is the reason it is being refused. @@ -952,22 +964,31 @@ async function resolveSelectedDevice( ); } -function connectionPlatformConflict( - state: RemoteConnectionState, - leaseBackend: LeaseBackend | undefined, - conflict: Readonly<{ +/** + * The refusal raised when the platform axis cannot be decided: two of the backend, the record, and + * the request name devices that are not the same device. `detail` says which of the three disagreed, + * because the advice differs — a bound connection is replaced with `--force`, and a request that + * contradicts itself has no `--force` to reach. + */ +export function connectionPlatformConflict( + options: Readonly<{ + session: string; + leaseBackend: LeaseBackend | undefined; boundPlatform?: CliFlags['platform']; requestedPlatform?: CliFlags['platform']; + detail: 'bound-connection' | 'requested-backend'; }>, ): AppError { return new AppError( 'INVALID_ARGS', - 'Active remote connection is already bound to a different platform. Re-run connect --force to replace it.', + options.detail === 'bound-connection' + ? 'Active remote connection is already bound to a different platform. Re-run connect --force to replace it.' + : 'The requested platform does not match the device this lease backend rents.', { - session: state.session, - leaseBackend, - platform: conflict.boundPlatform, - requestedPlatform: conflict.requestedPlatform, + session: options.session, + leaseBackend: options.leaseBackend, + platform: options.boundPlatform, + requestedPlatform: options.requestedPlatform, reason: 'CONNECTION_PLATFORM_CONFLICT', }, ); diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 84f5ce205d..9542484684 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -29,6 +29,7 @@ import { verifyResolvedConnectProvider, } from '../connection/connect-provider-adapters.ts'; import { + connectionPlatformConflict, hasDeferredMetroConfig, releaseRemoteConnectionLease, releasePreviousLease, @@ -163,7 +164,13 @@ function buildConnectedState(options: { : null; const now = new Date().toISOString(); const leaseBinding = buildConnectionLeaseBinding(flags, previous, connectionMetadata); - const runtimeBinding = buildConnectionRuntimeBinding(flags, previous, now, leaseBinding); + const runtimeBinding = buildConnectionRuntimeBinding( + flags, + previous, + now, + leaseBinding, + context.session, + ); return { version: 1, session: context.session, @@ -208,14 +215,24 @@ function buildConnectionRuntimeBinding( previous: RemoteConnectionState | null, now: string, leaseBinding: Pick, + session: string, ): Pick { const platform = narrowConnectionPlatform({ leaseBackend: leaseBinding.leaseBackend, recordedPlatform: previous?.platform, requestedPlatform: flags.platform, }); + if (!platform.ok) { + throw connectionPlatformConflict({ + session, + leaseBackend: leaseBinding.leaseBackend, + boundPlatform: platform.boundPlatform, + requestedPlatform: platform.requestedPlatform, + detail: previous ? 'bound-connection' : 'requested-backend', + }); + } return { - platform: platform.ok ? platform.platform : flags.platform, + platform: platform.platform, target: flags.target ?? previous?.target, runtime: previous?.runtime, metro: previous?.metro, From e51a88085e6590a242352a23b1d519c8363f622a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 27 Sep 2026 14:13:50 +0200 Subject: [PATCH 8/8] test(remote): check the platform the allocator is asked for, not just 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. --- .../remote-connection-platform-axis.test.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/__tests__/remote-connection-platform-axis.test.ts b/src/__tests__/remote-connection-platform-axis.test.ts index b7b24311fb..02b16927be 100644 --- a/src/__tests__/remote-connection-platform-axis.test.ts +++ b/src/__tests__/remote-connection-platform-axis.test.ts @@ -660,12 +660,18 @@ test('an undecided record narrows to the requested leaf or refuses, and never se 'agent-device-connect-undecided-leaf-', ); fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + const allocations: ReturnType[] = []; const command = ( session: string, leaseBackend: 'ios-simulator' | 'ios-instance', platform: 'apple' | 'macos', - ) => - materializeRemoteConnectionForCommand({ + ) => { + const allocation = recordedLeaseAllocate({ + leaseId: 'undecided-lease-1', + backend: leaseBackend, + }); + allocations.push(allocation); + return materializeRemoteConnectionForCommand({ command: 'snapshot', flags: { json: true, @@ -679,11 +685,9 @@ test('an undecided record narrows to the requested leaf or refuses, and never se session, platform, }, - client: createTestClient({ - allocate: recordedLeaseAllocate({ leaseId: 'undecided-lease-1', backend: leaseBackend }) - .stub, - }), + client: createTestClient({ allocate: allocation.stub }), }); + }; seedConnectionState({ stateDir, @@ -703,6 +707,11 @@ test('an undecided record narrows to the requested leaf or refuses, and never se 'macos', 'a runner guard rents no leaf, so the request keeps its own', ); + assert.equal( + allocations[0]?.request?.platform, + 'macos', + 'the lease request the provider sees names the leaf, not the family the record held', + ); seedConnectionState({ stateDir, @@ -725,6 +734,11 @@ test('an undecided record narrows to the requested leaf or refuses, and never se error.details?.requestedPlatform === 'macos', 'a backend that rents iOS is a bound device, alias or no alias', ); + assert.equal( + allocations[1]?.request, + undefined, + 'the refused request never reached the allocator with a platform to name', + ); fs.rmSync(tempRoot, { recursive: true, force: true }); });