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/5] 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/5] 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/5] 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/5] 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/5] 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;