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..4ae0057c68 --- /dev/null +++ b/packages/command-registry/src/__tests__/provider-profile-field-flags.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'vitest'; +import type { ProviderProfileField } from '@agent-device/contracts/provider-profile-fields'; +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 +// 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); + } +}); + +// 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/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/facades/remote.ts b/packages/contracts/src/facades/remote.ts index 852c8272cb..277da93e58 100644 --- a/packages/contracts/src/facades/remote.ts +++ b/packages/contracts/src/facades/remote.ts @@ -14,7 +14,11 @@ export type { ProviderConnectionResource, ProviderConnectionVerification, } from '../provider-connection.ts'; -export { PROVIDER_DEVICE_ORIENTATIONS } from '../remote-config-fields.ts'; +export { + PROVIDER_DEVICE_ORIENTATIONS, + PROVIDER_PROFILE_FIELD_FLAG_ALIASES, + PROVIDER_PROFILE_FIELD_FLAGS, +} from '../remote-config-fields.ts'; export type { CloudProviderProfileFields, ProviderDeviceOrientation, 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..4d79c40a3e --- /dev/null +++ b/packages/contracts/src/provider-profile-fields.test.ts @@ -0,0 +1,77 @@ +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, 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 (--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, + ), + (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 new file mode 100644 index 0000000000..44d009fe72 --- /dev/null +++ b/packages/contracts/src/provider-profile-fields.ts @@ -0,0 +1,54 @@ +import { AppError } from '@agent-device/kernel/errors'; +import { + PROVIDER_PROFILE_FIELD_FLAG_ALIASES, + PROVIDER_PROFILE_FIELD_FLAGS, + 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>; +}>; + +/** + * 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]), + ); + if (refused.length === 0) return; + const plural = refused.length !== 1; + throw new AppError( + 'INVALID_ARGS', + `${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.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 5ab0b6a6b2..728927d688 100644 --- a/packages/contracts/src/remote-config-fields.ts +++ b/packages/contracts/src/remote-config-fields.ts @@ -42,6 +42,46 @@ 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', +}; + +/** 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; 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..3469d3d731 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -257,6 +257,10 @@ 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 }; 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..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. * @@ -130,47 +128,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..1ff12c136f 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 { @@ -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 713fd7d8ce..0cd3d63d68 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,9 +50,75 @@ 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; - createRuntime: (env: DefaultCloudWebDriverProviderRuntimeEnv) => CloudWebDriverRuntime; + /** Every profile field, consumed or refused; lease allocation refuses the refused ones. */ + profileFields: ProviderProfileFieldDeclaration; + /** Receives `profileFields`, which the runtime requires, so the two cannot drift apart. */ + createRuntime: ( + env: DefaultCloudWebDriverProviderRuntimeEnv, + profileFields: ProviderProfileFieldDeclaration, + ) => CloudWebDriverRuntime; listArtifactsFromEnv: ( providerSessionId: string, env: DefaultCloudWebDriverArtifactEnv, @@ -65,10 +131,12 @@ export function createCloudWebDriverProviderDefinitions( return [ { provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, - createRuntime: (env) => + profileFields: BROWSERSTACK_PROFILE_FIELDS, + createRuntime: (env, profileFields) => createCloudWebDriverRuntime({ clientVersion: dependencies.clientVersion, provider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + profileFields, platform: 'android', deviceName: 'BrowserStack device', endpoint: env.BROWSERSTACK_WEBDRIVER_ENDPOINT ?? BROWSERSTACK_APP_AUTOMATE_ENDPOINT, @@ -172,10 +240,12 @@ export function createCloudWebDriverProviderDefinitions( }, { provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, - createRuntime: (env) => + profileFields: AWS_DEVICE_FARM_PROFILE_FIELDS, + createRuntime: (env, profileFields) => createCloudWebDriverRuntime({ clientVersion: dependencies.clientVersion, provider: CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + profileFields, endpoint: 'http://127.0.0.1/', platform: 'android', deviceName: 'AWS Device Farm device', @@ -192,13 +262,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.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 cce3f7ca86..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({ 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 3ee5aab125..4b94ac2d72 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 lease allocation. */ + 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-fields.test.ts b/src/__tests__/cloud-connect-profile-fields.test.ts new file mode 100644 index 0000000000..b1cb3ea087 --- /dev/null +++ b/src/__tests__/cloud-connect-profile-fields.test.ts @@ -0,0 +1,96 @@ +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 { resolveLimrunConnectProfile } from '../cli/connection/limrun-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 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-'); + + 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 fd25bb2551..b33c1130b3 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -810,7 +810,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 { @@ -836,8 +836,7 @@ test('connect aws-device-farm rejects BrowserStack-only device-feature flags', ( 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, /only supported by BrowserStack, not 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 new file mode 100644 index 0000000000..6409fbb95a --- /dev/null +++ b/src/__tests__/lease-provider-profile-fields.test.ts @@ -0,0 +1,128 @@ +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' && + JSON.stringify(error.details?.flags) === '["--provider-os-version"]', + ); + 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'); + 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 2d4141d1f8..b90f8e5427 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'; @@ -177,6 +178,35 @@ 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' } }), + (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 { + await runtime.shutdown(); + } +}); + test('Limrun iOS uses shared deep-link classification', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); @@ -943,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(); + } +}); 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/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index 28346814a9..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, @@ -175,3 +179,94 @@ 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], + ); + 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 0aaac48f63..ff12cc5e71 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -70,7 +70,11 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise entry.leaseId), + ); const lease = leaseRegistry.allocateLease(leaseScopeToAllocateRequest(leaseScope)); + const reused = activeLeaseIds.has(lease.leaseId); const requestId = req.meta?.requestId; return await leaseRegistry.runDeviceMutation(lease, async () => { let providerData: Record | undefined; @@ -87,7 +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); + } +} + /** * 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: 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..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, @@ -142,7 +143,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({ @@ -170,10 +171,12 @@ 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/, - ); + assert.ok(error instanceof AppError); + assert.equal(error.code, 'INVALID_ARGS'); + assert.deepEqual(error.details?.flags, [ + '--provider-device-orientation', + '--provider-network-profile', + ]); return true; }, ); @@ -185,6 +188,48 @@ test('AWS Device Farm facade rejects BrowserStack-owned device features at sessi }); }, 15_000); +test('BrowserStack refuses a refused field on a repeat allocation of its live lease', async () => { + 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', + }, + }), + (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), { + 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/`); 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.