From 3ab3c907bf7dcc65260d6ab49334b28ca0dd7c4c Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Sat, 3 Oct 2026 10:16:22 +0530 Subject: [PATCH 01/10] refactor(provider-webdriver): declare the profile fields each provider consumes Lease allocation forwards every provider profile field, but only some routes checked which ones a provider could act on. The connect builder and the AWS Device Farm session preparation refused BrowserStack device features, while the Limrun lease runtime, BrowserStack itself, and a remote-config profile skipped any check. A typed client asking Limrun for providerOsVersion '18.0' was therefore given whatever instance Limrun picked without an error. Each lease provider now declares, for every Cloud provider profile field, whether it consumes or refuses it. The declaration is a total record over CloudProviderProfileFields, so adding a field fails to build until every provider decides. One refusal, rejectRefusedProviderProfileFields from the new @agent-device/contracts/provider-profile-fields subpath, runs at lease preparation (the WebDriver session manager for BrowserStack and AWS Device Farm, and Limrun allocation) and at connect, so connect, leases.allocate and remote-config profiles all fail the same way with INVALID_ARGS naming the flag and the provider. This replaces the BrowserStack-only device-feature check. AWS Device Farm now also refuses the hub-only app, OS version, project and build flags it never read, and BrowserStack refuses the AWS Device Farm flags. Co-Authored-By: Claude Opus 5.5 --- packages/contracts/package.json | 4 + .../src/provider-profile-fields.test.ts | 72 ++++++++++ .../contracts/src/provider-profile-fields.ts | 65 +++++++++ packages/provider-limrun/src/device.ts | 28 ++++ packages/provider-limrun/src/index.ts | 2 +- packages/provider-limrun/src/runtime.ts | 3 + .../provider-limrun/src/session-allocation.ts | 7 +- .../src/browserstack-device-features.ts | 41 ------ packages/provider-webdriver/src/index.ts | 4 +- .../src/provider-definitions.ts | 75 +++++++++-- .../src/provider-profile-fields.test.ts | 47 +++++++ .../provider-webdriver/src/runtime-session.ts | 6 + packages/provider-webdriver/src/runtime.ts | 3 + .../layering/contracts-exports.snapshot.json | 1 + src/__tests__/cloud-connect-profile.test.ts | 62 ++++++++- .../lease-provider-profile-fields.test.ts | 126 ++++++++++++++++++ src/cli/connection/cloud-webdriver-profile.ts | 11 +- src/cli/connection/limrun-profile.ts | 3 + .../cloud-webdriver-provider-adapters.test.ts | 4 +- website/docs/docs/device-clouds.md | 2 + 20 files changed, 506 insertions(+), 60 deletions(-) create mode 100644 packages/contracts/src/provider-profile-fields.test.ts create mode 100644 packages/contracts/src/provider-profile-fields.ts create mode 100644 packages/provider-webdriver/src/provider-profile-fields.test.ts create mode 100644 src/__tests__/lease-provider-profile-fields.test.ts diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 328cdce13a..bee7b0f6f9 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -364,6 +364,10 @@ "types": "./src/runtime-operation-names.ts", "default": "./src/runtime-operation-names.ts" }, + "./provider-profile-fields": { + "types": "./src/provider-profile-fields.ts", + "default": "./src/provider-profile-fields.ts" + }, "./progress": { "types": "./src/facades/progress.ts", "default": "./src/facades/progress.ts" diff --git a/packages/contracts/src/provider-profile-fields.test.ts b/packages/contracts/src/provider-profile-fields.test.ts new file mode 100644 index 0000000000..d97ca1f19f --- /dev/null +++ b/packages/contracts/src/provider-profile-fields.test.ts @@ -0,0 +1,72 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { AppError } from '@agent-device/kernel/errors'; +import { + rejectRefusedProviderProfileFields, + type ProviderProfileFieldDeclaration, +} from './provider-profile-fields.ts'; + +const DECLARATION: ProviderProfileFieldDeclaration = { + provider: 'fake', + label: 'Fake Cloud', + fields: { + providerApp: 'consumed', + providerOsVersion: 'refused', + providerProject: 'consumed', + providerBuild: 'consumed', + providerSessionName: 'consumed', + providerDeviceOrientation: 'consumed', + providerGeoLocation: 'refused', + providerTimezone: 'consumed', + providerAppiumVersion: 'consumed', + providerLanguage: 'consumed', + providerLocale: 'consumed', + providerNetworkProfile: 'consumed', + providerCustomNetwork: 'consumed', + providerNoResignApp: 'refused', + awsProjectArn: 'consumed', + awsDeviceArn: 'consumed', + awsAppArn: 'consumed', + awsRegion: 'consumed', + awsInteractionMode: 'consumed', + }, +}; + +test('consumed, unset, empty, and false fields pass', () => { + assert.doesNotThrow(() => rejectRefusedProviderProfileFields(undefined, DECLARATION)); + assert.doesNotThrow(() => + rejectRefusedProviderProfileFields( + { + providerApp: 'app', + providerOsVersion: '', + providerGeoLocation: undefined, + providerNoResignApp: false, + unrelated: 'x', + }, + DECLARATION, + ), + ); +}); + +test('a refused field fails with its flag and the provider named', () => { + assert.throws( + () => rejectRefusedProviderProfileFields({ providerOsVersion: '18' }, DECLARATION), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message === '--provider-os-version is not supported by Fake Cloud.' && + error.details?.provider === 'fake' && + JSON.stringify(error.details?.flags) === '["--provider-os-version"]', + ); +}); + +test('every refused field is reported at once', () => { + assert.throws( + () => + rejectRefusedProviderProfileFields( + { providerGeoLocation: 'US', providerNoResignApp: true, providerOsVersion: '18' }, + DECLARATION, + ), + /--provider-os-version, --provider-geo-location, --provider-no-resign-app are not supported by Fake Cloud\./, + ); +}); diff --git a/packages/contracts/src/provider-profile-fields.ts b/packages/contracts/src/provider-profile-fields.ts new file mode 100644 index 0000000000..acbd0f4c3b --- /dev/null +++ b/packages/contracts/src/provider-profile-fields.ts @@ -0,0 +1,65 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { CloudProviderProfileFields } from './remote-config-fields.ts'; + +export type ProviderProfileField = keyof CloudProviderProfileFields; + +/** + * What one lease provider does with every Cloud provider profile field. The record is total, so a + * field added to the profile fails to build until each provider says whether it consumes it; a + * field a provider neither reads nor refuses would otherwise ride the profile and be dropped. + */ +export type ProviderProfileFieldDeclaration = Readonly<{ + provider: string; + label: string; + fields: Readonly>; +}>; + +const PROVIDER_PROFILE_FIELD_FLAGS: Readonly> = { + providerApp: '--provider-app', + providerOsVersion: '--provider-os-version', + providerProject: '--provider-project', + providerBuild: '--provider-build', + providerSessionName: '--provider-session-name', + providerDeviceOrientation: '--provider-device-orientation', + providerGeoLocation: '--provider-geo-location', + providerTimezone: '--provider-timezone', + providerAppiumVersion: '--provider-appium-version', + providerLanguage: '--provider-language', + providerLocale: '--provider-locale', + providerNetworkProfile: '--provider-network-profile', + providerCustomNetwork: '--provider-custom-network', + providerNoResignApp: '--provider-no-resign-app', + awsProjectArn: '--aws-project-arn', + awsDeviceArn: '--aws-device-arn', + awsAppArn: '--aws-app-arn', + awsRegion: '--aws-region', + awsInteractionMode: '--aws-interaction-mode', +}; + +/** + * Fails when `flags` set a profile field the provider refuses. Every route to a provider — connect, + * `leases.allocate`, a hand-authored remote-config profile — runs this against the same declaration. + */ +export function rejectRefusedProviderProfileFields( + flags: Readonly> | undefined, + declaration: ProviderProfileFieldDeclaration, +): void { + const refused = (Object.keys(declaration.fields) as ProviderProfileField[]) + .filter((field) => declaration.fields[field] === 'refused' && isSet(flags?.[field])) + .map((field) => PROVIDER_PROFILE_FIELD_FLAGS[field]); + if (refused.length === 0) return; + const plural = refused.length !== 1; + throw new AppError( + 'INVALID_ARGS', + `${refused.join(', ')} ${plural ? 'are' : 'is'} not supported by ${declaration.label}.`, + { + hint: `Drop ${plural ? 'those flags' : 'the flag'} or use a provider that supports ${plural ? 'them' : 'it'}.`, + provider: declaration.provider, + flags: refused, + }, + ); +} + +function isSet(value: unknown): boolean { + return value !== undefined && value !== null && value !== false && value !== ''; +} diff --git a/packages/provider-limrun/src/device.ts b/packages/provider-limrun/src/device.ts index 0650af5b1a..78f563da4e 100644 --- a/packages/provider-limrun/src/device.ts +++ b/packages/provider-limrun/src/device.ts @@ -1,11 +1,39 @@ import { createHash } from 'node:crypto'; import type { DeviceLease } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { ProviderProfileFieldDeclaration } from '@agent-device/contracts/provider-profile-fields'; export type LimrunPlatform = 'ios' | 'android'; export const LIMRUN_PROVIDER = 'limrun'; +/** Limrun reads only the app to preinstall; it picks the instance itself. */ +export const LIMRUN_PROFILE_FIELDS: ProviderProfileFieldDeclaration = { + provider: LIMRUN_PROVIDER, + label: 'Limrun', + fields: { + providerApp: 'consumed', + providerOsVersion: 'refused', + providerProject: 'refused', + providerBuild: 'refused', + providerSessionName: 'refused', + providerDeviceOrientation: 'refused', + providerGeoLocation: 'refused', + providerTimezone: 'refused', + providerAppiumVersion: 'refused', + providerLanguage: 'refused', + providerLocale: 'refused', + providerNetworkProfile: 'refused', + providerCustomNetwork: 'refused', + providerNoResignApp: 'refused', + awsProjectArn: 'refused', + awsDeviceArn: 'refused', + awsAppArn: 'refused', + awsRegion: 'refused', + awsInteractionMode: 'refused', + }, +}; + const LIMRUN_DEVICE_ID_PREFIX = LIMRUN_PROVIDER; export function platformForLimrunLeaseBackend(backend: string): LimrunPlatform | undefined { diff --git a/packages/provider-limrun/src/index.ts b/packages/provider-limrun/src/index.ts index d22a8ab6a4..ebb24478d1 100644 --- a/packages/provider-limrun/src/index.ts +++ b/packages/provider-limrun/src/index.ts @@ -1,4 +1,4 @@ -export { LIMRUN_PROVIDER } from './device.ts'; +export { LIMRUN_PROFILE_FIELDS, LIMRUN_PROVIDER } from './device.ts'; export { createLimrunRuntime, type LimrunRuntime, type LimrunRuntimeOptions } from './runtime.ts'; export { verifyLimrunConnection } from './connection-verification.ts'; export type { LimrunInstanceAccess } from './instance-access.ts'; diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index d57bf353c5..9492c0c775 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -260,6 +260,9 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const existing = this.sessions.get(lease.leaseId); if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; + const { rejectRefusedLimrunProfileFields } = await import('./session-allocation.ts'); + // Ahead of attach as well as create: an attached instance reads none of the refused fields either. + rejectRefusedLimrunProfileFields(context); const session = (await this.attachSession(platform, lease)) ?? (await this.createSession(platform, lease, context)); diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts index e04c8b1055..7c9fda7219 100644 --- a/packages/provider-limrun/src/session-allocation.ts +++ b/packages/provider-limrun/src/session-allocation.ts @@ -1,8 +1,9 @@ import type Limrun from '@limrun/api'; import type { DeviceLease, LeaseLifecycleContext } from '@agent-device/contracts/device'; import { AppError } from '@agent-device/kernel/errors'; +import { rejectRefusedProviderProfileFields } from '@agent-device/contracts/provider-profile-fields'; import { createLimrunAndroidSession, type LimrunAndroidSession } from './android.ts'; -import { buildLimrunDevice } from './device.ts'; +import { buildLimrunDevice, LIMRUN_PROFILE_FIELDS } from './device.ts'; import { createLimrunIosSession, type LimrunIosSession } from './ios.ts'; import { assertLimrunUploadedAppAccess, @@ -34,6 +35,10 @@ type SessionAllocationParams = Readonly<{ dependencies: LimrunRuntimeDependencies; }>; +export function rejectRefusedLimrunProfileFields(context?: LeaseLifecycleContext): void { + rejectRefusedProviderProfileFields(context?.flags, LIMRUN_PROFILE_FIELDS); +} + export async function resolveRequestedLimrunAppAsset( limrun: Limrun, platform: 'android' | 'ios', diff --git a/packages/provider-webdriver/src/browserstack-device-features.ts b/packages/provider-webdriver/src/browserstack-device-features.ts index 3ea041add7..275ae64ff3 100644 --- a/packages/provider-webdriver/src/browserstack-device-features.ts +++ b/packages/provider-webdriver/src/browserstack-device-features.ts @@ -130,47 +130,6 @@ export function buildBrowserStackDeviceFeatureCapabilities( return capabilities; } -/** - * Canonical CLI flags for every device-feature capability set on `flags`. - * - * These capabilities are BrowserStack-owned. Other providers have no equivalent, so a caller who - * passes them to AWS Device Farm would otherwise have them accepted, persisted into the profile, - * and then silently dropped — the session runs with provider defaults and nothing says why. - * Callers use this to reject them at the point the provider is known. - */ -function browserStackOnlyDeviceFeatureFlags(flags: Record | undefined): string[] { - return BROWSERSTACK_DEVICE_FEATURE_SPECS.filter((spec) => { - const value = flags?.[spec.field]; - return value !== undefined && value !== false && value !== ''; - }).map((spec) => spec.flag); -} - -/** - * Fails when a non-BrowserStack provider was given BrowserStack-owned device features. - * - * Called from both the CLI profile builder and the provider's own session preparation. The second - * is the one that actually closes the hole: the typed client and hand-authored remote-config - * profiles reach session preparation without passing through `connect`, so a CLI-only check leaves - * those routes accepting the capabilities and dropping them. - */ -export function rejectBrowserStackOnlyDeviceFeatures( - flags: Record | undefined, - provider: string, -): void { - const configured = browserStackOnlyDeviceFeatureFlags(flags); - if (configured.length === 0) return; - const plural = configured.length !== 1; - throw new AppError( - 'INVALID_ARGS', - `${configured.join(', ')} ${plural ? 'are' : 'is'} only supported by BrowserStack, not ${provider}.`, - { - hint: `Drop ${plural ? 'those flags' : 'the flag'} or use the browserstack provider.`, - provider, - flags: configured, - }, - ); -} - /** * Reads device-feature fields off an untyped flag bag (a daemon request), so the daemon-side * capability build and the CLI-side profile build stay driven by the same table. diff --git a/packages/provider-webdriver/src/index.ts b/packages/provider-webdriver/src/index.ts index 8410ab710f..2072cc8a5a 100644 --- a/packages/provider-webdriver/src/index.ts +++ b/packages/provider-webdriver/src/index.ts @@ -4,6 +4,7 @@ import type { } from '@agent-device/contracts/observability'; import type { ProviderWebDriverDependencies } from './dependencies.ts'; import { + CLOUD_WEBDRIVER_PROFILE_FIELDS, createCloudWebDriverProviderDefinitions, type DefaultCloudWebDriverArtifactEnv, type DefaultCloudWebDriverProviderRuntimeEnv, @@ -17,10 +18,9 @@ import { } from './connection-verification.ts'; import type { CloudWebDriverRuntime } from './runtime.ts'; -export { CLOUD_WEBDRIVER_PROVIDERS }; +export { CLOUD_WEBDRIVER_PROFILE_FIELDS, CLOUD_WEBDRIVER_PROVIDERS }; export { readAwsDeviceFarmRegionFromArn }; export { parseBrowserStackAppReference } from './browserstack.ts'; -export { rejectBrowserStackOnlyDeviceFeatures } from './browserstack-device-features.ts'; export type { CloudWebDriverKnownProviderName } from './providers.ts'; export type { ProviderWebDriverDependencies, RunHostCommand } from './dependencies.ts'; export type { diff --git a/packages/provider-webdriver/src/provider-definitions.ts b/packages/provider-webdriver/src/provider-definitions.ts index 713fd7d8ce..c051889dd3 100644 --- a/packages/provider-webdriver/src/provider-definitions.ts +++ b/packages/provider-webdriver/src/provider-definitions.ts @@ -1,5 +1,6 @@ import type { CloudArtifactsResult } from '@agent-device/contracts/observability'; import type { LeaseLifecycleContext } from '@agent-device/contracts/device'; +import type { ProviderProfileFieldDeclaration } from '@agent-device/contracts/provider-profile-fields'; import { AppError } from '@agent-device/kernel/errors'; import type { ProviderWebDriverDependencies } from './dependencies.ts'; import { @@ -20,7 +21,6 @@ import { import { buildBrowserStackDeviceFeatureCapabilities, readBrowserStackDeviceFeatureFields, - rejectBrowserStackOnlyDeviceFeatures, } from './browserstack-device-features.ts'; import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts'; import { readAwsDeviceFarmRegionFromArn } from './connection-verification.ts'; @@ -50,8 +50,70 @@ export type DefaultCloudWebDriverProviderRuntimeEnv = DefaultCloudWebDriverArtif AWS_DEVICE_FARM_APP_ARN?: string; }; +const BROWSERSTACK_PROFILE_FIELDS: ProviderProfileFieldDeclaration = { + provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + label: 'BrowserStack', + fields: { + providerApp: 'consumed', + providerOsVersion: 'consumed', + providerProject: 'consumed', + providerBuild: 'consumed', + providerSessionName: 'consumed', + providerDeviceOrientation: 'consumed', + providerGeoLocation: 'consumed', + providerTimezone: 'consumed', + providerAppiumVersion: 'consumed', + providerLanguage: 'consumed', + providerLocale: 'consumed', + providerNetworkProfile: 'consumed', + providerCustomNetwork: 'consumed', + providerNoResignApp: 'consumed', + awsProjectArn: 'refused', + awsDeviceArn: 'refused', + awsAppArn: 'refused', + awsRegion: 'refused', + awsInteractionMode: 'refused', + }, +}; + +const AWS_DEVICE_FARM_PROFILE_FIELDS: ProviderProfileFieldDeclaration = { + provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + label: 'AWS Device Farm', + fields: { + providerApp: 'refused', + providerOsVersion: 'refused', + providerProject: 'refused', + providerBuild: 'refused', + providerSessionName: 'consumed', + providerDeviceOrientation: 'refused', + providerGeoLocation: 'refused', + providerTimezone: 'refused', + providerAppiumVersion: 'refused', + providerLanguage: 'refused', + providerLocale: 'refused', + providerNetworkProfile: 'refused', + providerCustomNetwork: 'refused', + providerNoResignApp: 'refused', + awsProjectArn: 'consumed', + awsDeviceArn: 'consumed', + awsAppArn: 'consumed', + awsRegion: 'consumed', + awsInteractionMode: 'consumed', + }, +}; + +/** The profile fields each hub provider reads, for routes that check them before a runtime exists. */ +export const CLOUD_WEBDRIVER_PROFILE_FIELDS: Readonly< + Record +> = { + [CLOUD_WEBDRIVER_PROVIDERS.browserStack]: BROWSERSTACK_PROFILE_FIELDS, + [CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm]: AWS_DEVICE_FARM_PROFILE_FIELDS, +}; + export type CloudWebDriverProviderDefinition = { provider: CloudWebDriverKnownProviderName; + /** Every profile field, consumed or refused; session preparation refuses the refused ones. */ + profileFields: ProviderProfileFieldDeclaration; createRuntime: (env: DefaultCloudWebDriverProviderRuntimeEnv) => CloudWebDriverRuntime; listArtifactsFromEnv: ( providerSessionId: string, @@ -65,10 +127,12 @@ export function createCloudWebDriverProviderDefinitions( return [ { provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + profileFields: BROWSERSTACK_PROFILE_FIELDS, createRuntime: (env) => createCloudWebDriverRuntime({ clientVersion: dependencies.clientVersion, provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + profileFields: BROWSERSTACK_PROFILE_FIELDS, platform: 'android', deviceName: 'BrowserStack device', endpoint: env.BROWSERSTACK_WEBDRIVER_ENDPOINT ?? BROWSERSTACK_APP_AUTOMATE_ENDPOINT, @@ -172,10 +236,12 @@ export function createCloudWebDriverProviderDefinitions( }, { provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + profileFields: AWS_DEVICE_FARM_PROFILE_FIELDS, createRuntime: (env) => createCloudWebDriverRuntime({ clientVersion: dependencies.clientVersion, provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + profileFields: AWS_DEVICE_FARM_PROFILE_FIELDS, endpoint: 'http://127.0.0.1/', platform: 'android', deviceName: 'AWS Device Farm device', @@ -192,13 +258,6 @@ export function createCloudWebDriverProviderDefinitions( }, prepareSession: async ({ req, lease, base }) => { const request = requireRequest(req, 'AWS Device Farm'); - // Enforced here, not only in the CLI profile builder: the typed client and - // hand-authored remote-config profiles both reach session preparation without passing - // through `connect`, and would otherwise have these capabilities silently dropped. - rejectBrowserStackOnlyDeviceFeatures( - request.flags, - CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - ); const platform = requireRequestPlatform(request, 'AWS Device Farm'); const sessionOptions = { client: createAwsCliDeviceFarmClient({ diff --git a/packages/provider-webdriver/src/provider-profile-fields.test.ts b/packages/provider-webdriver/src/provider-profile-fields.test.ts new file mode 100644 index 0000000000..1eb322e782 --- /dev/null +++ b/packages/provider-webdriver/src/provider-profile-fields.test.ts @@ -0,0 +1,47 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import type { ProviderProfileField } from '@agent-device/contracts/provider-profile-fields'; +import { CLOUD_WEBDRIVER_PROFILE_FIELDS } from './provider-definitions.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; +import { BROWSERSTACK_DEVICE_FEATURE_SPECS } from './browserstack-device-features.ts'; + +// Fields a hub reads directly while building its session, outside the device-feature table. +const HUB_SESSION_FIELDS: readonly ProviderProfileField[] = [ + 'providerApp', + 'providerOsVersion', + 'providerProject', + 'providerBuild', + 'providerSessionName', +]; + +function consumedFields(provider: keyof typeof CLOUD_WEBDRIVER_PROFILE_FIELDS): string[] { + const { fields } = CLOUD_WEBDRIVER_PROFILE_FIELDS[provider]; + return (Object.keys(fields) as ProviderProfileField[]) + .filter((field) => fields[field] === 'consumed') + .sort(); +} + +test('every declaration names the provider it is registered under', () => { + for (const [provider, declaration] of Object.entries(CLOUD_WEBDRIVER_PROFILE_FIELDS)) { + assert.equal(declaration.provider, provider); + } +}); + +// A consumed device feature with no capability row would be accepted and then dropped at the hub. +test('BrowserStack consumes exactly the fields its capability builder reads', () => { + assert.deepEqual( + consumedFields(CLOUD_WEBDRIVER_PROVIDERS.browserStack), + [...HUB_SESSION_FIELDS, ...BROWSERSTACK_DEVICE_FEATURE_SPECS.map((spec) => spec.field)].sort(), + ); +}); + +test('AWS Device Farm consumes only its own fields and the session name', () => { + assert.deepEqual(consumedFields(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm), [ + 'awsAppArn', + 'awsDeviceArn', + 'awsInteractionMode', + 'awsProjectArn', + 'awsRegion', + 'providerSessionName', + ]); +}); diff --git a/packages/provider-webdriver/src/runtime-session.ts b/packages/provider-webdriver/src/runtime-session.ts index cce3f7ca86..293aa51b0d 100644 --- a/packages/provider-webdriver/src/runtime-session.ts +++ b/packages/provider-webdriver/src/runtime-session.ts @@ -203,6 +203,12 @@ export class WebDriverSessionManager { lease: DeviceLease, req: LeaseLifecycleContext | undefined, ): Promise { + if (this.options.profileFields) { + // Loaded on first allocation so the package entry's eager closure stays unchanged. + const { rejectRefusedProviderProfileFields } = + await import('@agent-device/contracts/provider-profile-fields'); + rejectRefusedProviderProfileFields(req?.flags, this.options.profileFields); + } const base = this.baseSessionForLease(lease); return this.options.prepareSession ? await this.options.prepareSession({ lease, req, base }) diff --git a/packages/provider-webdriver/src/runtime.ts b/packages/provider-webdriver/src/runtime.ts index 3ee5aab125..146cb89787 100644 --- a/packages/provider-webdriver/src/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -18,6 +18,7 @@ import type { PlatformRuntimeProviderModule, } from '@agent-device/contracts/platform-runtime-operations'; import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; +import type { ProviderProfileFieldDeclaration } from '@agent-device/contracts/provider-profile-fields'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createCloudWebDriverCapabilities, @@ -100,6 +101,8 @@ export type CloudWebDriverRuntimeOptions = { deviceId?: (lease: DeviceLease) => string; prepareSession?: CloudWebDriverPrepareSession; capabilityOverrides?: CloudWebDriverCapabilityOverrides; + /** Profile fields this provider reads; any field it refuses fails session preparation. */ + profileFields?: ProviderProfileFieldDeclaration; }; export function createCloudWebDriverRuntime( diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 840c9d49cb..d5f7f69d10 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -87,6 +87,7 @@ "@agent-device/contracts/platform-runtime-operations", "@agent-device/contracts/platform-runtime-unavailable", "@agent-device/contracts/progress", + "@agent-device/contracts/provider-profile-fields", "@agent-device/contracts/react-native-overlay", "@agent-device/contracts/record-runtime-execution", "@agent-device/contracts/recording", diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index fd25bb2551..bac00c15c9 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -326,6 +326,64 @@ test('connect limrun rejects unsupported public device and backend selections', assert.match(backend.stdout, /requires --lease-backend ios-instance/); }); +test('connect limrun refuses profile fields Limrun does not read', async () => { + const result = await runCliCapture( + [ + 'connect', + 'limrun', + '--platform', + 'ios', + '--provider-os-version', + '18', + '--provider-geo-location', + 'US', + '--json', + ], + { + env: { LIMRUN_API_KEY: 'lim_test_key' }, + stateDirPrefix: 'agent-device-connect-limrun-profile-fields-', + }, + ); + assert.equal(result.code, 1); + assert.match( + result.stdout, + /--provider-os-version, --provider-geo-location are not supported by Limrun/, + ); +}); + +test('connect browserstack refuses AWS Device Farm flags', () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-browserstack-reject-'); + + try { + assert.throws( + () => + resolveCloudWebDriverConnectProfile({ + provider: 'browserstack', + stateDir: path.join(tempRoot, '.state'), + cwd: tempRoot, + env: {}, + flags: { + json: false, + help: false, + version: false, + platform: 'android', + device: 'Google Pixel 8', + awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project:project-a', + }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.message, '--aws-project-arn is not supported by BrowserStack.'); + assert.equal(error.details?.provider, 'browserstack'); + return true; + }, + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('connect without remote config rejects legacy remoteConfig string profile response', async () => { const tempRoot = mkdtempForTestSync('agent-device-connect-cloud-legacy-'); const stateDir = path.join(tempRoot, '.state'); @@ -810,7 +868,7 @@ test('connect does not activate provider state when verification fails', async ( } }); -test('connect aws-device-farm rejects BrowserStack-only device-feature flags', () => { +test('connect aws-device-farm rejects device-feature flags it does not read', () => { const tempRoot = mkdtempForTestSync('agent-device-connect-aws-reject-'); try { @@ -837,7 +895,7 @@ test('connect aws-device-farm rejects BrowserStack-only device-feature flags', ( // Names every offending flag, and fires before the provider's own required-arg checks so // the caller is told what is unsupported rather than what else is missing. assert.match(error.message, /--provider-device-orientation, --provider-timezone/); - assert.match(error.message, /only supported by BrowserStack, not aws-device-farm/); + assert.match(error.message, /are not supported by AWS Device Farm/); assert.deepEqual(error.details?.flags, [ '--provider-device-orientation', '--provider-timezone', diff --git a/src/__tests__/lease-provider-profile-fields.test.ts b/src/__tests__/lease-provider-profile-fields.test.ts new file mode 100644 index 0000000000..4d0b35e00b --- /dev/null +++ b/src/__tests__/lease-provider-profile-fields.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { + errorResponse, + type DaemonRequest, + type DaemonResponse, +} from '@agent-device/kernel/contracts'; +import { LimrunRuntime } from '../sdk/limrun.ts'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; +import { handleLeaseCommands } from '../daemon/handlers/lease.ts'; +import { LeaseRegistry } from '../daemon/lease-registry.ts'; +import { createProviderDeviceRuntimeRequestProviders } from '../provider-device-runtime.ts'; +import { + hashRemoteConfigFile, + writeRemoteConnectionState, +} from '../remote/remote-connection-state.ts'; +import { createTransport } from './client-transport-fixture.ts'; +import { runCliCapture } from './cli-capture.ts'; +import { makeTempWorkspace } from './cli-config-fixtures.ts'; +import { makeSessionStore } from './test-utils/store-factory.ts'; + +const limrunInstances = vi.hoisted(() => ({ + iosCreate: vi.fn(async () => { + throw new Error('a refused allocation must not create a Limrun instance'); + }), +})); + +vi.mock('@limrun/api', () => ({ + default: class MockLimrun { + readonly iosInstances = { create: limrunInstances.iosCreate }; + }, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +// The daemon's lease handler over the same provider composition a daemon builds, so a request +// reaches the Limrun runtime exactly as it does in production. +function limrunDaemon(): (req: Omit) => Promise { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); + const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + const leaseRegistry = new LeaseRegistry(); + const sessionStore = makeSessionStore('agent-device-limrun-profile-fields-'); + return async (req) => { + try { + return (await handleLeaseCommands({ + req: { ...req, token: 'test-token' } as Parameters[0]['req'], + sessionName: req.session ?? 'default', + sessionStore, + leaseRegistry, + providerRuntimeIds: providers.providerRuntimeIds, + providerRuntimeRequiredIds: providers.providerRuntimeRequiredIds, + leaseLifecycleProvider: providers.leaseLifecycleProvider, + })) as DaemonResponse; + } catch (error) { + const normalized = normalizeError(error); + return errorResponse(normalized.code, normalized.message, normalized.details); + } + }; +} + +test('leases.allocate refuses an OS version Limrun cannot honour instead of ignoring it', async () => { + const setup = createTransport(limrunDaemon()); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + await assert.rejects( + client.leases.allocate({ + tenant: 'limrun', + runId: 'run-os-version', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + platform: 'ios', + providerOsVersion: '18.0', + }), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /--provider-os-version is not supported by Limrun/.test(error.message), + ); + assert.equal(limrunInstances.iosCreate.mock.calls.length, 0); +}); + +test('a remote-config profile cannot carry a field Limrun refuses past lease allocation', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'limrun.remote.json'); + fs.writeFileSync(remoteConfig, JSON.stringify({ providerGeoLocation: 'US' }), 'utf8'); + const now = new Date().toISOString(); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'limrun-profile', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + tenant: 'limrun', + runId: 'run-profile', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + platform: 'ios', + connectedAt: now, + updatedAt: now, + }, + }); + const daemon = limrunDaemon(); + + try { + const result = await runCliCapture(['open', '--state-dir', stateDir, '--json'], { + cwd: project, + env: { HOME: home }, + sendToDaemon: async (req) => await daemon(req), + }); + + assert.equal(result.code, 1); + assert.equal(result.calls[0]?.command, 'lease_allocate'); + assert.equal(result.calls[0]?.flags?.providerGeoLocation, 'US'); + assert.match(result.stdout, /--provider-geo-location is not supported by Limrun/); + assert.equal(limrunInstances.iosCreate.mock.calls.length, 0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/cli/connection/cloud-webdriver-profile.ts b/src/cli/connection/cloud-webdriver-profile.ts index 2d3d5d799f..049a707a75 100644 --- a/src/cli/connection/cloud-webdriver-profile.ts +++ b/src/cli/connection/cloud-webdriver-profile.ts @@ -1,10 +1,11 @@ import { + CLOUD_WEBDRIVER_PROFILE_FIELDS, CLOUD_WEBDRIVER_PROVIDERS, parseBrowserStackAppReference, readAwsDeviceFarmRegionFromArn, - rejectBrowserStackOnlyDeviceFeatures, type CloudWebDriverKnownProviderName, } from '@agent-device/provider-webdriver'; +import { rejectRefusedProviderProfileFields } from '@agent-device/contracts/provider-profile-fields'; import type { RemoteConfigProfile } from '../../remote/remote-config-schema.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { PlatformSelector } from '@agent-device/kernel/device'; @@ -24,7 +25,12 @@ export function resolveCloudWebDriverConnectProfile(options: { cwd: string; env?: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { - const providerConfig = requireConnectProfileBuilder(options.provider)(options); + const buildProfileFields = requireConnectProfileBuilder(options.provider); + rejectRefusedProviderProfileFields( + options.flags, + CLOUD_WEBDRIVER_PROFILE_FIELDS[options.provider], + ); + const providerConfig = buildProfileFields(options); const clientId = buildConnectClientId( options.provider, options.stateDir, @@ -144,7 +150,6 @@ function awsDeviceFarmProfileFields(options: { env?: EnvMap; }): RemoteConfigProfile { const { env, flags } = options; - rejectBrowserStackOnlyDeviceFeatures(flags, CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm); const platform = requireCloudWebDriverPlatform( flags.platform, 'connect aws-device-farm requires --platform ios|android.', diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index bcffed85f4..56f786cb8f 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -3,6 +3,8 @@ import type { RemoteConfigProfile } from '../../remote/remote-config-schema.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { CliFlags } from '@agent-device/contracts/command'; import { type EnvMap } from '@agent-device/kernel/source-value'; +import { rejectRefusedProviderProfileFields } from '@agent-device/contracts/provider-profile-fields'; +import { LIMRUN_PROFILE_FIELDS } from '@agent-device/provider-limrun'; import { readMetroProfileFields } from './profile-fields.ts'; import { persistAndResolveGeneratedProfile } from './generated-config.ts'; import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts'; @@ -61,6 +63,7 @@ function buildLimrunRemoteProfile(options: { flags: CliFlags }): RemoteConfigPro } function validateLimrunConnectFlags(flags: CliFlags): 'android-instance' | 'ios-instance' { + rejectRefusedProviderProfileFields(flags, LIMRUN_PROFILE_FIELDS); if (flags.platform !== 'android' && flags.platform !== 'ios') { throw new AppError('INVALID_ARGS', 'connect limrun requires --platform ios or android.'); } diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts index d98a17f151..319f799c3e 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -142,7 +142,7 @@ test('BrowserStack facade nests device-feature capabilities inside bstack:option }); }, 15_000); -test('AWS Device Farm facade rejects BrowserStack-owned device features at session preparation', async () => { +test('AWS Device Farm facade rejects device features it does not read at session preparation', async () => { await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { const host = new FakeAwsHostCommand(`${server.url}/wd/hub/`); const provider = createProviderWebDriver({ @@ -172,7 +172,7 @@ test('AWS Device Farm facade rejects BrowserStack-owned device features at sessi (error: unknown) => { assert.match( (error as Error).message, - /--provider-device-orientation, --provider-network-profile are only supported by BrowserStack, not aws-device-farm/, + /--provider-device-orientation, --provider-network-profile are not supported by AWS Device Farm/, ); return true; }, diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index b7f165dd5f..d81d679bc0 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -20,4 +20,6 @@ For each provider, the standard lifecycle is: 3. Follow the printed next command to install or open the app. 4. Run normal device commands, then `agent-device close` and `agent-device disconnect`. +Each provider reads only its own provider flags (`--provider-*` and `--aws-*`). A flag the provider does not use fails with `INVALID_ARGS` naming the flag, whether it arrives through `connect`, `client.leases.allocate()`, or a remote-config profile, so a setting is never silently dropped. + Each provider guide covers its connection selectors, client configuration, MCP setup, artifacts, and troubleshooting. Generated remote profiles are safe to store as non-secret configuration. They may include app IDs, ARNs, device names, OS versions, and labels, but never provider API keys or AWS secret keys. From ccd0e189a0dac71e1e0cc13cc83a66e3d3e188da Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Sat, 3 Oct 2026 10:19:38 +0530 Subject: [PATCH 02/10] fix(provider-webdriver): refuse profile fields on a repeat allocation too The daemon hands a run's repeat lease_allocate the lease it already holds, and both the WebDriver runtime and the Limrun runtime returned the live session for a known lease before checking the request's profile fields. A second allocation on the same run that set a refused field, such as awsProjectArn on BrowserStack, therefore succeeded. Both runtimes now refuse before reusing a live session. Because a refusal of that repeat request is a provider allocate failure, the lease handler no longer releases a lease it reused when allocate throws; doing so would end the run's lease and leave the session the first allocation created without an owner. The runtime's profileFields option is now required, and each provider definition passes its own declaration into createRuntime, so a provider that forgets to wire its declaration fails to compile instead of silently refusing nothing. Co-Authored-By: Claude Opus 5.5 --- packages/provider-limrun/src/runtime.ts | 7 ++-- packages/provider-webdriver/src/index.ts | 2 +- .../src/profile-fields.fixtures.ts | 30 ++++++++++++++ .../src/provider-definitions.ts | 16 +++++--- .../src/runtime-session.test.ts | 2 + .../provider-webdriver/src/runtime-session.ts | 11 +++--- .../provider-webdriver/src/runtime.test.ts | 2 + packages/provider-webdriver/src/runtime.ts | 4 +- src/__tests__/limrun-runtime.test.ts | 26 +++++++++++++ src/daemon/handlers/__tests__/lease.test.ts | 36 +++++++++++++++++ src/daemon/handlers/lease.ts | 8 +++- .../cloud-webdriver-provider-adapters.test.ts | 39 +++++++++++++++++++ 12 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 packages/provider-webdriver/src/profile-fields.fixtures.ts diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 9492c0c775..3469d3d731 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -257,12 +257,13 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { if (lease.leaseProvider !== this.provider) return undefined; const platform = platformForLimrunLeaseBackend(lease.backend); if (!platform) return undefined; + const { rejectRefusedLimrunProfileFields } = await import('./session-allocation.ts'); + // Before the reuse below, since a repeat allocation of a live lease carries flags of its own, and + // ahead of attach as well as create, since an attached instance reads none of them either. + rejectRefusedLimrunProfileFields(context); const existing = this.sessions.get(lease.leaseId); if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; - const { rejectRefusedLimrunProfileFields } = await import('./session-allocation.ts'); - // Ahead of attach as well as create: an attached instance reads none of the refused fields either. - rejectRefusedLimrunProfileFields(context); const session = (await this.attachSession(platform, lease)) ?? (await this.createSession(platform, lease, context)); diff --git a/packages/provider-webdriver/src/index.ts b/packages/provider-webdriver/src/index.ts index 2072cc8a5a..1ff12c136f 100644 --- a/packages/provider-webdriver/src/index.ts +++ b/packages/provider-webdriver/src/index.ts @@ -51,7 +51,7 @@ export function createProviderWebDriver( return { providerIds: definitions.map((definition) => definition.provider), createDefaultRuntimes: (env = process.env) => - definitions.map((definition) => definition.createRuntime(env)), + definitions.map((definition) => definition.createRuntime(env, definition.profileFields)), listArtifactsFromEnv: async (query, env) => { if (!query.providerSessionId) return undefined; return await definitions diff --git a/packages/provider-webdriver/src/profile-fields.fixtures.ts b/packages/provider-webdriver/src/profile-fields.fixtures.ts new file mode 100644 index 0000000000..941db5235a --- /dev/null +++ b/packages/provider-webdriver/src/profile-fields.fixtures.ts @@ -0,0 +1,30 @@ +import type { ProviderProfileFieldDeclaration } from '@agent-device/contracts/provider-profile-fields'; + +/** A test hub that reads every profile field, so no flag is refused. */ +export function consumeAllProfileFields(provider: string): ProviderProfileFieldDeclaration { + return { + provider, + label: provider, + fields: { + providerApp: 'consumed', + providerOsVersion: 'consumed', + providerProject: 'consumed', + providerBuild: 'consumed', + providerSessionName: 'consumed', + providerDeviceOrientation: 'consumed', + providerGeoLocation: 'consumed', + providerTimezone: 'consumed', + providerAppiumVersion: 'consumed', + providerLanguage: 'consumed', + providerLocale: 'consumed', + providerNetworkProfile: 'consumed', + providerCustomNetwork: 'consumed', + providerNoResignApp: 'consumed', + awsProjectArn: 'consumed', + awsDeviceArn: 'consumed', + awsAppArn: 'consumed', + awsRegion: 'consumed', + awsInteractionMode: 'consumed', + }, + }; +} diff --git a/packages/provider-webdriver/src/provider-definitions.ts b/packages/provider-webdriver/src/provider-definitions.ts index c051889dd3..0cd3d63d68 100644 --- a/packages/provider-webdriver/src/provider-definitions.ts +++ b/packages/provider-webdriver/src/provider-definitions.ts @@ -112,9 +112,13 @@ export const CLOUD_WEBDRIVER_PROFILE_FIELDS: Readonly< export type CloudWebDriverProviderDefinition = { provider: CloudWebDriverKnownProviderName; - /** Every profile field, consumed or refused; session preparation refuses the refused ones. */ + /** Every profile field, consumed or refused; lease allocation refuses the refused ones. */ profileFields: ProviderProfileFieldDeclaration; - createRuntime: (env: DefaultCloudWebDriverProviderRuntimeEnv) => CloudWebDriverRuntime; + /** Receives `profileFields`, which the runtime requires, so the two cannot drift apart. */ + createRuntime: ( + env: DefaultCloudWebDriverProviderRuntimeEnv, + profileFields: ProviderProfileFieldDeclaration, + ) => CloudWebDriverRuntime; listArtifactsFromEnv: ( providerSessionId: string, env: DefaultCloudWebDriverArtifactEnv, @@ -128,11 +132,11 @@ export function createCloudWebDriverProviderDefinitions( { provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, profileFields: BROWSERSTACK_PROFILE_FIELDS, - createRuntime: (env) => + createRuntime: (env, profileFields) => createCloudWebDriverRuntime({ clientVersion: dependencies.clientVersion, provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, - profileFields: BROWSERSTACK_PROFILE_FIELDS, + profileFields, platform: 'android', deviceName: 'BrowserStack device', endpoint: env.BROWSERSTACK_WEBDRIVER_ENDPOINT ?? BROWSERSTACK_APP_AUTOMATE_ENDPOINT, @@ -237,11 +241,11 @@ export function createCloudWebDriverProviderDefinitions( { provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, profileFields: AWS_DEVICE_FARM_PROFILE_FIELDS, - createRuntime: (env) => + createRuntime: (env, profileFields) => createCloudWebDriverRuntime({ clientVersion: dependencies.clientVersion, provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - profileFields: AWS_DEVICE_FARM_PROFILE_FIELDS, + profileFields, endpoint: 'http://127.0.0.1/', platform: 'android', deviceName: 'AWS Device Farm device', diff --git a/packages/provider-webdriver/src/runtime-session.test.ts b/packages/provider-webdriver/src/runtime-session.test.ts index e435e5571b..4429903b7e 100644 --- a/packages/provider-webdriver/src/runtime-session.test.ts +++ b/packages/provider-webdriver/src/runtime-session.test.ts @@ -3,6 +3,7 @@ import { afterEach, test } from 'vitest'; import type { DeviceLease } from '@agent-device/contracts/device'; import { AppError } from '@agent-device/kernel/errors'; import { createCloudWebDriverRuntime, type CloudWebDriverRuntimeOptions } from './runtime.ts'; +import { consumeAllProfileFields } from './profile-fields.fixtures.ts'; const realFetch = globalThis.fetch; @@ -106,6 +107,7 @@ function makeRuntime(overrides: Partial = {}) { endpoint: 'https://webdriver.test/wd/hub/', platform: 'android', deviceName: 'Test device', + profileFields: consumeAllProfileFields('webdriver-test'), requestPolicy: { retryAttempts: 0 }, ...overrides, }); diff --git a/packages/provider-webdriver/src/runtime-session.ts b/packages/provider-webdriver/src/runtime-session.ts index 293aa51b0d..7274789f6c 100644 --- a/packages/provider-webdriver/src/runtime-session.ts +++ b/packages/provider-webdriver/src/runtime-session.ts @@ -82,6 +82,11 @@ export class WebDriverSessionManager { async allocate(lease: DeviceLease, req?: LeaseLifecycleContext): Promise { if (lease.leaseProvider !== this.options.provider) return undefined; + // Before the reuse below: a repeat allocation of a live lease carries flags of its own. + // Loaded here rather than eagerly so the package entry's closure stays unchanged. + const { rejectRefusedProviderProfileFields } = + await import('@agent-device/contracts/provider-profile-fields'); + rejectRefusedProviderProfileFields(req?.flags, this.options.profileFields); if (this.sessionsByLeaseId.has(lease.leaseId)) return this.heartbeat(lease); const prepared = await this.prepareSession(lease, req); const client = new WebDriverClient({ @@ -203,12 +208,6 @@ export class WebDriverSessionManager { lease: DeviceLease, req: LeaseLifecycleContext | undefined, ): Promise { - if (this.options.profileFields) { - // Loaded on first allocation so the package entry's eager closure stays unchanged. - const { rejectRefusedProviderProfileFields } = - await import('@agent-device/contracts/provider-profile-fields'); - rejectRefusedProviderProfileFields(req?.flags, this.options.profileFields); - } const base = this.baseSessionForLease(lease); return this.options.prepareSession ? await this.options.prepareSession({ lease, req, base }) diff --git a/packages/provider-webdriver/src/runtime.test.ts b/packages/provider-webdriver/src/runtime.test.ts index 134e8a4a78..e94d1faece 100644 --- a/packages/provider-webdriver/src/runtime.test.ts +++ b/packages/provider-webdriver/src/runtime.test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; import { createCloudWebDriverRuntime } from './runtime.ts'; +import { consumeAllProfileFields } from './profile-fields.fixtures.ts'; test('publishes eager provider metadata without loading a WebDriver session', async () => { const runtime = createCloudWebDriverRuntime({ @@ -8,6 +9,7 @@ test('publishes eager provider metadata without loading a WebDriver session', as endpoint: 'https://webdriver.test/wd/hub/', platform: 'android', deviceName: 'Test device', + profileFields: consumeAllProfileFields('webdriver-test'), }); try { diff --git a/packages/provider-webdriver/src/runtime.ts b/packages/provider-webdriver/src/runtime.ts index 146cb89787..4b94ac2d72 100644 --- a/packages/provider-webdriver/src/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -101,8 +101,8 @@ export type CloudWebDriverRuntimeOptions = { deviceId?: (lease: DeviceLease) => string; prepareSession?: CloudWebDriverPrepareSession; capabilityOverrides?: CloudWebDriverCapabilityOverrides; - /** Profile fields this provider reads; any field it refuses fails session preparation. */ - profileFields?: ProviderProfileFieldDeclaration; + /** Profile fields this provider reads; any field it refuses fails lease allocation. */ + profileFields: ProviderProfileFieldDeclaration; }; export function createCloudWebDriverRuntime( diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 2d4141d1f8..17b22f2ec2 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -177,6 +177,32 @@ test('Limrun runtime identifies direct CLI usage to the Limrun API', async () => } }); +test('Limrun refuses a refused field on a repeat allocation of its live lease', async () => { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); + const lease: SimulatorLease = { + leaseId: 'lease-repeat', + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await allocateLease(lease); + await assert.rejects( + allocateLease(lease, { flags: { providerOsVersion: '18.0' } }), + /--provider-os-version is not supported by Limrun/, + ); + assert.equal(limrunMockState.iosCreate.mock.calls.length, 1); + } finally { + await runtime.shutdown(); + } +}); + test('Limrun iOS uses shared deep-link classification', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); diff --git a/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index 28346814a9..0e0f470607 100644 --- a/src/daemon/handlers/__tests__/lease.test.ts +++ b/src/daemon/handlers/__tests__/lease.test.ts @@ -175,3 +175,39 @@ test('a lease allocated without a provider keeps the TTL it was created with', a assert.equal(lease.expiresAt, lease.createdAt + 60_000); assert.deepEqual(registry.listActiveLeases(), [lease]); }); + +// The registry hands a run's repeat allocation the lease it already holds. A provider refusing the +// repeat request (a profile field it does not read) must leave that lease and its session alone. +test("a refused repeat allocation keeps the run's live lease", async () => { + const registry = new LeaseRegistry(); + const sessionStore = makeSessionStore('agent-device-refused-repeat-'); + let calls = 0; + const allocate = async (req: DaemonRequest) => + await handleLeaseCommands({ + req, + sessionName: 'lease-ttl-test', + sessionStore, + leaseRegistry: registry, + leaseLifecycleProvider: { + allocate: async () => { + calls += 1; + if (calls === 1) return { providerSessionId: 'session-1' }; + throw new AppError('INVALID_ARGS', '--provider-os-version is not supported by Cloud.'); + }, + }, + }); + + const first = await allocate(allocateRequest()); + const lease = (first?.ok ? first.data?.lease : undefined) as DeviceLease; + const repeat = allocateRequest(); + repeat.flags = { providerOsVersion: '18.0' }; + await assert.rejects( + allocate(repeat), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); + assert.equal(calls, 2); + assert.deepEqual( + registry.listActiveLeases().map((entry) => entry.leaseId), + [lease.leaseId], + ); +}); diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 0aaac48f63..5704959c24 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -70,7 +70,13 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise entry.leaseId), + ); const lease = leaseRegistry.allocateLease(leaseScopeToAllocateRequest(leaseScope)); + // A run's repeat allocation reuses its live lease; refusing that request must not end the + // lease, or the provider session the first allocation created is left without an owner. + const reused = activeLeaseIds.has(lease.leaseId); const requestId = req.meta?.requestId; return await leaseRegistry.runDeviceMutation(lease, async () => { let providerData: Record | undefined; @@ -87,7 +93,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { + await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { + const provider = createProviderWebDriver({ + clientVersion: CLIENT_VERSION, + runHostCommand: unexpectedHostCommand, + }); + const runtime = runtimeFor( + provider.createDefaultRuntimes({ + BROWSERSTACK_USERNAME: 'user', + BROWSERSTACK_ACCESS_KEY: 'key', + BROWSERSTACK_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`, + }), + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + ); + const lease = makeLease(CLOUD_WEBDRIVER_PROVIDERS.browserStack); + const context = browserStackContext(lease); + try { + await runtime.leaseLifecycle.allocate?.(lease, context); + const sessionCalls = server.calls.length; + await assert.rejects( + async () => + await runtime.leaseLifecycle.allocate?.(lease, { + flags: { + ...context.flags, + awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project/project-id', + }, + }), + /--aws-project-arn is not supported by BrowserStack/, + ); + assert.equal(server.calls.length, sessionCalls); + assert.deepEqual(await runtime.leaseLifecycle.heartbeat?.(lease), { + provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + }); + } finally { + await runtime.shutdown(); + } + }); +}, 15_000); + test('AWS Device Farm facade uses the injected host-command capability for its full lifecycle', async () => { await withProviderScenarioResource(FakeCloudProviderServer.start, async (server) => { const host = new FakeAwsHostCommand(`${server.url}/wd/hub/`); From 25a1eadfa15bf3d32b15e808a3473a43b81aadfe Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Sat, 3 Oct 2026 10:21:41 +0530 Subject: [PATCH 03/10] refactor(provider-webdriver): derive BrowserStack feature flags from the profile field map The refusal's field-to-flag table and the flag column of BROWSERSTACK_DEVICE_FEATURE_SPECS spelled the same flags twice. PROVIDER_PROFILE_FIELD_FLAGS now lives beside CloudProviderProfileFields and is exported from @agent-device/contracts/remote, which the BrowserStack table already imports, so its rows read each flag from that map without growing the provider-webdriver eager closure. The refusal reads the same map. A command-registry test pins every entry to the CLI's canonical flag for the field. Co-Authored-By: Claude Opus 5.5 --- .../provider-profile-field-flags.test.ts | 14 ++++++++++ packages/contracts/src/facades/remote.ts | 5 +++- .../contracts/src/provider-profile-fields.ts | 27 +++---------------- .../contracts/src/remote-config-fields.ts | 25 +++++++++++++++++ .../src/browserstack-device-features.ts | 20 +++++++------- 5 files changed, 56 insertions(+), 35 deletions(-) create mode 100644 packages/command-registry/src/__tests__/provider-profile-field-flags.test.ts diff --git a/packages/command-registry/src/__tests__/provider-profile-field-flags.test.ts b/packages/command-registry/src/__tests__/provider-profile-field-flags.test.ts new file mode 100644 index 0000000000..5560c9a84d --- /dev/null +++ b/packages/command-registry/src/__tests__/provider-profile-field-flags.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest'; +import type { ProviderProfileField } from '@agent-device/contracts/provider-profile-fields'; +import { PROVIDER_PROFILE_FIELD_FLAGS } from '@agent-device/contracts/remote'; +import { getFlagDefinitionsForKey } from '../flag-registry.ts'; + +// Refusals and BrowserStack feature errors name flags from this map, so each must be the CLI's +// canonical spelling of that field. +test('every provider profile field flag is the canonical CLI flag for its field', () => { + for (const [field, flag] of Object.entries(PROVIDER_PROFILE_FIELD_FLAGS) as Array< + [ProviderProfileField, string] + >) { + expect(getFlagDefinitionsForKey(field)[0]?.names[0], field).toBe(flag); + } +}); diff --git a/packages/contracts/src/facades/remote.ts b/packages/contracts/src/facades/remote.ts index 852c8272cb..4ba7ec3255 100644 --- a/packages/contracts/src/facades/remote.ts +++ b/packages/contracts/src/facades/remote.ts @@ -14,7 +14,10 @@ export type { ProviderConnectionResource, ProviderConnectionVerification, } from '../provider-connection.ts'; -export { PROVIDER_DEVICE_ORIENTATIONS } from '../remote-config-fields.ts'; +export { + PROVIDER_DEVICE_ORIENTATIONS, + PROVIDER_PROFILE_FIELD_FLAGS, +} from '../remote-config-fields.ts'; export type { CloudProviderProfileFields, ProviderDeviceOrientation, diff --git a/packages/contracts/src/provider-profile-fields.ts b/packages/contracts/src/provider-profile-fields.ts index acbd0f4c3b..4c43d7128f 100644 --- a/packages/contracts/src/provider-profile-fields.ts +++ b/packages/contracts/src/provider-profile-fields.ts @@ -1,5 +1,8 @@ import { AppError } from '@agent-device/kernel/errors'; -import type { CloudProviderProfileFields } from './remote-config-fields.ts'; +import { + PROVIDER_PROFILE_FIELD_FLAGS, + type CloudProviderProfileFields, +} from './remote-config-fields.ts'; export type ProviderProfileField = keyof CloudProviderProfileFields; @@ -14,28 +17,6 @@ export type ProviderProfileFieldDeclaration = Readonly<{ fields: Readonly>; }>; -const PROVIDER_PROFILE_FIELD_FLAGS: Readonly> = { - providerApp: '--provider-app', - providerOsVersion: '--provider-os-version', - providerProject: '--provider-project', - providerBuild: '--provider-build', - providerSessionName: '--provider-session-name', - providerDeviceOrientation: '--provider-device-orientation', - providerGeoLocation: '--provider-geo-location', - providerTimezone: '--provider-timezone', - providerAppiumVersion: '--provider-appium-version', - providerLanguage: '--provider-language', - providerLocale: '--provider-locale', - providerNetworkProfile: '--provider-network-profile', - providerCustomNetwork: '--provider-custom-network', - providerNoResignApp: '--provider-no-resign-app', - awsProjectArn: '--aws-project-arn', - awsDeviceArn: '--aws-device-arn', - awsAppArn: '--aws-app-arn', - awsRegion: '--aws-region', - awsInteractionMode: '--aws-interaction-mode', -}; - /** * Fails when `flags` set a profile field the provider refuses. Every route to a provider — connect, * `leases.allocate`, a hand-authored remote-config profile — runs this against the same declaration. diff --git a/packages/contracts/src/remote-config-fields.ts b/packages/contracts/src/remote-config-fields.ts index 5ab0b6a6b2..a2434435e8 100644 --- a/packages/contracts/src/remote-config-fields.ts +++ b/packages/contracts/src/remote-config-fields.ts @@ -42,6 +42,31 @@ export type CloudProviderProfileFields = { awsInteractionMode?: 'INTERACTIVE' | 'NO_VIDEO' | 'VIDEO_ONLY'; }; +/** Canonical CLI flag for each profile field, so an error can name a recovery action. */ +export const PROVIDER_PROFILE_FIELD_FLAGS: Readonly< + Record +> = { + providerApp: '--provider-app', + providerOsVersion: '--provider-os-version', + providerProject: '--provider-project', + providerBuild: '--provider-build', + providerSessionName: '--provider-session-name', + providerDeviceOrientation: '--provider-device-orientation', + providerGeoLocation: '--provider-geo-location', + providerTimezone: '--provider-timezone', + providerAppiumVersion: '--provider-appium-version', + providerLanguage: '--provider-language', + providerLocale: '--provider-locale', + providerNetworkProfile: '--provider-network-profile', + providerCustomNetwork: '--provider-custom-network', + providerNoResignApp: '--provider-no-resign-app', + awsProjectArn: '--aws-project-arn', + awsDeviceArn: '--aws-device-arn', + awsAppArn: '--aws-app-arn', + awsRegion: '--aws-region', + awsInteractionMode: '--aws-interaction-mode', +}; + export type RemoteConfigMetroOptions = { metroProjectRoot?: string; metroKind?: MetroPrepareKind; diff --git a/packages/provider-webdriver/src/browserstack-device-features.ts b/packages/provider-webdriver/src/browserstack-device-features.ts index 275ae64ff3..05995a068e 100644 --- a/packages/provider-webdriver/src/browserstack-device-features.ts +++ b/packages/provider-webdriver/src/browserstack-device-features.ts @@ -1,5 +1,6 @@ import { PROVIDER_DEVICE_ORIENTATIONS, + PROVIDER_PROFILE_FIELD_FLAGS, type CloudProviderProfileFields, } from '@agent-device/contracts/remote'; import { AppError } from '@agent-device/kernel/errors'; @@ -34,7 +35,7 @@ type BrowserStackDeviceFeatureSpec = { field: keyof BrowserStackDeviceFeatureFields; /** Key emitted inside `bstack:options`. */ capability: string; - /** Canonical CLI flag, so an error can name a recovery action. */ + /** Canonical CLI flag, read from `PROVIDER_PROFILE_FIELD_FLAGS`. */ flag: string; /** * `negated-boolean` is a flag whose presence means "turn the capability off" — BrowserStack @@ -46,24 +47,21 @@ type BrowserStackDeviceFeatureSpec = { platform?: CloudWebDriverPlatform; }; -export const BROWSERSTACK_DEVICE_FEATURE_SPECS: readonly BrowserStackDeviceFeatureSpec[] = [ +const BROWSERSTACK_DEVICE_FEATURE_ROWS: readonly Omit[] = [ { field: 'providerDeviceOrientation', capability: 'deviceOrientation', - flag: '--provider-device-orientation', type: 'enum', enumValues: PROVIDER_DEVICE_ORIENTATIONS, }, { field: 'providerGeoLocation', capability: 'geoLocation', - flag: '--provider-geo-location', type: 'string', }, { field: 'providerTimezone', capability: 'timezone', - flag: '--provider-timezone', type: 'string', }, { @@ -71,31 +69,26 @@ export const BROWSERSTACK_DEVICE_FEATURE_SPECS: readonly BrowserStackDeviceFeatu // (deepLink, pressButton, activateApp) need a 2.x+ server. field: 'providerAppiumVersion', capability: 'appiumVersion', - flag: '--provider-appium-version', type: 'string', }, { field: 'providerLanguage', capability: 'language', - flag: '--provider-language', type: 'string', }, { field: 'providerLocale', capability: 'locale', - flag: '--provider-locale', type: 'string', }, { field: 'providerNetworkProfile', capability: 'networkProfile', - flag: '--provider-network-profile', type: 'string', }, { field: 'providerCustomNetwork', capability: 'customNetwork', - flag: '--provider-custom-network', type: 'string', }, { @@ -103,12 +96,17 @@ export const BROWSERSTACK_DEVICE_FEATURE_SPECS: readonly BrowserStackDeviceFeatu // entitlements. Opting out keeps entitlement-dependent features (push notifications) testable. field: 'providerNoResignApp', capability: 'resignApp', - flag: '--provider-no-resign-app', type: 'negated-boolean', platform: 'ios', }, ]; +export const BROWSERSTACK_DEVICE_FEATURE_SPECS: readonly BrowserStackDeviceFeatureSpec[] = + BROWSERSTACK_DEVICE_FEATURE_ROWS.map((row) => ({ + ...row, + flag: PROVIDER_PROFILE_FIELD_FLAGS[row.field], + })); + /** * Builds the `bstack:options` fragment for the configured device features. * From 7bd1bd2cac3e279b84d62028d55e121099700713 Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Sat, 3 Oct 2026 18:18:56 +0530 Subject: [PATCH 04/10] fix(daemon): release a reused lease when its repeat allocation is canceled A repeat allocation reuses the run's live lease, and a provider refusal leaves that lease in place. A requester that hangs up also surfaces as an allocate rejection, because the request signal aborts with the typed cancellation, so the handler preserved the lease for a requester that would never release it, and the provider session from the first allocation lingered until the TTL sweep. Release the lease and its provider session through the gone-requester path when the request was canceled, reused or not. Only a refusal while the requester still waits preserves the reused lease. The refused-repeat test now also asserts the first allocation's provider session is still owned by the lease. Co-Authored-By: Claude Opus 5.5 --- src/daemon/handlers/__tests__/lease.test.ts | 61 ++++++++++++++++++++- src/daemon/handlers/lease.ts | 11 +++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index 0e0f470607..222336b092 100644 --- a/src/daemon/handlers/__tests__/lease.test.ts +++ b/src/daemon/handlers/__tests__/lease.test.ts @@ -6,7 +6,11 @@ import type { DaemonRequest } from '../../daemon-request.ts'; import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; import type { DeviceLease } from '@agent-device/contracts/device'; import { AppError } from '@agent-device/kernel/errors'; -import { clearRequestCanceled, markRequestCanceled } from '@agent-device/host-kit/request'; +import { + clearRequestCanceled, + markRequestCanceled, + registerRequestAbort, +} from '@agent-device/host-kit/request'; import { HUMAN_CONTROL_LEASE_REQUEST, HUMAN_CONTROL_SCOPE, @@ -210,4 +214,59 @@ test("a refused repeat allocation keeps the run's live lease", async () => { registry.listActiveLeases().map((entry) => entry.leaseId), [lease.leaseId], ); + assert.equal( + registry.resolveProviderSession({ + provider: lease.leaseProvider, + providerSessionId: 'session-1', + tenantId: lease.tenantId, + })?.leaseId, + lease.leaseId, + ); +}); + +// A requester that hangs up during its repeat allocation will never release the lease it reused, so +// the provider rejecting with the cancellation must release that lease and its session. +test('a repeat allocation canceled by its requester releases the reused lease', async () => { + const registry = new LeaseRegistry(); + const sessionStore = makeSessionStore('agent-device-canceled-repeat-'); + const requestId = 'canceled-repeat-allocation'; + const releasedSessions: unknown[] = []; + let calls = 0; + const allocate = async (req: DaemonRequest) => + await handleLeaseCommands({ + req, + sessionName: 'lease-ttl-test', + sessionStore, + leaseRegistry: registry, + leaseLifecycleProvider: { + allocate: async (_lease, context) => { + calls += 1; + if (calls === 1) return { providerSessionId: 'session-1' }; + markRequestCanceled(requestId); + context?.signal?.throwIfAborted(); + throw new Error('the request signal was not aborted'); + }, + release: async (released) => { + releasedSessions.push(released.leaseId); + return { providerSessionId: 'session-1' }; + }, + }, + }); + + const first = await allocate(allocateRequest()); + const lease = (first?.ok ? first.data?.lease : undefined) as DeviceLease; + const repeat = allocateRequest(); + repeat.meta = { ...repeat.meta, requestId }; + const registration = registerRequestAbort(requestId); + try { + await assert.rejects( + allocate(repeat), + (error: unknown) => error instanceof AppError && error.details?.released === true, + ); + } finally { + clearRequestCanceled(requestId, registration); + } + assert.equal(calls, 2); + assert.deepEqual(releasedSessions, [lease.leaseId]); + assert.deepEqual(registry.listActiveLeases(), []); }); diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 5704959c24..f0727e0d22 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -76,6 +76,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { @@ -93,7 +94,15 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise Date: Sat, 3 Oct 2026 18:20:27 +0530 Subject: [PATCH 05/10] test: assert profile field refusals by code and flags, not message text The refusal builder emits INVALID_ARGS with the refused flags in details.flags. Key the runtime, lease, connect and adapter tests on those typed details so rewording the message or a provider label does not break them. The builder's own unit tests still pin the message. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/cloud-connect-profile.test.ts | 15 ++++++++------- .../lease-provider-profile-fields.test.ts | 6 ++++-- src/__tests__/limrun-runtime.test.ts | 6 +++++- .../cloud-webdriver-provider-adapters.test.ts | 16 +++++++++++----- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index bac00c15c9..3b00310cc7 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -345,10 +345,12 @@ test('connect limrun refuses profile fields Limrun does not read', async () => { }, ); assert.equal(result.code, 1); - assert.match( - result.stdout, - /--provider-os-version, --provider-geo-location are not supported by Limrun/, - ); + const payload = JSON.parse(result.stdout); + assert.equal(payload.error.code, 'INVALID_ARGS'); + assert.deepEqual(payload.error.details.flags, [ + '--provider-os-version', + '--provider-geo-location', + ]); }); test('connect browserstack refuses AWS Device Farm flags', () => { @@ -374,8 +376,8 @@ test('connect browserstack refuses AWS Device Farm flags', () => { (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.code, 'INVALID_ARGS'); - assert.equal(error.message, '--aws-project-arn is not supported by BrowserStack.'); assert.equal(error.details?.provider, 'browserstack'); + assert.deepEqual(error.details?.flags, ['--aws-project-arn']); return true; }, ); @@ -894,8 +896,7 @@ test('connect aws-device-farm rejects device-feature flags it does not read', () assert.equal(error.code, 'INVALID_ARGS'); // Names every offending flag, and fires before the provider's own required-arg checks so // the caller is told what is unsupported rather than what else is missing. - assert.match(error.message, /--provider-device-orientation, --provider-timezone/); - assert.match(error.message, /are not supported by AWS Device Farm/); + assert.equal(error.details?.provider, 'aws-device-farm'); assert.deepEqual(error.details?.flags, [ '--provider-device-orientation', '--provider-timezone', diff --git a/src/__tests__/lease-provider-profile-fields.test.ts b/src/__tests__/lease-provider-profile-fields.test.ts index 4d0b35e00b..6409fbb95a 100644 --- a/src/__tests__/lease-provider-profile-fields.test.ts +++ b/src/__tests__/lease-provider-profile-fields.test.ts @@ -79,7 +79,7 @@ test('leases.allocate refuses an OS version Limrun cannot honour instead of igno (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && - /--provider-os-version is not supported by Limrun/.test(error.message), + JSON.stringify(error.details?.flags) === '["--provider-os-version"]', ); assert.equal(limrunInstances.iosCreate.mock.calls.length, 0); }); @@ -118,7 +118,9 @@ test('a remote-config profile cannot carry a field Limrun refuses past lease all assert.equal(result.code, 1); assert.equal(result.calls[0]?.command, 'lease_allocate'); assert.equal(result.calls[0]?.flags?.providerGeoLocation, 'US'); - assert.match(result.stdout, /--provider-geo-location is not supported by Limrun/); + const payload = JSON.parse(result.stdout); + assert.equal(payload.error.code, 'INVALID_ARGS'); + assert.deepEqual(payload.error.details.flags, ['--provider-geo-location']); assert.equal(limrunInstances.iosCreate.mock.calls.length, 0); } finally { fs.rmSync(root, { recursive: true, force: true }); diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 17b22f2ec2..16ef1d32b1 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -8,6 +8,7 @@ import { LimrunRuntime } from '../sdk/limrun.ts'; import { createExpiredProviderLeaseReleaser } from '../daemon/provider-lease-expiry.ts'; import type { SimulatorLease } from '../daemon/lease-registry.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; import { runCmd } from '@agent-device/host-kit/command'; import { readVersion } from '@agent-device/host-kit/version'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; @@ -195,7 +196,10 @@ test('Limrun refuses a refused field on a repeat allocation of its live lease', await allocateLease(lease); await assert.rejects( allocateLease(lease, { flags: { providerOsVersion: '18.0' } }), - /--provider-os-version is not supported by Limrun/, + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + JSON.stringify(error.details?.flags) === '["--provider-os-version"]', ); assert.equal(limrunMockState.iosCreate.mock.calls.length, 1); } finally { diff --git a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts index daaca9fa6d..31e5d18072 100644 --- a/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts +++ b/test/integration/provider-scenarios/cloud-webdriver-provider-adapters.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import type { IncomingHttpHeaders } from 'node:http'; import path from 'node:path'; import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; import { CLOUD_WEBDRIVER_PROVIDERS, createProviderWebDriver, @@ -170,10 +171,12 @@ test('AWS Device Farm facade rejects device features it does not read at session }, }), (error: unknown) => { - assert.match( - (error as Error).message, - /--provider-device-orientation, --provider-network-profile are not supported by AWS Device Farm/, - ); + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.deepEqual(error.details?.flags, [ + '--provider-device-orientation', + '--provider-network-profile', + ]); return true; }, ); @@ -212,7 +215,10 @@ test('BrowserStack refuses a refused field on a repeat allocation of its live le awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project/project-id', }, }), - /--aws-project-arn is not supported by BrowserStack/, + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + JSON.stringify(error.details?.flags) === '["--aws-project-arn"]', ); assert.equal(server.calls.length, sessionCalls); assert.deepEqual(await runtime.leaseLifecycle.heartbeat?.(lease), { From 3c0fc29a4081fa4e484d0ef1c76e42cf408bf575 Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Sat, 3 Oct 2026 18:21:20 +0530 Subject: [PATCH 06/10] fix(contracts): name a refused profile field's accepted aliases too A refusal named only the canonical --provider-* flag, so a user who set the field through an alias such as --os-version was told to drop a flag they never typed. The flags bag keeps the field, not the spelling, so the message now lists every accepted spelling, for example "--provider-os-version (--os-version)". details.flags stays canonical. Contracts cannot depend on the command registry, so the aliases live in PROVIDER_PROFILE_FIELD_FLAG_ALIASES, and a command-registry test checks that map against the aliases the CLI parses. Co-Authored-By: Claude Opus 5.5 --- .../provider-profile-field-flags.test.ts | 15 ++++++++++++++- packages/contracts/src/facades/remote.ts | 1 + .../src/provider-profile-fields.test.ts | 11 ++++++++--- .../contracts/src/provider-profile-fields.ts | 18 +++++++++++++----- packages/contracts/src/remote-config-fields.ts | 15 +++++++++++++++ 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/command-registry/src/__tests__/provider-profile-field-flags.test.ts b/packages/command-registry/src/__tests__/provider-profile-field-flags.test.ts index 5560c9a84d..4ae0057c68 100644 --- a/packages/command-registry/src/__tests__/provider-profile-field-flags.test.ts +++ b/packages/command-registry/src/__tests__/provider-profile-field-flags.test.ts @@ -1,6 +1,9 @@ import { expect, test } from 'vitest'; import type { ProviderProfileField } from '@agent-device/contracts/provider-profile-fields'; -import { PROVIDER_PROFILE_FIELD_FLAGS } from '@agent-device/contracts/remote'; +import { + PROVIDER_PROFILE_FIELD_FLAG_ALIASES, + PROVIDER_PROFILE_FIELD_FLAGS, +} from '@agent-device/contracts/remote'; import { getFlagDefinitionsForKey } from '../flag-registry.ts'; // Refusals and BrowserStack feature errors name flags from this map, so each must be the CLI's @@ -12,3 +15,13 @@ test('every provider profile field flag is the canonical CLI flag for its field' expect(getFlagDefinitionsForKey(field)[0]?.names[0], field).toBe(flag); } }); + +// Contracts cannot depend on the command registry, so refusals carry their own copy of each field's +// accepted aliases; it must match the aliases the CLI parses. +test('every provider profile field lists the aliases the CLI accepts for it', () => { + for (const field of Object.keys(PROVIDER_PROFILE_FIELD_FLAGS) as ProviderProfileField[]) { + expect(PROVIDER_PROFILE_FIELD_FLAG_ALIASES[field] ?? [], field).toEqual( + getFlagDefinitionsForKey(field)[0]?.names.slice(1), + ); + } +}); diff --git a/packages/contracts/src/facades/remote.ts b/packages/contracts/src/facades/remote.ts index 4ba7ec3255..277da93e58 100644 --- a/packages/contracts/src/facades/remote.ts +++ b/packages/contracts/src/facades/remote.ts @@ -16,6 +16,7 @@ export type { } from '../provider-connection.ts'; export { PROVIDER_DEVICE_ORIENTATIONS, + PROVIDER_PROFILE_FIELD_FLAG_ALIASES, PROVIDER_PROFILE_FIELD_FLAGS, } from '../remote-config-fields.ts'; export type { diff --git a/packages/contracts/src/provider-profile-fields.test.ts b/packages/contracts/src/provider-profile-fields.test.ts index d97ca1f19f..4d79c40a3e 100644 --- a/packages/contracts/src/provider-profile-fields.test.ts +++ b/packages/contracts/src/provider-profile-fields.test.ts @@ -48,13 +48,13 @@ test('consumed, unset, empty, and false fields pass', () => { ); }); -test('a refused field fails with its flag and the provider named', () => { +test('a refused field fails with its flag, its aliases, and the provider named', () => { assert.throws( () => rejectRefusedProviderProfileFields({ providerOsVersion: '18' }, DECLARATION), (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS' && - error.message === '--provider-os-version is not supported by Fake Cloud.' && + error.message === '--provider-os-version (--os-version) is not supported by Fake Cloud.' && error.details?.provider === 'fake' && JSON.stringify(error.details?.flags) === '["--provider-os-version"]', ); @@ -67,6 +67,11 @@ test('every refused field is reported at once', () => { { providerGeoLocation: 'US', providerNoResignApp: true, providerOsVersion: '18' }, DECLARATION, ), - /--provider-os-version, --provider-geo-location, --provider-no-resign-app are not supported by Fake Cloud\./, + (error: unknown) => + error instanceof AppError && + error.message === + '--provider-os-version (--os-version), --provider-geo-location (--geo-location), --provider-no-resign-app are not supported by Fake Cloud.' && + JSON.stringify(error.details?.flags) === + '["--provider-os-version","--provider-geo-location","--provider-no-resign-app"]', ); }); diff --git a/packages/contracts/src/provider-profile-fields.ts b/packages/contracts/src/provider-profile-fields.ts index 4c43d7128f..44d009fe72 100644 --- a/packages/contracts/src/provider-profile-fields.ts +++ b/packages/contracts/src/provider-profile-fields.ts @@ -1,5 +1,6 @@ import { AppError } from '@agent-device/kernel/errors'; import { + PROVIDER_PROFILE_FIELD_FLAG_ALIASES, PROVIDER_PROFILE_FIELD_FLAGS, type CloudProviderProfileFields, } from './remote-config-fields.ts'; @@ -25,22 +26,29 @@ export function rejectRefusedProviderProfileFields( flags: Readonly> | undefined, declaration: ProviderProfileFieldDeclaration, ): void { - const refused = (Object.keys(declaration.fields) as ProviderProfileField[]) - .filter((field) => declaration.fields[field] === 'refused' && isSet(flags?.[field])) - .map((field) => PROVIDER_PROFILE_FIELD_FLAGS[field]); + const refused = (Object.keys(declaration.fields) as ProviderProfileField[]).filter( + (field) => declaration.fields[field] === 'refused' && isSet(flags?.[field]), + ); if (refused.length === 0) return; const plural = refused.length !== 1; throw new AppError( 'INVALID_ARGS', - `${refused.join(', ')} ${plural ? 'are' : 'is'} not supported by ${declaration.label}.`, + `${refused.map(describeFlag).join(', ')} ${plural ? 'are' : 'is'} not supported by ${declaration.label}.`, { hint: `Drop ${plural ? 'those flags' : 'the flag'} or use a provider that supports ${plural ? 'them' : 'it'}.`, provider: declaration.provider, - flags: refused, + flags: refused.map((field) => PROVIDER_PROFILE_FIELD_FLAGS[field]), }, ); } +// The flags bag keeps the field, not the spelling, so name every spelling the user could have typed. +function describeFlag(field: ProviderProfileField): string { + const aliases = PROVIDER_PROFILE_FIELD_FLAG_ALIASES[field]; + const flag = PROVIDER_PROFILE_FIELD_FLAGS[field]; + return aliases?.length ? `${flag} (${aliases.join(', ')})` : flag; +} + function isSet(value: unknown): boolean { return value !== undefined && value !== null && value !== false && value !== ''; } diff --git a/packages/contracts/src/remote-config-fields.ts b/packages/contracts/src/remote-config-fields.ts index a2434435e8..728927d688 100644 --- a/packages/contracts/src/remote-config-fields.ts +++ b/packages/contracts/src/remote-config-fields.ts @@ -67,6 +67,21 @@ export const PROVIDER_PROFILE_FIELD_FLAGS: Readonly< awsInteractionMode: '--aws-interaction-mode', }; +/** Other spellings the CLI accepts for a profile field, so an error also names the one typed. */ +export const PROVIDER_PROFILE_FIELD_FLAG_ALIASES: Readonly< + Partial> +> = { + providerOsVersion: ['--os-version'], + providerDeviceOrientation: ['--device-orientation'], + providerGeoLocation: ['--geo-location'], + providerTimezone: ['--timezone'], + providerAppiumVersion: ['--appium-version'], + providerLanguage: ['--language'], + providerLocale: ['--locale'], + providerNetworkProfile: ['--network-profile'], + providerCustomNetwork: ['--custom-network'], +}; + export type RemoteConfigMetroOptions = { metroProjectRoot?: string; metroKind?: MetroPrepareKind; From ed606bfd8f3db013510132b9aa50a1592f10cbd1 Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Mon, 5 Oct 2026 13:17:12 +0530 Subject: [PATCH 07/10] refactor(daemon): settle a failed lease allocation in one helper Main's provider-only work pass and the reused-lease rules met in the lease_allocate handler and pushed it over the complexity threshold. Move the failure handling (release a new lease, keep a reused one, release a reused one whose requester left) into settleFailedAllocation. No behaviour change. Co-Authored-By: Claude Opus 5.5 --- src/daemon/handlers/lease.ts | 38 ++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index f0727e0d22..9561894c7f 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -74,9 +74,6 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise entry.leaseId), ); const lease = leaseRegistry.allocateLease(leaseScopeToAllocateRequest(leaseScope)); - // A run's repeat allocation reuses its live lease; refusing that request must not end the - // lease, or the provider session the first allocation created is left without an owner. - // A requester that hung up owns nothing, so its canceled repeat allocation still releases. const reused = activeLeaseIds.has(lease.leaseId); const requestId = req.meta?.requestId; return await leaseRegistry.runDeviceMutation(lease, async () => { @@ -94,15 +91,13 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise { + if (!reused) { + leaseRegistry.releaseLease(leaseReleaseRequestFor(lease)); + return; + } + if (isRequestCanceled(requestId)) { + throw await releaseAllocationForGoneRequester(lease, leaseLifecycleProvider, leaseRegistry); + } +} + async function releaseAllocationForGoneRequester( lease: DeviceLease, leaseLifecycleProvider: LeaseLifecycleProvider | undefined, From e0425c8f4bdce9c40c0a56bdfce44a3bc02056fe Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Mon, 5 Oct 2026 13:22:58 +0530 Subject: [PATCH 08/10] test: move the connect profile-field refusals to their own file cloud-connect-profile.test.ts is over the test-file size tripwire and may not grow. The two new refusal tests go to cloud-connect-profile-fields.test.ts, which mirrors the profile builders they exercise. Co-Authored-By: Claude Opus 5.5 --- .../cloud-connect-profile-fields.test.ts | 68 +++++++++++++++++++ src/__tests__/cloud-connect-profile.test.ts | 60 ---------------- 2 files changed, 68 insertions(+), 60 deletions(-) create mode 100644 src/__tests__/cloud-connect-profile-fields.test.ts diff --git a/src/__tests__/cloud-connect-profile-fields.test.ts b/src/__tests__/cloud-connect-profile-fields.test.ts new file mode 100644 index 0000000000..5f00130636 --- /dev/null +++ b/src/__tests__/cloud-connect-profile-fields.test.ts @@ -0,0 +1,68 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { resolveCloudWebDriverConnectProfile } from '../cli/connection/cloud-webdriver-profile.ts'; +import { runCliCapture } from './cli-capture.ts'; +import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; + +test('connect limrun refuses profile fields Limrun does not read', async () => { + const result = await runCliCapture( + [ + 'connect', + 'limrun', + '--platform', + 'ios', + '--provider-os-version', + '18', + '--provider-geo-location', + 'US', + '--json', + ], + { + env: { LIMRUN_API_KEY: 'lim_test_key' }, + stateDirPrefix: 'agent-device-connect-limrun-profile-fields-', + }, + ); + assert.equal(result.code, 1); + const payload = JSON.parse(result.stdout); + assert.equal(payload.error.code, 'INVALID_ARGS'); + assert.deepEqual(payload.error.details.flags, [ + '--provider-os-version', + '--provider-geo-location', + ]); +}); + +test('connect browserstack refuses AWS Device Farm flags', () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-browserstack-reject-'); + + try { + assert.throws( + () => + resolveCloudWebDriverConnectProfile({ + provider: 'browserstack', + stateDir: path.join(tempRoot, '.state'), + cwd: tempRoot, + env: {}, + flags: { + json: false, + help: false, + version: false, + platform: 'android', + device: 'Google Pixel 8', + awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project:project-a', + }, + }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details?.provider, 'browserstack'); + assert.deepEqual(error.details?.flags, ['--aws-project-arn']); + return true; + }, + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 3b00310cc7..b33c1130b3 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -326,66 +326,6 @@ test('connect limrun rejects unsupported public device and backend selections', assert.match(backend.stdout, /requires --lease-backend ios-instance/); }); -test('connect limrun refuses profile fields Limrun does not read', async () => { - const result = await runCliCapture( - [ - 'connect', - 'limrun', - '--platform', - 'ios', - '--provider-os-version', - '18', - '--provider-geo-location', - 'US', - '--json', - ], - { - env: { LIMRUN_API_KEY: 'lim_test_key' }, - stateDirPrefix: 'agent-device-connect-limrun-profile-fields-', - }, - ); - assert.equal(result.code, 1); - const payload = JSON.parse(result.stdout); - assert.equal(payload.error.code, 'INVALID_ARGS'); - assert.deepEqual(payload.error.details.flags, [ - '--provider-os-version', - '--provider-geo-location', - ]); -}); - -test('connect browserstack refuses AWS Device Farm flags', () => { - const tempRoot = mkdtempForTestSync('agent-device-connect-browserstack-reject-'); - - try { - assert.throws( - () => - resolveCloudWebDriverConnectProfile({ - provider: 'browserstack', - stateDir: path.join(tempRoot, '.state'), - cwd: tempRoot, - env: {}, - flags: { - json: false, - help: false, - version: false, - platform: 'android', - device: 'Google Pixel 8', - awsProjectArn: 'arn:aws:devicefarm:us-west-2:123:project:project-a', - }, - }), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.code, 'INVALID_ARGS'); - assert.equal(error.details?.provider, 'browserstack'); - assert.deepEqual(error.details?.flags, ['--aws-project-arn']); - return true; - }, - ); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } -}); - test('connect without remote config rejects legacy remoteConfig string profile response', async () => { const tempRoot = mkdtempForTestSync('agent-device-connect-cloud-legacy-'); const stateDir = path.join(tempRoot, '.state'); From 23867299747642dbc8d4b8b2213fcd9e214c1588 Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Mon, 5 Oct 2026 13:39:51 +0530 Subject: [PATCH 09/10] test(limrun): refuse profile fields on an attached instance too Allocate and connect against an existing instance (LIM_IOS_INSTANCE_URL and token, no API key) accept the consumed --provider-app and refuse the rest, like a created instance. Co-Authored-By: Claude Opus 5.5 --- .../cloud-connect-profile-fields.test.ts | 28 +++++++++++++++++++ src/__tests__/limrun-runtime.test.ts | 19 +++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/__tests__/cloud-connect-profile-fields.test.ts b/src/__tests__/cloud-connect-profile-fields.test.ts index 5f00130636..b1cb3ea087 100644 --- a/src/__tests__/cloud-connect-profile-fields.test.ts +++ b/src/__tests__/cloud-connect-profile-fields.test.ts @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { resolveCloudWebDriverConnectProfile } from '../cli/connection/cloud-webdriver-profile.ts'; +import { resolveLimrunConnectProfile } from '../cli/connection/limrun-profile.ts'; import { runCliCapture } from './cli-capture.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; @@ -34,6 +35,33 @@ test('connect limrun refuses profile fields Limrun does not read', async () => { ]); }); +test('connect limrun applies the same refusal when attaching to an existing instance', () => { + const tempRoot = mkdtempForTestSync('agent-device-connect-limrun-attach-profile-fields-'); + const connect = (flags: Record) => + resolveLimrunConnectProfile({ + stateDir: path.join(tempRoot, '.state'), + cwd: tempRoot, + env: { + LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', + LIM_IOS_INSTANCE_TOKEN: 'ios-instance-token', + }, + flags: { json: false, help: false, version: false, platform: 'ios', ...flags }, + }); + + try { + assert.equal(connect({ providerApp: 'Example.ipa' }).flags.providerApp, 'Example.ipa'); + assert.throws( + () => connect({ providerOsVersion: '18.0' }), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + JSON.stringify(error.details?.flags) === '["--provider-os-version"]', + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('connect browserstack refuses AWS Device Farm flags', () => { const tempRoot = mkdtempForTestSync('agent-device-connect-browserstack-reject-'); diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 16ef1d32b1..b90f8e5427 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -973,3 +973,22 @@ test('Limrun without an API key refuses operations that need one', async () => { } assert.throws(() => new LimrunRuntime({}), /requires an apiKey or instance access/); }); + +test('Limrun attaches an existing instance under a consumed field and refuses a refused one', async () => { + const runtime = new LimrunRuntime({ instances: { ios: ATTACHED_IOS } }); + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + try { + await assert.rejects( + allocateLease(iosLease('lease-refused'), { flags: { providerOsVersion: '18.0' } }), + (error: unknown) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); + assert.equal(vi.mocked(createIosInstanceClient).mock.calls.length, 0); + const ios = await allocateLease(iosLease('lease-attached-ios'), { + flags: { providerApp: 'Example.ipa' }, + }); + assert.match(String(ios?.limrunInstanceId), /^attached-[a-f0-9]{12}$/); + } finally { + await runtime.shutdown(); + } +}); From 3d27e717b3b4ac43868c155741490dbf6aa07028 Mon Sep 17 00:00:00 2001 From: amankansal-lt Date: Mon, 5 Oct 2026 15:20:04 +0530 Subject: [PATCH 10/10] docs(daemon): keep the gone-requester doc comment on its function Co-Authored-By: Claude Opus 5.5 --- src/daemon/handlers/lease.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 9561894c7f..ff12cc5e71 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -189,13 +189,6 @@ async function releaseLease( return { registryReleased: leaseRegistry.releaseLease(request).released, provider }; } -/** - * Releases a lease that finished allocating after its requester was gone and - * turns the outcome into the canceled-request error nobody is left to receive: - * a throwing provider release is folded into `releaseError` rather than raised, - * and the provider session counts as released only when it reported no - * warnings — otherwise the error names what an operator must stop by hand. - */ // A run's repeat allocation reuses its live lease; refusing that request must not end the // lease, or the provider session the first allocation created is left without an owner. // A requester that hung up owns nothing, so its canceled repeat allocation still releases. @@ -215,6 +208,13 @@ async function settleFailedAllocation( } } +/** + * Releases a lease that finished allocating after its requester was gone and + * turns the outcome into the canceled-request error nobody is left to receive: + * a throwing provider release is folded into `releaseError` rather than raised, + * and the provider session counts as released only when it reported no + * warnings — otherwise the error names what an operator must stop by hand. + */ async function releaseAllocationForGoneRequester( lease: DeviceLease, leaseLifecycleProvider: LeaseLifecycleProvider | undefined,