From 5e1658afe63916a00468bb1426d421da518be9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 21:09:02 +0200 Subject: [PATCH 01/11] feat: open Limrun uploaded apps --- .../application-lifecycle-interaction.test.ts | 33 ++++- .../src/application-lifecycle-interaction.ts | 50 +++++-- packages/contracts/src/device-provider.ts | 10 ++ packages/contracts/src/facades/device.ts | 2 + .../contracts/src/provider-device-runtime.ts | 2 + .../provider-limrun/src/app-catalog.test.ts | 68 +++++++++ packages/provider-limrun/src/app-catalog.ts | 99 +++++++++++++ .../provider-limrun/src/app-log-runtime.ts | 2 + .../provider-limrun/src/app-preinstall.ts | 37 +++++ .../src/connection-verification.test.ts | 2 +- .../src/connection-verification.ts | 2 +- packages/provider-limrun/src/lifecycle.ts | 2 + .../src/runtime-dependencies.test.ts | 104 +++++++++++-- packages/provider-limrun/src/runtime.ts | 138 ++++++++---------- .../provider-limrun/src/session-allocation.ts | 116 +++++++++++++++ src/__tests__/cli-config-limrun.test.ts | 111 ++++++++++++++ src/__tests__/cloud-connect-profile.test.ts | 9 +- src/cli-schema/cli-help-topics.test.ts | 3 +- src/cli-schema/cli-help.ts | 7 +- src/cli/commands/connection-presentation.ts | 13 ++ src/cli/commands/connection-runtime.ts | 15 ++ src/commands/management/app.ts | 5 +- src/commands/management/output.ts | 4 +- .../daemon-command-descriptor.ts | 12 +- src/core/command-descriptor/registry.ts | 8 +- .../__tests__/daemon-command-registry.test.ts | 15 ++ .../__tests__/request-handler-chain.test.ts | 11 ++ src/daemon/daemon-command-registry.ts | 5 +- .../session-inventory-apps-runtime.test.ts | 75 ++++++++++ src/daemon/handlers/session-inventory.ts | 31 ++++ src/daemon/handlers/session.ts | 7 +- src/daemon/request-admission.ts | 16 +- src/daemon/request-handler-chain.ts | 4 +- src/daemon/request-router.ts | 5 +- src/daemon/server/daemon-runtime.ts | 2 + src/provider-device-runtime.ts | 17 +++ 36 files changed, 892 insertions(+), 150 deletions(-) create mode 100644 packages/provider-limrun/src/app-catalog.test.ts create mode 100644 packages/provider-limrun/src/app-catalog.ts create mode 100644 packages/provider-limrun/src/app-preinstall.ts create mode 100644 packages/provider-limrun/src/session-allocation.ts create mode 100644 src/__tests__/cli-config-limrun.test.ts diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index 2e42310f7c..6627c4d139 100644 --- a/packages/contracts/src/application-lifecycle-interaction.test.ts +++ b/packages/contracts/src/application-lifecycle-interaction.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor } from './interactor-types.ts'; import type { OpenApplicationInput } from './application-lifecycle-runtime.ts'; @@ -91,6 +91,37 @@ test('direct lifecycle owners preserve the daemon runtime launch URL follow-up', expect(calls[1]?.options).toHaveProperty('launchArgs', undefined); }); +test('direct lifecycle owners resolve app aliases once before target dispatch', async () => { + const open = vi.fn(async () => undefined); + const binding = bindLocalApplicationLifecycleInteractor({ + device: WEB_DEVICE, + signal: new AbortController().signal, + resolveInteractor: async () => interactorWithOpen(open), + }); + const lifecycle = bindDirectApplicationLifecycle({ + binding, + owner: 'Provider', + openTargetIdentity: 'bundle-id', + resolveAppAlias: (app) => (app === 'Example.app.zip' ? 'com.example.app' : app), + }); + + await expect( + lifecycle.resolveOpenTarget({ target: 'Example.app.zip', surface: 'app' }), + ).resolves.toEqual({ appBundleId: 'com.example.app', appName: 'com.example.app' }); + await lifecycle.openApplication( + openInput({ + target: 'Example.app.zip', + positionals: ['Example.app.zip'], + appBundleId: 'Example.app.zip', + }), + ); + + expect(open).toHaveBeenCalledWith( + 'com.example.app', + expect.objectContaining({ appBundleId: 'com.example.app' }), + ); +}); + test.each([ { name: 'more than two positionals', diff --git a/packages/contracts/src/application-lifecycle-interaction.ts b/packages/contracts/src/application-lifecycle-interaction.ts index 0db4ce162f..7b277bb778 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -262,6 +262,7 @@ export type DirectApplicationLifecycleParams = Readonly<{ openTargetIdentity: DirectOpenTargetIdentity; /** Owners whose native open does not replace a running application close it first. */ closeBeforeRelaunch?: boolean; + resolveAppAlias?(app: string): string; /** Port reverse is the one non-direct operation a provider owner may still implement. */ configureProviderPortReverse?: ApplicationLifecycleRuntimeOperations['configureProviderPortReverse']; }>; @@ -282,7 +283,8 @@ export function bindDirectApplicationLifecycle( ); }; return Object.freeze({ - resolveOpenTarget: async (input) => resolveDirectOpenTarget(params.openTargetIdentity, input), + resolveOpenTarget: async (input) => + resolveDirectOpenTarget(params.openTargetIdentity, resolveOpenTargetAlias(params, input)), prepareApplicationOpen: async () => undefined, openApplication: async (input) => await openDirectApplication(params, input), applyRuntimeHints: unavailable, @@ -304,38 +306,64 @@ async function openDirectApplication( params: DirectApplicationLifecycleParams, input: OpenApplicationInput, ): Promise { + const resolvedInput = resolveOpenApplicationAliases(params, input); const { binding } = params; - const interactor = await binding.resolveInteractor(input.execution, input.appBundleId); - if (params.closeBeforeRelaunch && input.relaunch && input.target !== undefined) { + const interactor = await binding.resolveInteractor( + resolvedInput.execution, + resolvedInput.appBundleId, + ); + if (params.closeBeforeRelaunch && resolvedInput.relaunch && resolvedInput.target !== undefined) { await invokeApplicationClose({ device: binding.device, interactor, - positionals: [input.appBundleId ?? input.target], + positionals: [resolvedInput.appBundleId ?? resolvedInput.target], }); } await invokeApplicationOpen({ device: binding.device, interactor, - positionals: input.positionals, - appBundleId: input.appBundleId, - execution: input.execution, + positionals: resolvedInput.positionals, + appBundleId: resolvedInput.appBundleId, + execution: resolvedInput.execution, }); - const followUpUrl = followUpRuntimeLaunchUrl(input); + const followUpUrl = followUpRuntimeLaunchUrl(resolvedInput); if (followUpUrl) { await invokeApplicationOpen({ device: binding.device, interactor, positionals: [followUpUrl], - appBundleId: input.appBundleId, + appBundleId: resolvedInput.appBundleId, execution: { - ...input.execution, + ...resolvedInput.execution, clearAppState: undefined, launchConsole: undefined, launchArgs: undefined, }, }); } - return { appBundleId: input.appBundleId, timing: {} }; + return { appBundleId: resolvedInput.appBundleId, timing: {} }; +} + +function resolveOpenTargetAlias( + params: DirectApplicationLifecycleParams, + input: OpenTargetResolutionInput, +): OpenTargetResolutionInput { + if (!input.target || !params.resolveAppAlias) return input; + return { ...input, target: params.resolveAppAlias(input.target) }; +} + +function resolveOpenApplicationAliases( + params: DirectApplicationLifecycleParams, + input: OpenApplicationInput, +): OpenApplicationInput { + const resolve = params.resolveAppAlias; + if (!resolve) return input; + return { + ...input, + target: input.target ? resolve(input.target) : undefined, + positionals: input.positionals.map((value, index) => (index === 0 ? resolve(value) : value)), + appBundleId: input.appBundleId ? resolve(input.appBundleId) : undefined, + }; } export function followUpRuntimeLaunchUrl(input: OpenApplicationInput): string | undefined { diff --git a/packages/contracts/src/device-provider.ts b/packages/contracts/src/device-provider.ts index 4cf6c88e2a..034ae34aa1 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -66,3 +66,13 @@ export type ProviderDeviceInventorySource = Readonly<{ signal: AbortSignal, ): Promise; }>; + +export type ProviderAppCatalogQuery = Readonly<{ + provider: string; + platform: 'android' | 'ios'; +}>; + +export type ProviderAppCatalog = ( + query: ProviderAppCatalogQuery, + signal?: AbortSignal, +) => Promise; diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index d8f9d7047e..8d70558c44 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -24,6 +24,8 @@ export type { DeviceLease, LeaseLifecycleContext, LeaseLifecycleProvider, + ProviderAppCatalog, + ProviderAppCatalogQuery, ProviderDeviceInventoryOutcome, ProviderDeviceInventorySource, } from '../device-provider.ts'; diff --git a/packages/contracts/src/provider-device-runtime.ts b/packages/contracts/src/provider-device-runtime.ts index 755110272c..838a098fc9 100644 --- a/packages/contracts/src/provider-device-runtime.ts +++ b/packages/contracts/src/provider-device-runtime.ts @@ -4,6 +4,7 @@ import type { DeviceInventoryProvider, DeviceLease, LeaseLifecycleProvider, + ProviderAppCatalog, } from './device-provider.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; @@ -36,6 +37,7 @@ export type ProviderDeviceRuntime = { leaseLifecycle: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifacts?: CloudArtifactProvider; + appCatalog?: ProviderAppCatalog; deviceInventoryProvider: DeviceInventoryProvider; ownsDevice(device: DeviceInfo): boolean; getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor | undefined; diff --git a/packages/provider-limrun/src/app-catalog.test.ts b/packages/provider-limrun/src/app-catalog.test.ts new file mode 100644 index 0000000000..6685274847 --- /dev/null +++ b/packages/provider-limrun/src/app-catalog.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + listLimrunAppAssets, + resolveInstalledAppIdForAsset, + resolveLimrunAppAsset, +} from './app-catalog.ts'; + +describe('Limrun uploaded app catalog', () => { + test('lists only uploaded assets compatible with the requested platform', async () => { + const list = vi.fn(async () => [ + { id: 'android-explicit', name: 'build.bin', os: 'android', md5: 'a' }, + { id: 'android-apk', name: 'com.example.app.apk', md5: 'b' }, + { id: 'ios-zip', name: 'Example.app.zip', md5: 'c' }, + { id: 'pending', name: 'pending.apk' }, + { id: 'unknown', name: 'notes.txt', md5: 'd' }, + ]); + const limrun = { assets: { list } } as never; + + await expect(listLimrunAppAssets(limrun, 'android')).resolves.toEqual([ + { id: 'android-explicit', name: 'build.bin' }, + { id: 'android-apk', name: 'com.example.app.apk' }, + ]); + await expect(listLimrunAppAssets(limrun, 'ios')).resolves.toEqual([ + { id: 'ios-zip', name: 'Example.app.zip' }, + ]); + }); + + test('resolves an exact uploaded asset name and rejects platform mismatches', async () => { + const list = vi + .fn() + .mockResolvedValueOnce([ + { id: 'similar', name: 'Example.app.zip.backup.zip', md5: 'z' }, + { id: 'ios-app', name: 'Example.app.zip', md5: 'a' }, + ]) + .mockResolvedValueOnce([{ id: 'android-app', name: 'Example.apk', md5: 'b' }]); + const limrun = { assets: { list } } as never; + + await expect(resolveLimrunAppAsset(limrun, 'ios', 'Example.app.zip')).resolves.toEqual({ + id: 'ios-app', + name: 'Example.app.zip', + }); + await expect(resolveLimrunAppAsset(limrun, 'ios', 'Example.apk')).resolves.toBeUndefined(); + }); + + test('matches an uploaded iOS asset when the instance also contains Expo Go', () => { + expect( + resolveInstalledAppIdForAsset('easagentdevice.app.zip', [ + { id: 'dev.expo.easagentdevice', name: 'Agent Device' }, + { id: 'host.exp.Exponent', name: 'Expo Go' }, + ]), + ).toBe('dev.expo.easagentdevice'); + expect( + resolveInstalledAppIdForAsset('unrelated-build.zip', [ + { id: 'com.example.first' }, + { id: 'com.example.second' }, + ]), + ).toBeUndefined(); + }); + + test('rejects colliding exact installed identities', () => { + expect( + resolveInstalledAppIdForAsset('example.app.zip', [ + { id: 'com.first', name: 'Example' }, + { id: 'com.second.example', name: 'Second' }, + ]), + ).toBeUndefined(); + }); +}); diff --git a/packages/provider-limrun/src/app-catalog.ts b/packages/provider-limrun/src/app-catalog.ts new file mode 100644 index 0000000000..2f6c097dbc --- /dev/null +++ b/packages/provider-limrun/src/app-catalog.ts @@ -0,0 +1,99 @@ +import type Limrun from '@limrun/api'; +import type { Asset } from '@limrun/api/resources/assets'; +import { AppError } from '@agent-device/kernel/errors'; + +const APP_CATALOG_LIMIT = 1_000; + +export type LimrunAppAsset = Readonly<{ + id: string; + name: string; +}>; + +type InstalledAppIdentity = Readonly<{ id: string; name?: string }>; + +export async function listLimrunAppAssets( + limrun: Limrun, + platform: 'android' | 'ios', + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const assets = await limrun.assets.list({ limit: APP_CATALOG_LIMIT }, { signal }); + signal?.throwIfAborted(); + const apps: LimrunAppAsset[] = []; + for (const asset of assets) { + const app = toAvailableAppAsset(asset, platform); + if (app) apps.push(app); + } + return apps.sort((left, right) => left.name.localeCompare(right.name)); +} + +export async function resolveLimrunAppAsset( + limrun: Limrun, + platform: 'android' | 'ios', + name: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const assets = await limrun.assets.list({ limit: 2, nameFilter: name }, { signal }); + signal?.throwIfAborted(); + const matches = assets + .filter((asset) => asset.name === name) + .map((asset) => toAvailableAppAsset(asset, platform)) + .filter((asset): asset is LimrunAppAsset => asset !== undefined); + if (matches.length <= 1) return matches[0]; + throw new AppError('COMMAND_FAILED', `Limrun returned multiple uploaded apps named ${name}.`, { + app: name, + platform, + assetIds: matches.map((asset) => asset.id), + }); +} + +export function resolveInstalledAppIdForAsset( + assetName: string, + apps: readonly InstalledAppIdentity[], +): string | undefined { + const assetKey = appIdentityKey( + assetName.replace(/\.(?:tar\.gz|tgz|tar|zip|ipa|apk)$/i, '').replace(/\.app$/i, ''), + ); + if (assetKey.length < 5) return undefined; + const candidates = apps.map((app) => ({ app, keys: appIdentityValues(app) })); + const exact = candidates.filter(({ keys }) => keys.includes(assetKey)); + return exact.length === 1 ? exact[0]?.app.id : undefined; +} + +function toAvailableAppAsset( + asset: Asset, + requestedPlatform: 'android' | 'ios', +): LimrunAppAsset | undefined { + if (!asset.md5) return undefined; + const platform = resolveAssetPlatform(asset); + if (platform !== requestedPlatform) return undefined; + return { id: asset.id, name: asset.name }; +} + +function resolveAssetPlatform(asset: Asset): 'android' | 'ios' | undefined { + if (asset.os === 'android' || asset.os === 'ios') return asset.os; + const name = asset.name.toLowerCase(); + if (name.endsWith('.apk')) return 'android'; + if ( + name.endsWith('.ipa') || + name.endsWith('.zip') || + name.endsWith('.tar') || + name.endsWith('.tar.gz') || + name.endsWith('.tgz') + ) { + return 'ios'; + } + return undefined; +} + +function appIdentityValues(app: InstalledAppIdentity): string[] { + const terminalId = app.id.split(/[.:/]/).at(-1); + return [app.id, terminalId, app.name] + .filter((value): value is string => typeof value === 'string') + .map(appIdentityKey); +} + +function appIdentityKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ''); +} diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 0ffc696d47..b1a2ed8afb 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -59,6 +59,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit< runtimeInstance: string; ownsDevice(device: DeviceInfo): boolean; getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; + resolveAppAlias?(device: DeviceInfo, app: string): string; openCurrent(device: DeviceInfo): Promise; hasLiveSession(device: DeviceInfo): boolean; reconnect( @@ -280,6 +281,7 @@ function bindLimrunAppLogs( device, signal, getInteractor: options.getInteractor, + resolveAppAlias: (app) => options.resolveAppAlias?.(device, app) ?? app, configurePortReverse: options.configurePortReverse, }), runtimeFacts.operations, diff --git a/packages/provider-limrun/src/app-preinstall.ts b/packages/provider-limrun/src/app-preinstall.ts new file mode 100644 index 0000000000..02475629ec --- /dev/null +++ b/packages/provider-limrun/src/app-preinstall.ts @@ -0,0 +1,37 @@ +import type Limrun from '@limrun/api'; +import type { LeaseLifecycleContext } from '@agent-device/contracts/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { LimrunAndroidSession } from './android.ts'; +import { + resolveInstalledAppIdForAsset, + resolveLimrunAppAsset, + type LimrunAppAsset, +} from './app-catalog.ts'; +import { createLimrunDeviceSession } from './device-session.ts'; +import type { LimrunIosSession } from './ios.ts'; + +export async function resolveRequestedLimrunAppAsset( + limrun: Limrun, + platform: 'android' | 'ios', + context?: LeaseLifecycleContext, +): Promise { + const value = context?.flags?.providerApp; + const name = typeof value === 'string' ? value.trim() : ''; + if (!name) return undefined; + return await resolveLimrunAppAsset(limrun, platform, name, context?.signal); +} + +export async function resolvePreinstalledAppId( + session: LimrunAndroidSession | LimrunIosSession, + asset: LimrunAppAsset, +): Promise { + const deviceSession = createLimrunDeviceSession(session); + const apps = await deviceSession.listApps('user-installed'); + const matchedAppId = resolveInstalledAppIdForAsset(asset.name, apps); + if (matchedAppId) return matchedAppId; + throw new AppError( + 'COMMAND_FAILED', + `Limrun installed ${asset.name}, but its application identifier could not be resolved unambiguously.`, + { asset: asset.name, installedApps: apps.map((app) => app.id) }, + ); +} diff --git a/packages/provider-limrun/src/connection-verification.test.ts b/packages/provider-limrun/src/connection-verification.test.ts index 46feaaeda0..24950f2a78 100644 --- a/packages/provider-limrun/src/connection-verification.test.ts +++ b/packages/provider-limrun/src/connection-verification.test.ts @@ -42,7 +42,7 @@ test('Limrun verification reads the selected instance service without creating a }, app: { status: 'missing', - message: 'A new Limrun instance does not have your app yet.', + message: 'Run apps to choose an uploaded asset before allocation.', }, }); assert.deepEqual(mockState.androidList.mock.calls, [[{ limit: 1 }]]); diff --git a/packages/provider-limrun/src/connection-verification.ts b/packages/provider-limrun/src/connection-verification.ts index 426708a0e7..b277d1cddd 100644 --- a/packages/provider-limrun/src/connection-verification.ts +++ b/packages/provider-limrun/src/connection-verification.ts @@ -67,7 +67,7 @@ export async function verifyLimrunConnection( }, app: { status: 'missing', - message: 'A new Limrun instance does not have your app yet.', + message: 'Run apps to choose an uploaded asset before allocation.', }, }; } diff --git a/packages/provider-limrun/src/lifecycle.ts b/packages/provider-limrun/src/lifecycle.ts index 67dfe0d24d..b8995d6c1c 100644 --- a/packages/provider-limrun/src/lifecycle.ts +++ b/packages/provider-limrun/src/lifecycle.ts @@ -14,6 +14,7 @@ type LimrunLifecycleParams = Readonly<{ configurePortReverse( options: ProviderPortReverseOptions, ): Promise | undefined>; + resolveAppAlias?(app: string): string; }>; /** Limrun owns its live-session lifecycle, relaunch, and exact port-reverse mechanics. */ @@ -24,6 +25,7 @@ export function bindLimrunApplicationLifecycle( owner: 'Limrun', openTargetIdentity: 'bundle-id', closeBeforeRelaunch: true, + resolveAppAlias: params.resolveAppAlias, configureProviderPortReverse: async (input) => await params.configurePortReverse(input), binding: bindProviderApplicationLifecycleInteractor({ device: params.device, diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index 5b705153f7..4a22879d38 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -14,6 +14,7 @@ import type { const state = vi.hoisted(() => ({ constructorOptions: [] as Array<{ defaultHeaders?: Record }>, + androidCreateInputs: [] as unknown[], tunnelClose: vi.fn(), disconnect: vi.fn(), })); @@ -27,20 +28,31 @@ vi.mock('@limrun/api', () => ({ }; readonly androidInstances = { - create: vi.fn(async () => ({ - metadata: { id: 'android-instance-1' }, - status: { - token: 'instance-token', - apiUrl: 'https://android.example', - adbWebSocketUrl: 'wss://adb.example', - }, - })), + create: vi.fn(async (input: unknown) => { + state.androidCreateInputs.push(input); + return { + metadata: { id: 'android-instance-1' }, + status: { + token: 'instance-token', + apiUrl: 'https://android.example', + adbWebSocketUrl: 'wss://adb.example', + }, + }; + }), list: vi.fn(), delete: vi.fn(async () => undefined), }; readonly assets = { getOrUpload: vi.fn(), + list: vi.fn(async () => [ + { + id: 'asset-example', + name: 'Example.apk', + md5: 'uploaded', + os: 'android', + }, + ]), }; constructor(options: { defaultHeaders?: Record }) { @@ -108,6 +120,63 @@ test('factory uses the injected Android and host adapters as its construction se assert.equal(state.tunnelClose.mock.calls.length, 1); }); +test('allocation installs an exact uploaded asset before binding its application id', async () => { + state.androidCreateInputs.length = 0; + const fixture = createContractFixture(); + const runtime = createLimrunRuntime({ apiKey: 'lim_test_key' }, fixture.dependencies); + + try { + await runtime.leaseLifecycle.allocate?.(androidLease(), { + flags: { providerApp: 'Example.apk' }, + }); + + assert.deepEqual(state.androidCreateInputs[0], { + wait: true, + metadata: { + displayName: 'agent-device-team-a-run-a', + labels: { + source: 'agent-device-cli', + provider: 'limrun', + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + }, + }, + spec: { + initialAssets: [ + { + kind: 'App', + source: 'AssetIDs', + assetIds: ['asset-example'], + }, + ], + }, + }); + assert.equal(fixture.listApps.mock.calls[0]?.[1], 'user-installed'); + } finally { + await runtime.shutdown(); + } +}); + +test('allocation rejects an unrelated foreground app after preinstall', async () => { + const fixture = createContractFixture(); + fixture.listApps.mockResolvedValueOnce([{ id: 'com.foreground.app', name: 'Foreground' }]); + fixture.getForegroundApp.mockResolvedValueOnce({ + appId: 'com.foreground.app', + activity: '.MainActivity', + }); + const runtime = createLimrunRuntime({ apiKey: 'lim_test_key' }, fixture.dependencies); + + await assert.rejects( + async () => + await runtime.leaseLifecycle.allocate?.(androidLease(), { + flags: { providerApp: 'Example.apk' }, + }), + (error) => error instanceof AppError && error.code === 'COMMAND_FAILED', + ); + assert.equal(fixture.getForegroundApp.mock.calls.length, 0); +}); + function createContractFixture() { const adbCalls: string[][] = []; const activeReverseMappings: LimrunPortReverseMapping[] = []; @@ -120,6 +189,10 @@ function createContractFixture() { visible: false, inputOwner: 'unknown' as const, })); + const getForegroundApp = vi.fn(async () => ({ + appId: 'com.example.app', + activity: '.MainActivity', + })); const dependencies = { clientVersion: 'test-version', android: { @@ -128,10 +201,7 @@ function createContractFixture() { createInMemoryPortReverse(adb, activeReverseMappings), inferAppName: async () => 'Example', listApps, - getForegroundApp: async () => ({ - appId: 'com.example.app', - activity: '.MainActivity', - }), + getForegroundApp, getKeyboardState, dismissKeyboard: async () => ({ visible: false, @@ -155,7 +225,15 @@ function createContractFixture() { readBundleAppName: async () => undefined, }, } satisfies LimrunRuntimeDependencies; - return { adbCalls, createInteractor, dependencies, getKeyboardState, interactor, listApps }; + return { + adbCalls, + createInteractor, + dependencies, + getForegroundApp, + getKeyboardState, + interactor, + listApps, + }; } function createInMemoryPortReverse(adb: LimrunAdbExecutor, mappings: LimrunPortReverseMapping[]) { diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 3b0cc201d6..79f0ba36f3 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -4,6 +4,8 @@ import type { DeviceInventoryProvider, DeviceLease, LeaseLifecycleProvider, + LeaseLifecycleContext, + ProviderAppCatalog, ProviderDeviceInstallOptions, ProviderDeviceInstallResult, ProviderDeviceRuntime, @@ -16,22 +18,11 @@ import { cleanupLimrunAndroidAdbTunnel, configureLimrunAndroidPortReverse, createLimrunAndroidInteractor, - createLimrunAndroidSession, installLimrunAndroidApp, type LimrunAndroidSession, } from './android.ts'; -import { - buildLimrunDevice, - LIMRUN_PROVIDER, - parseLimrunDeviceId, - platformForLimrunLeaseBackend, -} from './device.ts'; -import { - createLimrunIosInteractor, - createLimrunIosSession, - installLimrunIosApp, - type LimrunIosSession, -} from './ios.ts'; +import { LIMRUN_PROVIDER, parseLimrunDeviceId, platformForLimrunLeaseBackend } from './device.ts'; +import { createLimrunIosInteractor, installLimrunIosApp, type LimrunIosSession } from './ios.ts'; import { createLimrunDeviceSession, type LimrunDeviceSession } from './device-session.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; import type { @@ -45,15 +36,7 @@ import type { LimrunAppLogReader } from './app-log-poller.ts'; import { buildLimrunClientOptions, LIMRUN_CLIENT_HEADER } from './client-options.ts'; import { resolveLimrunRuntimeInstance } from './runtime-instance.ts'; import type { LimrunRequestOperationDrain } from './request-cancellation.ts'; - -type LimrunInstance = { - metadata: { id: string }; - status: { - token: string; - apiUrl?: string; - adbWebSocketUrl?: string; - }; -}; +import type { LimrunAppAsset } from './app-catalog.ts'; type LimrunRuntimeSession = LimrunIosSession | LimrunAndroidSession; @@ -104,15 +87,27 @@ export function createLimrunRuntime( class LimrunRuntimeImplementation implements ProviderDeviceRuntime { private readonly limrun: Limrun; private readonly sessions = new Map(); + private readonly appAliases = new Map< + string, + Readonly<{ assetName: string; installedAppId: string }> + >(); private readonly options: LimrunRuntimeOptions; private readonly dependencies: LimrunRuntimeDependencies; readonly provider = LIMRUN_PROVIDER; readonly leaseLifecycle: LeaseLifecycleProvider = { - allocate: async (lease) => await this.allocate(lease), + allocate: async (lease, context) => await this.allocate(lease, context), release: async (lease) => await this.release(lease), }; + readonly appCatalog: ProviderAppCatalog = async (query, signal) => { + if (query.provider !== this.provider) return undefined; + const { listLimrunAppAssets } = await import('./app-catalog.ts'); + return (await listLimrunAppAssets(this.limrun, query.platform, signal)).map( + (asset) => asset.name, + ); + }; + readonly recoverExpiredLease: ProviderExpiredLeaseRecovery = async (lease) => { if (lease.leaseProvider !== this.provider || !platformForLimrunLeaseBackend(lease.backend)) { throw new AppError('UNSUPPORTED_OPERATION', 'Limrun cannot recover this expired lease.', { @@ -226,77 +221,53 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const sessions = [...this.sessions.values()]; await Promise.allSettled(sessions.map(async (session) => await this.terminateSession(session))); this.sessions.clear(); + this.appAliases.clear(); } - private async allocate(lease: DeviceLease): Promise | undefined> { + private async allocate( + lease: DeviceLease, + context?: LeaseLifecycleContext, + ): Promise | undefined> { if (lease.leaseProvider !== this.provider) return undefined; const platform = platformForLimrunLeaseBackend(lease.backend); if (!platform) return undefined; const existing = this.sessions.get(lease.leaseId); if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; + const { resolvePreinstalledAppId, resolveRequestedLimrunAppAsset } = + await import('./app-preinstall.ts'); + const { allocateLimrunAndroidSession, allocateLimrunIosSession } = + await import('./session-allocation.ts'); + const requestedAsset = await resolveRequestedLimrunAppAsset(this.limrun, platform, context); const session = platform === 'ios' - ? await this.createIosSession(lease) - : await this.createAndroidSession(lease); - this.sessions.set(lease.leaseId, session); - return { limrunInstanceId: session.instanceId, device: session.device }; - } - - private async createIosSession(lease: DeviceLease): Promise { - const instance = (await this.limrun.iosInstances.create({ - wait: true, - metadata: this.buildInstanceMetadata(lease), - spec: this.options.region ? { region: this.options.region } : {}, - })) as LimrunInstance; - try { - if (!instance.status.apiUrl) { - throw new AppError('COMMAND_FAILED', 'Limrun iOS instance did not expose apiUrl'); + ? await allocateLimrunIosSession(this.sessionAllocationParams(lease, requestedAsset)) + : await allocateLimrunAndroidSession(this.sessionAllocationParams(lease, requestedAsset)); + if (requestedAsset) { + try { + const installedAppId = await resolvePreinstalledAppId(session, requestedAsset); + this.appAliases.set(lease.leaseId, { + assetName: requestedAsset.name, + installedAppId, + }); + } catch (error) { + await this.terminateSession(session); + throw error; } - return await createLimrunIosSession( - { - lease, - instanceId: instance.metadata.id, - device: buildLimrunDevice('ios', lease, instance.metadata.id), - apiUrl: instance.status.apiUrl, - token: instance.status.token, - }, - this.dependencies, - ); - } catch (error) { - await this.limrun.iosInstances.delete(instance.metadata.id).catch(() => {}); - throw error; } + this.sessions.set(lease.leaseId, session); + return { limrunInstanceId: session.instanceId, device: session.device }; } - private async createAndroidSession(lease: DeviceLease): Promise { - const instance = (await this.limrun.androidInstances.create({ - wait: true, + private sessionAllocationParams(lease: DeviceLease, app?: LimrunAppAsset) { + return { + limrun: this.limrun, + lease, metadata: this.buildInstanceMetadata(lease), - spec: this.options.region ? { region: this.options.region } : {}, - })) as LimrunInstance; - try { - if (!instance.status.apiUrl || !instance.status.adbWebSocketUrl) { - throw new AppError( - 'COMMAND_FAILED', - 'Limrun Android instance did not expose API and ADB websocket endpoints', - ); - } - return await createLimrunAndroidSession( - { - lease, - instanceId: instance.metadata.id, - device: buildLimrunDevice('android', lease, instance.metadata.id), - apiUrl: instance.status.apiUrl, - adbUrl: instance.status.adbWebSocketUrl, - token: instance.status.token, - }, - this.dependencies, - ); - } catch (error) { - await this.limrun.androidInstances.delete(instance.metadata.id).catch(() => {}); - throw error; - } + region: this.options.region, + app, + dependencies: this.dependencies, + }; } private buildInstanceMetadata(lease: DeviceLease) { @@ -317,6 +288,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { if (!session) return await this.releaseRecoveredSession(lease); await this.terminateSession(session); this.sessions.delete(lease.leaseId); + this.appAliases.delete(lease.leaseId); return { limrunInstanceId: session.instanceId }; } @@ -359,6 +331,13 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { return session?.platform === parsed.platform ? session : undefined; } + resolveAppAlias(device: DeviceInfo, app: string): string { + const parsed = parseLimrunDeviceId(device.id); + if (!parsed) return app; + const alias = this.appAliases.get(parsed.leaseId); + return alias?.assetName === app ? alias.installedAppId : app; + } + currentAppLogReader(device: DeviceInfo): LimrunAppLogReader | undefined { const session = this.getSessionForDevice(device); if (!session) return undefined; @@ -407,6 +386,7 @@ async function loadLimrunPlatformRuntime( ownsDevice: (device) => runtime.ownsDevice(device), hasLiveSession: (device) => runtime.hasLiveSession(device), getInteractor: (device, runner) => runtime.getInteractor(device, runner), + resolveAppAlias: (device, app) => runtime.resolveAppAlias(device, app), openCurrent: async (device) => runtime.currentAppLogReader(device), reconnect: async (descriptor, signal) => await runtime.reconnectAppLogReader(descriptor, signal), diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts new file mode 100644 index 0000000000..5df042a1d6 --- /dev/null +++ b/packages/provider-limrun/src/session-allocation.ts @@ -0,0 +1,116 @@ +import type Limrun from '@limrun/api'; +import type { DeviceLease } from '@agent-device/contracts/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { createLimrunAndroidSession, type LimrunAndroidSession } from './android.ts'; +import { buildLimrunDevice } from './device.ts'; +import { createLimrunIosSession, type LimrunIosSession } from './ios.ts'; +import type { LimrunAppAsset } from './app-catalog.ts'; +import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; + +type LimrunInstance = { + metadata: { id: string }; + status: { + token: string; + apiUrl?: string; + adbWebSocketUrl?: string; + }; +}; + +type SessionAllocationParams = Readonly<{ + limrun: Limrun; + lease: DeviceLease; + metadata: Readonly<{ + displayName: string; + labels: Readonly>; + }>; + region?: string; + app?: LimrunAppAsset; + dependencies: LimrunRuntimeDependencies; +}>; + +export async function allocateLimrunIosSession( + params: SessionAllocationParams, +): Promise { + const instance = (await params.limrun.iosInstances.create({ + wait: true, + metadata: params.metadata, + spec: { + ...(params.region ? { region: params.region } : {}), + ...(params.app + ? { + initialAssets: [ + { + kind: 'App' as const, + source: 'AssetID' as const, + assetId: params.app.id, + launchMode: 'RelaunchIfRunning' as const, + }, + ], + } + : {}), + }, + })) as LimrunInstance; + try { + if (!instance.status.apiUrl) { + throw new AppError('COMMAND_FAILED', 'Limrun iOS instance did not expose apiUrl'); + } + return await createLimrunIosSession( + { + lease: params.lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('ios', params.lease, instance.metadata.id), + apiUrl: instance.status.apiUrl, + token: instance.status.token, + }, + params.dependencies, + ); + } catch (error) { + await params.limrun.iosInstances.delete(instance.metadata.id).catch(() => {}); + throw error; + } +} + +export async function allocateLimrunAndroidSession( + params: SessionAllocationParams, +): Promise { + const instance = (await params.limrun.androidInstances.create({ + wait: true, + metadata: params.metadata, + spec: { + ...(params.region ? { region: params.region } : {}), + ...(params.app + ? { + initialAssets: [ + { + kind: 'App' as const, + source: 'AssetIDs' as const, + assetIds: [params.app.id], + }, + ], + } + : {}), + }, + })) as LimrunInstance; + try { + if (!instance.status.apiUrl || !instance.status.adbWebSocketUrl) { + throw new AppError( + 'COMMAND_FAILED', + 'Limrun Android instance did not expose API and ADB websocket endpoints', + ); + } + return await createLimrunAndroidSession( + { + lease: params.lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('android', params.lease, instance.metadata.id), + apiUrl: instance.status.apiUrl, + token: instance.status.token, + adbUrl: instance.status.adbWebSocketUrl, + }, + params.dependencies, + ); + } catch (error) { + await params.limrun.androidInstances.delete(instance.metadata.id).catch(() => {}); + throw error; + } +} diff --git a/src/__tests__/cli-config-limrun.test.ts b/src/__tests__/cli-config-limrun.test.ts new file mode 100644 index 0000000000..309a6c685d --- /dev/null +++ b/src/__tests__/cli-config-limrun.test.ts @@ -0,0 +1,111 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + hashRemoteConfigFile, + readActiveConnectionState, + writeRemoteConnectionState, +} from '../remote/remote-connection-state.ts'; +import { runCliCapture } from './cli-capture.ts'; +import { makeTempWorkspace } from './cli-config-fixtures.ts'; + +test('Limrun apps lists uploaded assets without allocating an instance', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'limrun.remote.json'); + fs.writeFileSync(remoteConfig, '{}', 'utf8'); + const now = new Date().toISOString(); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'limrun-apps', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + tenant: 'limrun', + runId: 'run-apps', + leaseBackend: 'android-instance', + leaseProvider: 'limrun', + platform: 'android', + connectedAt: now, + updatedAt: now, + }, + }); + + const result = await runCliCapture(['apps', '--state-dir', stateDir, '--json'], { + cwd: project, + env: { HOME: home }, + defaultResponse: { ok: true, data: { apps: ['Example.apk'] } }, + }); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'apps'); + assert.equal(result.calls[0]?.flags?.leaseId, undefined); + assert.equal(result.calls[0]?.flags?.leaseProvider, 'limrun'); + assert.equal(readActiveConnectionState({ stateDir })?.leaseId, undefined); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('Limrun open allocates with the exact uploaded asset name', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'limrun.remote.json'); + fs.writeFileSync(remoteConfig, '{}', 'utf8'); + const now = new Date().toISOString(); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'limrun-open', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + tenant: 'limrun', + runId: 'run-open', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + platform: 'ios', + connectedAt: now, + updatedAt: now, + }, + }); + + const result = await runCliCapture( + ['open', 'Example.app.zip', '--state-dir', stateDir, '--json'], + { + cwd: project, + env: { HOME: home }, + sendToDaemon: async (req) => { + if (req.command === 'lease_allocate') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-limrun-open', + tenantId: 'limrun', + runId: 'run-open', + backend: 'ios-instance', + leaseProvider: 'limrun', + }, + }, + }; + } + if (req.command === 'open') return { ok: true, data: { appId: 'com.example.app' } }; + throw new Error(`unexpected daemon command: ${req.command}`); + }, + }, + ); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 2); + assert.equal(result.calls[0]?.command, 'lease_allocate'); + assert.equal(result.calls[0]?.flags?.providerApp, 'Example.app.zip'); + assert.equal(result.calls[1]?.command, 'open'); + assert.equal(result.calls[1]?.positionals?.[0], 'Example.app.zip'); + assert.equal(result.calls[1]?.flags?.providerApp, 'Example.app.zip'); + assert.equal(readActiveConnectionState({ stateDir })?.leaseId, 'lease-limrun-open'); + + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index b075fbafa6..fb3f486168 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -53,7 +53,7 @@ beforeEach(() => { }, app: { status: 'missing', - message: 'A new Limrun instance does not have your app yet.', + message: 'Run apps to choose an uploaded asset before allocation.', }, }); mockedVerifyWebDriverConnection.mockImplementation(async (options) => @@ -544,12 +544,13 @@ test('connect output makes verified configuration, deferred device allocation, a ); assert.match( result.stdout, - /App: not installed yet — A new Limrun instance does not have your app yet/, + /App: not installed yet — Run apps to choose an uploaded asset before allocation/, ); assert.match(result.stdout, /No live device session has been created/); assert.match(result.stdout, /Next:/); - assert.match(result.stdout, /agent-device install /); - assert.match(result.stdout, /agent-device open --relaunch/); + assert.match(result.stdout, /agent-device apps/); + assert.match(result.stdout, /agent-device open /); + assert.doesNotMatch(result.stdout, /agent-device install/); assert.doesNotMatch(result.stdout, /lease pending/); }); diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index 8affb909c3..2a2a9b24f6 100644 --- a/src/cli-schema/cli-help-topics.test.ts +++ b/src/cli-schema/cli-help-topics.test.ts @@ -438,7 +438,8 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /It does not create an instance/); assert.match(help, /Read the printed Device, App, Next, and workflow-note lines/); assert.match(help, /verification\/device\/app\/liveSession\/nextSteps\/notes/); - assert.match(help, /Do not run devices or apps as a pre-open catalog probe/); + assert.match(help, /Do not run devices as a pre-open catalog probe/); + assert.match(help, /Limrun is the exception for apps/); assert.match(help, /AWS Device Farm cannot install after allocation/); assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/); assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index 36bbc4ca97..be471100e8 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -736,9 +736,9 @@ Providers: After direct-provider connect: Read the printed Device, App, Next, and workflow-note lines. They are also available as verification/device/app/liveSession/nextSteps/notes in --json output. BrowserStack and AWS Device Farm create the hosted session on open. open needs the installed package or bundle identifier, not the app artifact name or ARN. - A new Limrun instance has no user app. Run install first; install allocates the instance, then open launches the installed id. + Before Limrun allocation, apps lists compatible uploaded app assets without creating an instance. open creates the instance with that asset, resolves its installed app id, and launches it. install remains available when the app comes from a fresh local path or URL. AWS Device Farm cannot install after allocation. If connect reports no attached app, run its printed reconnect command, which includes --session --force, before open. - Do not run devices or apps as a pre-open catalog probe for direct providers; those commands can allocate the deferred provider session and only inspect that live device. + Do not run devices as a pre-open catalog probe for direct providers; it can allocate the deferred provider session. Limrun is the exception for apps: before allocation it lists uploaded assets for the selected platform. Device cloud interfaces: CLI is the canonical bootstrap path: connect limrun/browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. @@ -788,7 +788,8 @@ Limrun direct-device flow: agent-device connect limrun --platform android Limrun creates remote iOS simulators and Android emulators only. Do not pass local device selectors such as --udid, --serial, or --device. - agent-device open com.example.app + agent-device apps + agent-device open Example.apk agent-device snapshot -i agent-device close agent-device disconnect diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index 7257a8f184..2d9ceba8d5 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -40,6 +40,14 @@ export function buildLeasePreparationNotice( 'No live device session has been created. Run devices to inspect inventory without allocating, then open when ready.', }; } + if (state.leaseProvider === 'limrun') { + return { + status: 'deferred', + nextSteps: buildConnectWorkflow(state, verification).nextSteps, + message: + 'No live device session has been created. Run apps to inspect uploaded assets without allocating; open creates the instance.', + }; + } if (leaseKind === 'direct-device-provider') { return { status: 'deferred', @@ -233,6 +241,11 @@ function buildUnscopedConnectWorkflow( if (!verification && leaseKind === 'direct-device-provider') { return { nextSteps: defaultDirectProviderLifecycle() }; } + if (verification?.provider === 'limrun') { + return { + nextSteps: ['agent-device apps', 'agent-device open '], + }; + } const appMissing = verification?.app?.status === 'missing'; return { nextSteps: requiresInstall(verification) diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 51a769b367..90518d2356 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -111,6 +111,13 @@ export async function materializeRemoteConnectionForCommand(options: { remoteConfig.profile, ); const nextFlags = { ...mergedFlags, session: state.session }; + if ( + state.leaseProvider === 'limrun' && + command === PUBLIC_COMMANDS.open && + typeof options.positionals?.[0] === 'string' + ) { + nextFlags.providerApp = options.positionals[0]; + } let nextRuntime = selectCompatibleRuntime(state.runtime, nextFlags.platform) ?? options.runtime; let nextState = state; let changed = !existingState; @@ -378,12 +385,20 @@ type ConnectionLeasePolicy = { function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { if (state.leaseProvider === 'proxy') return PROXY_CONNECTION_LEASE_POLICY; + if (state.leaseProvider === 'limrun') return LIMRUN_CONNECTION_LEASE_POLICY; if (isCloudWebDriverProviderName(state.leaseProvider)) { return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; } return DEFAULT_CONNECTION_LEASE_POLICY; } +const LIMRUN_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { + shouldAllocate: (command) => + command !== PUBLIC_COMMANDS.apps && !leaseDeferredCommands.has(command), + ttlMs: () => undefined, + resolveLeaseState: async (options) => ({ state: options.state }), +}; + const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => !leaseDeferredCommands.has(command), ttlMs: () => undefined, diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 3abfa93628..4de2fddc0a 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -188,8 +188,9 @@ const closeDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.close, (input) => export const appsCommandFacet = defineCommandFacet({ name: 'apps', text: { - summary: 'List installed apps', - cliDetail: 'Defaults to user-installed apps; use --all to include system/OEM apps.', + summary: 'List installed apps or deferred provider app assets', + cliDetail: + 'Before Limrun allocation, lists uploaded app assets for the selected platform. On a live device, defaults to user-installed apps; use --all to include system/OEM apps.', }, metadata: appsCommandMetadata, definition: appsCommandDefinition, diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 79eb03c3a2..b7ef799f7f 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -67,13 +67,13 @@ function appsCliOutput(params: { stderr: params.appsFilter === 'all' ? 'Showing all apps, including system apps.\n' - : 'Showing user-installed apps. Use --all to include system apps.\n', + : 'Showing user-installed apps or deferred provider app assets. Use --all to include system apps on a live device.\n', text: params.result.length > 0 ? params.result.join('\n') : params.appsFilter === 'all' ? 'No apps found.' - : 'No user-installed apps found.', + : 'No user apps or provider app assets found.', }; } diff --git a/src/core/command-descriptor/daemon-command-descriptor.ts b/src/core/command-descriptor/daemon-command-descriptor.ts index 713fb39ff7..f93498b227 100644 --- a/src/core/command-descriptor/daemon-command-descriptor.ts +++ b/src/core/command-descriptor/daemon-command-descriptor.ts @@ -50,15 +50,5 @@ export type DaemonCommandDescriptor = { preferExplicitDeviceOverExistingSession?: boolean; allowSessionlessDefaultDevice?: (req: TRequest) => boolean; skipSessionlessProviderDevice?: (req: TRequest) => boolean; - /** - * #2016: this request shape is eligible for the sessionless, - * no-lease-anywhere lease-admission bypass — a session that was never - * created (deferred `connect`, `open` never ran) has no lease to admit or - * release. Only `close` declares it, and only for the plain-close shape - * (no app-target positional): `close ` resolves its device straight - * from flags when there's no session, so it must stay behind full - * lease/tenant admission. Declared here so `request-admission.ts` asks the - * registry instead of reclassifying `req.command`/`req.positionals` itself. - */ - sessionlessPlainCloseAdmissionExempt?: (req: TRequest) => boolean; + sessionlessLeaseAdmissionExempt?: (req: TRequest) => boolean; }; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index c86dd28b00..0c9f7903ac 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -179,6 +179,11 @@ const isShardedTestRequest = (req: DispatchedCommand): boolean => const isPlainCloseRequest = (req: DispatchedCommand): boolean => (req.positionals?.length ?? 0) === 0; +const isDeferredProviderAppCatalogRequest = (req: DispatchedCommand): boolean => + req.flags?.leaseId === undefined && + typeof req.flags?.leaseProvider === 'string' && + (req.flags?.platform === 'android' || req.flags?.platform === 'ios'); + // ADR 0014 request-sensitive ref-frame resolvers. The action is the leading // positional (see keyboard/alert daemon writers in src/commands/system/index.ts // and src/commands/capture/alert.ts). Only the read-only status probes preserve @@ -558,6 +563,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, + sessionlessLeaseAdmissionExempt: isDeferredProviderAppCatalogRequest, }, platformExecution: { kind: 'device-runtime', uses: [appsRuntimeUse] as const }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -914,7 +920,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'may-invalidate', allowInvalidRecording: true, saveScriptFlagOwner: true, - sessionlessPlainCloseAdmissionExempt: isPlainCloseRequest, + sessionlessLeaseAdmissionExempt: isPlainCloseRequest, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index dce44a4933..603ae4f5be 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -7,6 +7,7 @@ import { getDaemonCommandRoute, getSessionCommandKind, isLeaseAdmissionExempt, + isSessionlessLeaseAdmissionExempt, shouldBlockForInvalidRecording, shouldGuardAndroidBlockingDialog, shouldLockSessionExecution, @@ -80,6 +81,20 @@ test('daemon command registry preserves request admission traits', () => { assert.equal(shouldValidateSessionSelector(INTERNAL_COMMANDS.leaseAllocate), true); assert.equal(isLeaseAdmissionExempt(PUBLIC_COMMANDS.open), false); assert.equal(shouldLockSessionExecution(PUBLIC_COMMANDS.open), true); + assert.equal( + isSessionlessLeaseAdmissionExempt({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun' }, + }), + true, + ); + assert.equal( + isSessionlessLeaseAdmissionExempt({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, + }), + false, + ); }); test('daemon command registry preserves replay and recording traits', () => { diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index 04e8fd91ff..8f8057c457 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -102,6 +102,17 @@ test('request handler chain routes trace commands to the record-trace family', a assert.equal(response?.data?.trace, 'started'); }); +test('request handler chain forwards the deferred provider app catalog to inventory', async () => { + const req = makeRequest('apps'); + req.flags = { platform: 'android', leaseProvider: 'limrun' }; + const response = await runRequestHandlerChain({ + ...makeChainParams(req), + providerAppCatalog: async () => ['Example.apk'], + }); + + assert.deepEqual(response, { ok: true, data: { apps: ['Example.apk'] } }); +}); + // R61 put `react-native dismiss-overlay` behind the owner's own `tapPoint` admission, and the // chain had never forwarded the request's runtime bindings to that route — so the dismissal leg // had been reaching a missing gateway ever since R48 moved it off the retired dispatcher. Only diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index 7c97d2221c..7d22448795 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -83,9 +83,8 @@ export function usesSessionlessDefaultProviderDevice(req: DaemonRequest): boolea return typeof allow === 'function' ? allow(req) : false; } -/** #2016: whether this request qualifies for the sessionless plain-close lease-admission bypass. */ -export function isSessionlessPlainCloseAdmissionExempt(req: DaemonRequest): boolean { - const exempt = getDaemonCommandDescriptor(req.command)?.sessionlessPlainCloseAdmissionExempt; +export function isSessionlessLeaseAdmissionExempt(req: DaemonRequest): boolean { + const exempt = getDaemonCommandDescriptor(req.command)?.sessionlessLeaseAdmissionExempt; return typeof exempt === 'function' ? exempt(req) : false; } diff --git a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts index 2f71c204a2..0cd1d2081e 100644 --- a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts @@ -14,6 +14,10 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../request-runtime-binding.ts'; +import { + clearRequestAbortRegistration, + registerRequestAbort, +} from '@agent-device/host-kit/request'; const MACOS_DEVICE = { platform: 'apple' as const, @@ -123,3 +127,74 @@ test('macOS apps consumes generic readiness and app inventory through one runtim expect(ensureReady).toHaveBeenCalledOnce(); expect(listApps).toHaveBeenCalledOnce(); }); + +test('deferred provider apps returns uploaded assets without resolving a device', async () => { + const sessionStore = makeSessionStore(); + const listAvailableApps = vi.fn(async () => ['Example.apk', 'Settings.apk']); + const req: DaemonRequest = { + token: 'test-token', + session: 'limrun-apps', + command: 'apps', + positionals: [], + flags: { + platform: 'android', + leaseProvider: 'limrun', + }, + }; + + const response = await handleSessionInventoryCommands({ + req, + sessionName: req.session, + sessionStore, + inspectFacts, + bindDevice, + providerAppCatalog: listAvailableApps, + }); + + expect(response).toEqual({ + ok: true, + data: { apps: ['Example.apk', 'Settings.apk'] }, + }); + expect(listAvailableApps).toHaveBeenCalledWith( + { + provider: 'limrun', + platform: 'android', + }, + undefined, + ); + expect(inspectFacts).not.toHaveBeenCalled(); + expect(bindCount).toBe(0); +}); + +test('deferred provider apps forwards request cancellation to the catalog', async () => { + const requestId = 'provider-app-catalog-abort'; + const registration = registerRequestAbort(requestId); + const reason = new Error('catalog canceled'); + const providerAppCatalog = vi.fn(async (_query, signal?: AbortSignal) => { + expect(signal).toBe(registration?.controller.signal); + return await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + const req: DaemonRequest = { + token: 'test-token', + session: 'limrun-apps-abort', + command: 'apps', + positionals: [], + flags: { platform: 'ios', leaseProvider: 'limrun' }, + meta: { requestId }, + }; + + try { + const response = handleSessionInventoryCommands({ + req, + sessionName: req.session, + sessionStore: makeSessionStore(), + providerAppCatalog, + }); + registration?.controller.abort(reason); + await expect(response).rejects.toBe(reason); + } finally { + clearRequestAbortRegistration(registration); + } +}); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 192b0ff85f..8c1cf7fa4f 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -39,6 +39,9 @@ import { } from '@agent-device/contracts/platform-runtime-operations'; import { ensureAppsRuntimeReady, listAppsFromRuntime } from '../apps-runtime.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { ProviderAppCatalog } from '@agent-device/contracts/device'; +import { resolveLeaseScope } from '../lease-context.ts'; +import { getRequestSignal } from '@agent-device/host-kit/request'; export async function handleSessionInventoryCommands(params: { req: DaemonRequest; @@ -46,6 +49,7 @@ export async function handleSessionInventoryCommands(params: { sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; + providerAppCatalog?: ProviderAppCatalog; }): Promise { const { req, sessionName, sessionStore } = params; switch (req.command) { @@ -67,6 +71,7 @@ export async function handleSessionInventoryCommands(params: { sessionStore, bindDevice: params.bindDevice, inspectFacts: params.inspectFacts, + providerAppCatalog: params.providerAppCatalog, }); default: return null; @@ -302,9 +307,15 @@ async function handleAppsInventory(params: { sessionName: string; sessionStore: SessionStore; bindDevice?: BindDeviceRuntime; + providerAppCatalog?: ProviderAppCatalog; inspectFacts?: InspectDeviceRuntimeFacts; }): Promise { const { req, sessionName, sessionStore, bindDevice, inspectFacts } = params; + const providerCatalogResponse = await resolveProviderAppCatalogResponse( + req, + params.providerAppCatalog, + ); + if (providerCatalogResponse) return providerCatalogResponse; const resolution = await resolveInventoryCommandDevice({ req, sessionName, @@ -332,6 +343,26 @@ async function handleAppsInventory(params: { return appsInventoryResponse(apps); } +async function resolveProviderAppCatalogResponse( + req: DaemonRequest, + providerAppCatalog: ProviderAppCatalog | undefined, +): Promise { + if (!providerAppCatalog) return undefined; + const leaseScope = resolveLeaseScope(req); + if (leaseScope.leaseId) return undefined; + const provider = leaseScope.leaseProvider; + const platform = req.flags?.platform; + if (!provider || (platform !== 'android' && platform !== 'ios')) return undefined; + const apps = await providerAppCatalog( + { + provider, + platform, + }, + getRequestSignal(req.meta?.requestId), + ); + return apps ? { ok: true, data: { apps: [...apps] } } : undefined; +} + async function inspectCapabilityFacts( device: DeviceInfo, inspectFacts: InspectDeviceRuntimeFacts | undefined, diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 3a092a80ff..abbef60b28 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -19,7 +19,7 @@ import { handleDoctorCommand } from './session-doctor.ts'; import { handlePrepareCommand } from './session-prepare.ts'; import type { DescriptorSessionRouteCommandName } from '../../core/command-descriptor/registry.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; +import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; import type { BindDeviceRuntime, BindExactDeviceRuntime, @@ -41,6 +41,7 @@ export type SessionCommandInput = { sessionStore: SessionStore; leaseRegistry?: LeaseRegistry; leaseLifecycleProvider?: LeaseLifecycleProvider; + providerAppCatalog?: ProviderAppCatalog; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; /** @@ -76,6 +77,7 @@ const handleSessionInventoryCommandGroup: SessionCommandHandler = async ({ sessionStore, inspectFacts, bindDevice, + providerAppCatalog, }) => await handleSessionInventoryCommands({ req, @@ -83,6 +85,7 @@ const handleSessionInventoryCommandGroup: SessionCommandHandler = async ({ sessionStore, inspectFacts, bindDevice, + providerAppCatalog, }); const handleSessionStateCommandGroup: SessionCommandHandler = async ({ @@ -301,6 +304,7 @@ export async function handleSessionCommands( sessionStore, leaseRegistry = new LeaseRegistry(), leaseLifecycleProvider, + providerAppCatalog, invoke, invokeReplayAction, androidAdbExecutor, @@ -330,6 +334,7 @@ export async function handleSessionCommands( sessionStore, leaseRegistry, leaseLifecycleProvider, + providerAppCatalog, invoke, invokeReplayAction, androidAdbExecutor, diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 655350751c..ba0846b350 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -3,7 +3,7 @@ import { normalizeTenantId, resolveSessionIsolationMode } from './config.ts'; import { isTenantOwnedSessionName, tenantScopedSessionName } from './session-tenant-scope.ts'; import { isLeaseAdmissionExempt, - isSessionlessPlainCloseAdmissionExempt, + isSessionlessLeaseAdmissionExempt, } from './daemon-command-registry.ts'; import { DEFAULT_PROXY_LEASE_TTL_MS, @@ -72,22 +72,10 @@ export function assertRequestLeaseAdmission( const requestLeaseScope = resolveLeaseScope(req); assertProxyOpenLeaseMetadata(req, requestLeaseScope); const sessionLease = session?.lease; - // #2016: a tenant-isolated connection that never reached `open` has no - // daemon session and no lease to admit or release. Falling through would - // make the generic tenant/run/lease check below throw "tenant isolation - // requires lease id.", which reads as an access-control failure instead of - // "nothing to close". Let the close handler's own session lookup return - // its SESSION_NOT_FOUND response instead. Requires `session === undefined`, - // not just a lease-less session: a *stored* session under tenant isolation - // is keyed by tenant, not by run, so a lease-less stored session could - // belong to another run in the same tenant — admission must still verify a - // matching lease before that run's session can be torn down. Which request - // shape qualifies (plain `close`, not an app-target `close `) is the - // registry's call, not this module's — see `sessionlessPlainCloseAdmissionExempt`. if ( session === undefined && !requestLeaseScope.leaseId && - isSessionlessPlainCloseAdmissionExempt(req) + isSessionlessLeaseAdmissionExempt(req) ) { return undefined; } diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 3cb39940a6..5bfb598e00 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -5,7 +5,7 @@ import type { DaemonCommandRoute } from '../core/command-descriptor/daemon-comma import { getDaemonCommandRoute } from './daemon-command-registry.ts'; import * as genericRequestHandlerModule from './request-generic-dispatch.ts'; import type { DaemonCommandContext } from './context.ts'; -import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; +import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; import type { LeaseRegistry } from './lease-registry.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from './types.ts'; @@ -35,6 +35,7 @@ type RequestHandlerChainParams = { providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; + providerAppCatalog?: ProviderAppCatalog; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; /** @@ -155,6 +156,7 @@ async function runSessionHandler( sessionStore: params.sessionStore, leaseRegistry: params.leaseRegistry, leaseLifecycleProvider: params.leaseLifecycleProvider, + providerAppCatalog: params.providerAppCatalog, invoke: params.invoke, invokeReplayAction: params.invokeReplayAction, androidAdbExecutor: params.providerScope.androidAdbExecutor, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 4228a4b80e..03644cde56 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -1,6 +1,6 @@ import { withResolveTargetDeviceCacheScope } from '../core/dispatch-resolve.ts'; import { withDeviceInventoryContext } from '../request/device-inventory-context.ts'; -import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; +import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; import type { ComposedDeviceInventoryGateways } from '@agent-device/contracts/platform-module'; import type { DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; @@ -90,6 +90,7 @@ export type RequestRouterDeps = { providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; + providerAppCatalog?: ProviderAppCatalog; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; @@ -150,6 +151,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerRuntimeRequiredIds, leaseLifecycleProvider, cloudArtifactProvider, + providerAppCatalog, androidObservation = unavailableAndroidObservation, platformResourceCleanup = unavailablePlatformResourceCleanup, providerDeviceRuntimeScope, @@ -285,6 +287,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerRuntimeIds, providerRuntimeRequiredIds, cloudArtifactProvider, + providerAppCatalog, invoke: handleRequest, invokeReplayAction: allowReplayActions ? createReplayScopedActionInvoker(lockedScope, providerScope) diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 982ba0a01b..f2f90f3ba4 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -321,6 +321,7 @@ export async function startDaemonRuntime( }, }); const cloudArtifactProvider = providerRuntimeProviders.cloudArtifactProvider; + const providerAppCatalog = providerRuntimeProviders.providerAppCatalog; const deviceInventoryGateways = createPlatformDeviceInventoryGateways( providerRuntimeProviders.deviceInventorySource, ); @@ -332,6 +333,7 @@ export async function startDaemonRuntime( leaseRegistry, leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, cloudArtifactProvider, + providerAppCatalog, deviceInventoryGateways, deviceRuntimeGateway, appLogAdmissionLedger, diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index dde9afa826..bb58f2acf3 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -3,6 +3,7 @@ import type { DeviceLease, LeaseLifecycleContext, LeaseLifecycleProvider, + ProviderAppCatalog, ProviderDeviceRuntime, ProviderExpiredLeaseRecovery, } from '@agent-device/contracts/device'; @@ -46,6 +47,7 @@ export type ProviderDeviceRuntimeRequestProviders = { leaseLifecycleProvider?: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifactProvider?: CloudArtifactProvider; + providerAppCatalog?: ProviderAppCatalog; deviceInventorySource?: ProviderDeviceInventorySource; appleRunnerProvider?: AppleRunnerProviderResolver; appleRunnerScreenRecordingTransport?: AppleRunnerScreenRecordingTransportResolver; @@ -108,6 +110,7 @@ export function createProviderDeviceRuntimeRequestProviders( .map((runtime) => runtime.provider), recoverExpiredLease: composeExpiredLeaseRecovery(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), + providerAppCatalog: composeProviderAppCatalog(runtimes), deviceInventorySource: composeDeviceInventorySource(runtimes), appleRunnerProvider: composeAppleRunnerProviderResolver(runtimes), appleRunnerScreenRecordingTransport: @@ -210,6 +213,20 @@ function composeCloudArtifactProvider( }; } +function composeProviderAppCatalog( + runtimes: ProviderDeviceRuntime[], +): ProviderAppCatalog | undefined { + if (!runtimes.some((runtime) => runtime.appCatalog !== undefined)) return undefined; + return async (query, signal) => { + for (const runtime of runtimes) { + if (!runtime.appCatalog || !runtimeMatchesProvider(runtime, query.provider)) continue; + const apps = await runtime.appCatalog(query, signal); + if (apps) return apps; + } + return undefined; + }; +} + function composeDeviceInventorySource( runtimes: ProviderDeviceRuntime[], ): ProviderDeviceInventorySource | undefined { From 6a5073b057f1a31d337ab71806082e0114266001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 08:54:18 +0200 Subject: [PATCH 02/11] refactor: generalize deferred provider app selection --- .../application-lifecycle-interaction.test.ts | 4 ++-- .../src/application-lifecycle-interaction.ts | 16 ++++++++-------- packages/provider-limrun/src/app-log-runtime.ts | 4 ++-- packages/provider-limrun/src/lifecycle.ts | 4 ++-- packages/provider-limrun/src/runtime.ts | 4 ++-- src/cli/commands/connection-runtime.ts | 13 +++++++++---- src/cli/connection/provider-policy.test.ts | 11 +++++++++++ src/cli/connection/provider-policy.ts | 8 ++++++++ 8 files changed, 44 insertions(+), 20 deletions(-) create mode 100644 src/cli/connection/provider-policy.test.ts diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index 6627c4d139..4da6e55c65 100644 --- a/packages/contracts/src/application-lifecycle-interaction.test.ts +++ b/packages/contracts/src/application-lifecycle-interaction.test.ts @@ -91,7 +91,7 @@ test('direct lifecycle owners preserve the daemon runtime launch URL follow-up', expect(calls[1]?.options).toHaveProperty('launchArgs', undefined); }); -test('direct lifecycle owners resolve app aliases once before target dispatch', async () => { +test('direct lifecycle owners resolve provider app references before target dispatch', async () => { const open = vi.fn(async () => undefined); const binding = bindLocalApplicationLifecycleInteractor({ device: WEB_DEVICE, @@ -102,7 +102,7 @@ test('direct lifecycle owners resolve app aliases once before target dispatch', binding, owner: 'Provider', openTargetIdentity: 'bundle-id', - resolveAppAlias: (app) => (app === 'Example.app.zip' ? 'com.example.app' : app), + resolveAppReference: (app) => (app === 'Example.app.zip' ? 'com.example.app' : app), }); await expect( diff --git a/packages/contracts/src/application-lifecycle-interaction.ts b/packages/contracts/src/application-lifecycle-interaction.ts index 7b277bb778..bdca0620ae 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -262,7 +262,7 @@ export type DirectApplicationLifecycleParams = Readonly<{ openTargetIdentity: DirectOpenTargetIdentity; /** Owners whose native open does not replace a running application close it first. */ closeBeforeRelaunch?: boolean; - resolveAppAlias?(app: string): string; + resolveAppReference?(app: string): string; /** Port reverse is the one non-direct operation a provider owner may still implement. */ configureProviderPortReverse?: ApplicationLifecycleRuntimeOperations['configureProviderPortReverse']; }>; @@ -284,7 +284,7 @@ export function bindDirectApplicationLifecycle( }; return Object.freeze({ resolveOpenTarget: async (input) => - resolveDirectOpenTarget(params.openTargetIdentity, resolveOpenTargetAlias(params, input)), + resolveDirectOpenTarget(params.openTargetIdentity, resolveOpenTargetReference(params, input)), prepareApplicationOpen: async () => undefined, openApplication: async (input) => await openDirectApplication(params, input), applyRuntimeHints: unavailable, @@ -306,7 +306,7 @@ async function openDirectApplication( params: DirectApplicationLifecycleParams, input: OpenApplicationInput, ): Promise { - const resolvedInput = resolveOpenApplicationAliases(params, input); + const resolvedInput = resolveOpenApplicationReferences(params, input); const { binding } = params; const interactor = await binding.resolveInteractor( resolvedInput.execution, @@ -344,19 +344,19 @@ async function openDirectApplication( return { appBundleId: resolvedInput.appBundleId, timing: {} }; } -function resolveOpenTargetAlias( +function resolveOpenTargetReference( params: DirectApplicationLifecycleParams, input: OpenTargetResolutionInput, ): OpenTargetResolutionInput { - if (!input.target || !params.resolveAppAlias) return input; - return { ...input, target: params.resolveAppAlias(input.target) }; + if (!input.target || !params.resolveAppReference) return input; + return { ...input, target: params.resolveAppReference(input.target) }; } -function resolveOpenApplicationAliases( +function resolveOpenApplicationReferences( params: DirectApplicationLifecycleParams, input: OpenApplicationInput, ): OpenApplicationInput { - const resolve = params.resolveAppAlias; + const resolve = params.resolveAppReference; if (!resolve) return input; return { ...input, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index b1a2ed8afb..696dc7d6da 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -59,7 +59,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit< runtimeInstance: string; ownsDevice(device: DeviceInfo): boolean; getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; - resolveAppAlias?(device: DeviceInfo, app: string): string; + resolveAppReference?(device: DeviceInfo, app: string): string; openCurrent(device: DeviceInfo): Promise; hasLiveSession(device: DeviceInfo): boolean; reconnect( @@ -281,7 +281,7 @@ function bindLimrunAppLogs( device, signal, getInteractor: options.getInteractor, - resolveAppAlias: (app) => options.resolveAppAlias?.(device, app) ?? app, + resolveAppReference: (app) => options.resolveAppReference?.(device, app) ?? app, configurePortReverse: options.configurePortReverse, }), runtimeFacts.operations, diff --git a/packages/provider-limrun/src/lifecycle.ts b/packages/provider-limrun/src/lifecycle.ts index b8995d6c1c..5a4d0757ac 100644 --- a/packages/provider-limrun/src/lifecycle.ts +++ b/packages/provider-limrun/src/lifecycle.ts @@ -14,7 +14,7 @@ type LimrunLifecycleParams = Readonly<{ configurePortReverse( options: ProviderPortReverseOptions, ): Promise | undefined>; - resolveAppAlias?(app: string): string; + resolveAppReference?(app: string): string; }>; /** Limrun owns its live-session lifecycle, relaunch, and exact port-reverse mechanics. */ @@ -25,7 +25,7 @@ export function bindLimrunApplicationLifecycle( owner: 'Limrun', openTargetIdentity: 'bundle-id', closeBeforeRelaunch: true, - resolveAppAlias: params.resolveAppAlias, + resolveAppReference: params.resolveAppReference, configureProviderPortReverse: async (input) => await params.configurePortReverse(input), binding: bindProviderApplicationLifecycleInteractor({ device: params.device, diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 79f0ba36f3..341dedd879 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -331,7 +331,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { return session?.platform === parsed.platform ? session : undefined; } - resolveAppAlias(device: DeviceInfo, app: string): string { + resolveAppReference(device: DeviceInfo, app: string): string { const parsed = parseLimrunDeviceId(device.id); if (!parsed) return app; const alias = this.appAliases.get(parsed.leaseId); @@ -386,7 +386,7 @@ async function loadLimrunPlatformRuntime( ownsDevice: (device) => runtime.ownsDevice(device), hasLiveSession: (device) => runtime.hasLiveSession(device), getInteractor: (device, runner) => runtime.getInteractor(device, runner), - resolveAppAlias: (device, app) => runtime.resolveAppAlias(device, app), + resolveAppReference: (device, app) => runtime.resolveAppReference(device, app), openCurrent: async (device) => runtime.currentAppLogReader(device), reconnect: async (descriptor, signal) => await runtime.reconnectAppLogReader(descriptor, signal), diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 90518d2356..6fe31951b3 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -33,7 +33,10 @@ import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; -import { connectionProviderRequiresRemoteDaemon } from '../connection/provider-policy.ts'; +import { + connectionProviderRequiresRemoteDaemon, + connectionProviderSupportsDeferredAppSelection, +} from '../connection/provider-policy.ts'; import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; import { isCloudWebDriverProviderName } from '@agent-device/provider-webdriver'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; @@ -112,7 +115,7 @@ export async function materializeRemoteConnectionForCommand(options: { ); const nextFlags = { ...mergedFlags, session: state.session }; if ( - state.leaseProvider === 'limrun' && + connectionProviderSupportsDeferredAppSelection(state.leaseProvider) && command === PUBLIC_COMMANDS.open && typeof options.positionals?.[0] === 'string' ) { @@ -385,14 +388,16 @@ type ConnectionLeasePolicy = { function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { if (state.leaseProvider === 'proxy') return PROXY_CONNECTION_LEASE_POLICY; - if (state.leaseProvider === 'limrun') return LIMRUN_CONNECTION_LEASE_POLICY; + if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; + } if (isCloudWebDriverProviderName(state.leaseProvider)) { return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; } return DEFAULT_CONNECTION_LEASE_POLICY; } -const LIMRUN_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { +const DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => command !== PUBLIC_COMMANDS.apps && !leaseDeferredCommands.has(command), ttlMs: () => undefined, diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts new file mode 100644 index 0000000000..e6be84e13d --- /dev/null +++ b/src/cli/connection/provider-policy.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { connectionProviderSupportsDeferredAppSelection } from './provider-policy.ts'; + +test('only providers declaring deferred app selection use app catalog before allocation', () => { + assert.equal(connectionProviderSupportsDeferredAppSelection('limrun'), true); + assert.equal(connectionProviderSupportsDeferredAppSelection('browserstack'), false); + assert.equal(connectionProviderSupportsDeferredAppSelection('aws-device-farm'), false); + assert.equal(connectionProviderSupportsDeferredAppSelection('proxy'), false); + assert.equal(connectionProviderSupportsDeferredAppSelection(undefined), false); +}); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 8bdcecc48d..f489be1fea 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -7,6 +7,8 @@ import { export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; +const DEFERRED_APP_SELECTION_PROVIDERS = new Set(['limrun']); + export function isConnectProviderName(value: string | undefined): value is ConnectProvider { return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); } @@ -31,6 +33,12 @@ export function connectionProviderRequiresRemoteDaemon(provider: string | undefi return !isDirectDeviceConnectProvider(provider); } +export function connectionProviderSupportsDeferredAppSelection( + provider: string | undefined, +): boolean { + return isDirectDeviceConnectProvider(provider) && DEFERRED_APP_SELECTION_PROVIDERS.has(provider); +} + export function connectionProviderLeaseKind( provider: string | undefined, ): 'proxy' | 'direct-device-provider' | 'remote-provider' { From 708d1ba84b35cca5dcee11c9328d5e9efa6e1801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 10:48:39 +0200 Subject: [PATCH 03/11] refactor: fold limrun preinstall into allocation --- .../provider-limrun/src/app-preinstall.ts | 37 ------------------- packages/provider-limrun/src/runtime.ts | 10 +++-- .../provider-limrun/src/session-allocation.ts | 34 ++++++++++++++++- 3 files changed, 38 insertions(+), 43 deletions(-) delete mode 100644 packages/provider-limrun/src/app-preinstall.ts diff --git a/packages/provider-limrun/src/app-preinstall.ts b/packages/provider-limrun/src/app-preinstall.ts deleted file mode 100644 index 02475629ec..0000000000 --- a/packages/provider-limrun/src/app-preinstall.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type Limrun from '@limrun/api'; -import type { LeaseLifecycleContext } from '@agent-device/contracts/device'; -import { AppError } from '@agent-device/kernel/errors'; -import type { LimrunAndroidSession } from './android.ts'; -import { - resolveInstalledAppIdForAsset, - resolveLimrunAppAsset, - type LimrunAppAsset, -} from './app-catalog.ts'; -import { createLimrunDeviceSession } from './device-session.ts'; -import type { LimrunIosSession } from './ios.ts'; - -export async function resolveRequestedLimrunAppAsset( - limrun: Limrun, - platform: 'android' | 'ios', - context?: LeaseLifecycleContext, -): Promise { - const value = context?.flags?.providerApp; - const name = typeof value === 'string' ? value.trim() : ''; - if (!name) return undefined; - return await resolveLimrunAppAsset(limrun, platform, name, context?.signal); -} - -export async function resolvePreinstalledAppId( - session: LimrunAndroidSession | LimrunIosSession, - asset: LimrunAppAsset, -): Promise { - const deviceSession = createLimrunDeviceSession(session); - const apps = await deviceSession.listApps('user-installed'); - const matchedAppId = resolveInstalledAppIdForAsset(asset.name, apps); - if (matchedAppId) return matchedAppId; - throw new AppError( - 'COMMAND_FAILED', - `Limrun installed ${asset.name}, but its application identifier could not be resolved unambiguously.`, - { asset: asset.name, installedApps: apps.map((app) => app.id) }, - ); -} diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 341dedd879..580c8d0a9e 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -234,10 +234,12 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const existing = this.sessions.get(lease.leaseId); if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; - const { resolvePreinstalledAppId, resolveRequestedLimrunAppAsset } = - await import('./app-preinstall.ts'); - const { allocateLimrunAndroidSession, allocateLimrunIosSession } = - await import('./session-allocation.ts'); + const { + allocateLimrunAndroidSession, + allocateLimrunIosSession, + resolvePreinstalledAppId, + resolveRequestedLimrunAppAsset, + } = await import('./session-allocation.ts'); const requestedAsset = await resolveRequestedLimrunAppAsset(this.limrun, platform, context); const session = platform === 'ios' diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts index 5df042a1d6..2a77c83352 100644 --- a/packages/provider-limrun/src/session-allocation.ts +++ b/packages/provider-limrun/src/session-allocation.ts @@ -1,10 +1,15 @@ import type Limrun from '@limrun/api'; -import type { DeviceLease } from '@agent-device/contracts/device'; +import type { DeviceLease, LeaseLifecycleContext } from '@agent-device/contracts/device'; import { AppError } from '@agent-device/kernel/errors'; import { createLimrunAndroidSession, type LimrunAndroidSession } from './android.ts'; import { buildLimrunDevice } from './device.ts'; import { createLimrunIosSession, type LimrunIosSession } from './ios.ts'; -import type { LimrunAppAsset } from './app-catalog.ts'; +import { + resolveInstalledAppIdForAsset, + resolveLimrunAppAsset, + type LimrunAppAsset, +} from './app-catalog.ts'; +import { createLimrunDeviceSession } from './device-session.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; type LimrunInstance = { @@ -28,6 +33,31 @@ type SessionAllocationParams = Readonly<{ dependencies: LimrunRuntimeDependencies; }>; +export async function resolveRequestedLimrunAppAsset( + limrun: Limrun, + platform: 'android' | 'ios', + context?: LeaseLifecycleContext, +): Promise { + const value = context?.flags?.providerApp; + const name = typeof value === 'string' ? value.trim() : ''; + if (!name) return undefined; + return await resolveLimrunAppAsset(limrun, platform, name, context?.signal); +} + +export async function resolvePreinstalledAppId( + session: LimrunAndroidSession | LimrunIosSession, + asset: LimrunAppAsset, +): Promise { + const apps = await createLimrunDeviceSession(session).listApps('user-installed'); + const matchedAppId = resolveInstalledAppIdForAsset(asset.name, apps); + if (matchedAppId) return matchedAppId; + throw new AppError( + 'COMMAND_FAILED', + `Limrun installed ${asset.name}, but its application identifier could not be resolved unambiguously.`, + { asset: asset.name, installedApps: apps.map((app) => app.id) }, + ); +} + export async function allocateLimrunIosSession( params: SessionAllocationParams, ): Promise { From 3c2e6554fe42acd4ef7c28936465882693710ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 11:52:22 +0200 Subject: [PATCH 04/11] refactor: keep app selection provider independent --- src/cli/commands/connection-presentation.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index 2d9ceba8d5..2b3e558172 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -1,6 +1,9 @@ import { fingerprint, type RemoteConnectionState } from '../../remote/remote-connection-state.ts'; import type { ConnectVerification } from '../connection/connect-provider-adapters.ts'; -import { connectionProviderLeaseKind } from '../connection/provider-policy.ts'; +import { + connectionProviderLeaseKind, + connectionProviderSupportsDeferredAppSelection, +} from '../connection/provider-policy.ts'; import { shellQuoteIfNeeded } from '@agent-device/host-kit/command'; export type ConnectReadiness = ConnectVerification & { @@ -40,7 +43,7 @@ export function buildLeasePreparationNotice( 'No live device session has been created. Run devices to inspect inventory without allocating, then open when ready.', }; } - if (state.leaseProvider === 'limrun') { + if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { return { status: 'deferred', nextSteps: buildConnectWorkflow(state, verification).nextSteps, @@ -241,7 +244,7 @@ function buildUnscopedConnectWorkflow( if (!verification && leaseKind === 'direct-device-provider') { return { nextSteps: defaultDirectProviderLifecycle() }; } - if (verification?.provider === 'limrun') { + if (connectionProviderSupportsDeferredAppSelection(verification?.provider)) { return { nextSteps: ['agent-device apps', 'agent-device open '], }; @@ -342,6 +345,8 @@ function appIdPlaceholder(platform: RemoteConnectionState['platform']): string { function missingAppLabel(state: RemoteConnectionState): string { if (state.leaseProvider === 'aws-device-farm') return 'not attached'; - if (state.leaseProvider === 'limrun') return 'not installed yet'; + if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + return 'not installed yet'; + } return 'not available'; } From a64604441a61d79f7e0fdca9440fe295f29c7543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 12:09:12 +0200 Subject: [PATCH 05/11] refactor: enforce provider-independent commands --- scripts/layering/check.ts | 3 + .../layering/command-provider-policy.test.ts | 32 +++++++++ scripts/layering/command-provider-policy.ts | 67 +++++++++++++++++++ src/cli/commands/connection-presentation.ts | 6 +- src/cli/commands/connection-runtime.ts | 5 +- src/cli/commands/react-devtools.ts | 3 +- src/cli/connection/provider-policy.test.ts | 17 ++++- src/cli/connection/provider-policy.ts | 12 ++++ 8 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 scripts/layering/command-provider-policy.test.ts create mode 100644 scripts/layering/command-provider-policy.ts diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 920918ccc6..c343d64b8f 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -101,6 +101,7 @@ import { runtimeExecutionIntegrityViolations } from './runtime-execution-policy. import { sourceExecutionCompatibilityViolations } from './source-execution-policy.ts'; import { sessionResourceOwnershipViolations } from './session-resource-ownership.ts'; import { applicationLifecycleOwnershipViolations } from './application-lifecycle-policy.ts'; +import { commandProviderPolicyViolations } from './command-provider-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', @@ -535,6 +536,7 @@ export const LAYERING_RULE_IDS = [ 'record-runtime-ownership', 'session-resource-ownership', 'application-lifecycle-ownership', + 'command-provider-policy', 'contracts-implementation-authority', 'substrate-domain-shape', 'selector-pipeline-ownership', @@ -561,6 +563,7 @@ export const LAYERING_RULES: Readonly> = { 'session-resource-ownership': (context) => sessionResourceOwnershipViolations(context.sources), 'application-lifecycle-ownership': (context) => applicationLifecycleOwnershipViolations(context.sources), + 'command-provider-policy': (context) => commandProviderPolicyViolations(context.sources), 'contracts-implementation-authority': (context) => checkContractsImplementationAuthority(context.sources), 'substrate-domain-shape': (context) => diff --git a/scripts/layering/command-provider-policy.test.ts b/scripts/layering/command-provider-policy.test.ts new file mode 100644 index 0000000000..c1e76e5391 --- /dev/null +++ b/scripts/layering/command-provider-policy.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { commandProviderPolicyViolations } from './command-provider-policy.ts'; + +test('command implementations cannot branch on provider identity', () => { + const violations = commandProviderPolicyViolations( + new Map([ + ['src/cli/commands/open.ts', `if (state.leaseProvider === 'limrun') open();`], + ['src/commands/apps.ts', `if ('browserstack' !== verification['provider']) return;`], + [ + 'src/cli/commands/connect.ts', + `if (verification?.provider === 'aws-device-farm') reconnect();`, + ], + ['src/cli/commands/session.ts', `switch (state.leaseProvider) { case 'proxy': connect(); }`], + ['src/cli/connection/provider-policy.ts', `export const supported = provider === 'limrun';`], + [ + 'src/cli/commands/devices.ts', + `if (connectionProviderSupportsApps(state.leaseProvider)) list();`, + ], + ]), + ); + + assert.deepEqual( + violations.map(({ file, line }) => ({ file, line })), + [ + { file: 'src/cli/commands/open.ts', line: 1 }, + { file: 'src/commands/apps.ts', line: 1 }, + { file: 'src/cli/commands/connect.ts', line: 1 }, + { file: 'src/cli/commands/session.ts', line: 1 }, + ], + ); +}); diff --git a/scripts/layering/command-provider-policy.ts b/scripts/layering/command-provider-policy.ts new file mode 100644 index 0000000000..fbbc0959f7 --- /dev/null +++ b/scripts/layering/command-provider-policy.ts @@ -0,0 +1,67 @@ +import { parseSync } from 'oxc-parser'; +import { memberName, visitAst } from './layering-ast.ts'; +import type { LayeringViolation } from './model.ts'; + +export const COMMAND_PROVIDER_POLICY_RULE = 'R71 command-provider-policy'; + +const COMMAND_ROOTS = ['src/commands/', 'src/cli/commands/']; +const PROVIDER_FIELDS = new Set(['leaseProvider', 'provider']); +const EQUALITY_OPERATORS = new Set(['==', '===', '!=', '!==']); + +function isCommandFile(file: string): boolean { + return COMMAND_ROOTS.some((root) => file.startsWith(root)); +} + +function isStringLiteral(node: unknown): boolean { + if (node === null || typeof node !== 'object') return false; + const value = node as Record; + return value.type === 'Literal' && typeof value.value === 'string'; +} + +function isProviderField(node: unknown): boolean { + if (node === null || typeof node !== 'object') return false; + const value = node as Record; + if (value.type === 'ChainExpression') return isProviderField(value.expression); + return value.type === 'MemberExpression' && PROVIDER_FIELDS.has(memberName(value) ?? ''); +} + +function isProviderIdentityComparison(node: Record): boolean { + if (node.type !== 'BinaryExpression' || !EQUALITY_OPERATORS.has(String(node.operator))) { + return false; + } + return ( + (isProviderField(node.left) && isStringLiteral(node.right)) || + (isStringLiteral(node.left) && isProviderField(node.right)) + ); +} + +function isProviderIdentitySwitch(node: Record): boolean { + if (node.type !== 'SwitchStatement' || !isProviderField(node.discriminant)) return false; + const cases = Array.isArray(node.cases) ? node.cases : []; + return cases.some((entry) => { + if (entry === null || typeof entry !== 'object') return false; + return isStringLiteral((entry as Record).test); + }); +} + +export function commandProviderPolicyViolations( + sources: ReadonlyMap, +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const [file, source] of sources) { + if (!isCommandFile(file)) continue; + const program = parseSync(file, source).program as Record; + visitAst(program, (node) => { + if (!isProviderIdentityComparison(node) && !isProviderIdentitySwitch(node)) return; + const offset = typeof node.start === 'number' ? node.start : 0; + violations.push({ + rule: COMMAND_PROVIDER_POLICY_RULE, + file, + line: source.slice(0, offset).split('\n').length, + message: + 'command branches on provider identity; declare a semantic capability in src/cli/connection/provider-policy.ts', + }); + }); + } + return violations; +} diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index 2b3e558172..b4b770c340 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -2,6 +2,8 @@ import { fingerprint, type RemoteConnectionState } from '../../remote/remote-con import type { ConnectVerification } from '../connection/connect-provider-adapters.ts'; import { connectionProviderLeaseKind, + connectionProviderRequiresAppAttachment, + connectionProviderSupportsArtifacts, connectionProviderSupportsDeferredAppSelection, } from '../connection/provider-policy.ts'; import { shellQuoteIfNeeded } from '@agent-device/host-kit/command'; @@ -265,7 +267,7 @@ function requiresInstall(verification?: ConnectVerification): boolean { } function supportsProviderArtifacts(verification?: ConnectVerification): boolean { - return verification?.provider === 'browserstack' || verification?.provider === 'aws-device-farm'; + return connectionProviderSupportsArtifacts(verification?.provider); } function missingAttachedAppRecovery(verification?: ConnectVerification): string[] { @@ -344,7 +346,7 @@ function appIdPlaceholder(platform: RemoteConnectionState['platform']): string { } function missingAppLabel(state: RemoteConnectionState): string { - if (state.leaseProvider === 'aws-device-farm') return 'not attached'; + if (connectionProviderRequiresAppAttachment(state.leaseProvider)) return 'not attached'; if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { return 'not installed yet'; } diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 6fe31951b3..fb5535a20b 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -34,6 +34,7 @@ import type { CloudProviderSessionResult } from '@agent-device/contracts/observa import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; import { + connectionProviderLeaseKind, connectionProviderRequiresRemoteDaemon, connectionProviderSupportsDeferredAppSelection, } from '../connection/provider-policy.ts'; @@ -387,7 +388,9 @@ type ConnectionLeasePolicy = { }; function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { - if (state.leaseProvider === 'proxy') return PROXY_CONNECTION_LEASE_POLICY; + if (connectionProviderLeaseKind(state.leaseProvider) === 'proxy') { + return PROXY_CONNECTION_LEASE_POLICY; + } if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; } diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index c0ce8ba032..bfe31db566 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -6,6 +6,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { isRemoteBridgeBackend } from './remote-bridge.ts'; import type { CliFlags } from '@agent-device/contracts/command'; +import { connectionProviderSupportsDirectPortReverse } from '../connection/provider-policy.ts'; const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; @@ -168,7 +169,7 @@ function shouldConfigureDirectReverse( const { flags } = options; if (!flags) return false; return ( - flags.leaseProvider === 'limrun' && + connectionProviderSupportsDirectPortReverse(flags.leaseProvider) && flags.leaseBackend === 'android-instance' && flags.metroProxyBaseUrl === undefined && options.configureDirectPortReverse !== undefined diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index e6be84e13d..a7dd8c1a15 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { connectionProviderSupportsDeferredAppSelection } from './provider-policy.ts'; +import { + connectionProviderRequiresAppAttachment, + connectionProviderSupportsArtifacts, + connectionProviderSupportsDeferredAppSelection, + connectionProviderSupportsDirectPortReverse, +} from './provider-policy.ts'; test('only providers declaring deferred app selection use app catalog before allocation', () => { assert.equal(connectionProviderSupportsDeferredAppSelection('limrun'), true); @@ -9,3 +14,13 @@ test('only providers declaring deferred app selection use app catalog before all assert.equal(connectionProviderSupportsDeferredAppSelection('proxy'), false); assert.equal(connectionProviderSupportsDeferredAppSelection(undefined), false); }); + +test('provider capabilities stay declared outside command implementations', () => { + assert.equal(connectionProviderRequiresAppAttachment('aws-device-farm'), true); + assert.equal(connectionProviderRequiresAppAttachment('browserstack'), false); + assert.equal(connectionProviderSupportsArtifacts('aws-device-farm'), true); + assert.equal(connectionProviderSupportsArtifacts('browserstack'), true); + assert.equal(connectionProviderSupportsArtifacts('limrun'), false); + assert.equal(connectionProviderSupportsDirectPortReverse('limrun'), true); + assert.equal(connectionProviderSupportsDirectPortReverse('aws-device-farm'), false); +}); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index f489be1fea..a6c02dd0c3 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -39,6 +39,18 @@ export function connectionProviderSupportsDeferredAppSelection( return isDirectDeviceConnectProvider(provider) && DEFERRED_APP_SELECTION_PROVIDERS.has(provider); } +export function connectionProviderRequiresAppAttachment(provider: string | undefined): boolean { + return provider === CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm; +} + +export function connectionProviderSupportsArtifacts(provider: string | undefined): boolean { + return isCloudWebDriverProviderName(provider); +} + +export function connectionProviderSupportsDirectPortReverse(provider: string | undefined): boolean { + return provider === 'limrun'; +} + export function connectionProviderLeaseKind( provider: string | undefined, ): 'proxy' | 'direct-device-provider' | 'remote-provider' { From 3db638fc74c3eeaeea28afc9dc253664dd72dade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 13:05:56 +0200 Subject: [PATCH 06/11] refactor: restrict provider imports from commands --- oxlint.config.ts | 24 +++++++++++++++++++++ packages/provider-limrun/src/app-catalog.ts | 2 +- src/cli/commands/connection-runtime.ts | 4 ++-- src/cli/connection/provider-policy.test.ts | 4 ++++ src/cli/connection/provider-policy.ts | 4 ++++ 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index 58843a7412..b1eb981ec0 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -71,6 +71,30 @@ export default defineConfig({ ], }, }, + { + files: ['src/commands/**/*.ts', 'src/cli/commands/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'node:child_process', + message: + 'Use process helpers from @agent-device/host-kit/command instead of importing node:child_process directly.', + }, + ], + patterns: [ + { + group: ['@agent-device/provider-*'], + message: + 'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.', + }, + ], + }, + ], + }, + }, { files: [ 'packages/host-kit/src/internal/exec.ts', diff --git a/packages/provider-limrun/src/app-catalog.ts b/packages/provider-limrun/src/app-catalog.ts index 2f6c097dbc..13028f8f1e 100644 --- a/packages/provider-limrun/src/app-catalog.ts +++ b/packages/provider-limrun/src/app-catalog.ts @@ -95,5 +95,5 @@ function appIdentityValues(app: InstalledAppIdentity): string[] { } function appIdentityKey(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]+/g, ''); + return value.toLowerCase().replaceAll(/[^a-z0-9]+/g, ''); } diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index fb5535a20b..b68331142f 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -37,9 +37,9 @@ import { connectionProviderLeaseKind, connectionProviderRequiresRemoteDaemon, connectionProviderSupportsDeferredAppSelection, + connectionProviderUsesCloudWebDriverLease, } from '../connection/provider-policy.ts'; import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; -import { isCloudWebDriverProviderName } from '@agent-device/provider-webdriver'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; const leaseDeferredCommands = new Set([ @@ -394,7 +394,7 @@ function connectionLeasePolicyForState(state: RemoteConnectionState): Connection if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; } - if (isCloudWebDriverProviderName(state.leaseProvider)) { + if (connectionProviderUsesCloudWebDriverLease(state.leaseProvider)) { return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; } return DEFAULT_CONNECTION_LEASE_POLICY; diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index a7dd8c1a15..e80a01a5e4 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -5,6 +5,7 @@ import { connectionProviderSupportsArtifacts, connectionProviderSupportsDeferredAppSelection, connectionProviderSupportsDirectPortReverse, + connectionProviderUsesCloudWebDriverLease, } from './provider-policy.ts'; test('only providers declaring deferred app selection use app catalog before allocation', () => { @@ -23,4 +24,7 @@ test('provider capabilities stay declared outside command implementations', () = assert.equal(connectionProviderSupportsArtifacts('limrun'), false); assert.equal(connectionProviderSupportsDirectPortReverse('limrun'), true); assert.equal(connectionProviderSupportsDirectPortReverse('aws-device-farm'), false); + assert.equal(connectionProviderUsesCloudWebDriverLease('browserstack'), true); + assert.equal(connectionProviderUsesCloudWebDriverLease('aws-device-farm'), true); + assert.equal(connectionProviderUsesCloudWebDriverLease('limrun'), false); }); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index a6c02dd0c3..bebe9ad8ee 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -51,6 +51,10 @@ export function connectionProviderSupportsDirectPortReverse(provider: string | u return provider === 'limrun'; } +export function connectionProviderUsesCloudWebDriverLease(provider: string | undefined): boolean { + return isCloudWebDriverProviderName(provider); +} + export function connectionProviderLeaseKind( provider: string | undefined, ): 'proxy' | 'direct-device-provider' | 'remote-provider' { From 69e7ee83a2ec9599dea14a9826e0e2be10a5d3e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 13:19:31 +0200 Subject: [PATCH 07/11] refactor: keep provider guard lightweight --- scripts/layering/check.ts | 3 - .../layering/command-provider-policy.test.ts | 32 --------- scripts/layering/command-provider-policy.ts | 67 ------------------- 3 files changed, 102 deletions(-) delete mode 100644 scripts/layering/command-provider-policy.test.ts delete mode 100644 scripts/layering/command-provider-policy.ts diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index c343d64b8f..920918ccc6 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -101,7 +101,6 @@ import { runtimeExecutionIntegrityViolations } from './runtime-execution-policy. import { sourceExecutionCompatibilityViolations } from './source-execution-policy.ts'; import { sessionResourceOwnershipViolations } from './session-resource-ownership.ts'; import { applicationLifecycleOwnershipViolations } from './application-lifecycle-policy.ts'; -import { commandProviderPolicyViolations } from './command-provider-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', @@ -536,7 +535,6 @@ export const LAYERING_RULE_IDS = [ 'record-runtime-ownership', 'session-resource-ownership', 'application-lifecycle-ownership', - 'command-provider-policy', 'contracts-implementation-authority', 'substrate-domain-shape', 'selector-pipeline-ownership', @@ -563,7 +561,6 @@ export const LAYERING_RULES: Readonly> = { 'session-resource-ownership': (context) => sessionResourceOwnershipViolations(context.sources), 'application-lifecycle-ownership': (context) => applicationLifecycleOwnershipViolations(context.sources), - 'command-provider-policy': (context) => commandProviderPolicyViolations(context.sources), 'contracts-implementation-authority': (context) => checkContractsImplementationAuthority(context.sources), 'substrate-domain-shape': (context) => diff --git a/scripts/layering/command-provider-policy.test.ts b/scripts/layering/command-provider-policy.test.ts deleted file mode 100644 index c1e76e5391..0000000000 --- a/scripts/layering/command-provider-policy.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { commandProviderPolicyViolations } from './command-provider-policy.ts'; - -test('command implementations cannot branch on provider identity', () => { - const violations = commandProviderPolicyViolations( - new Map([ - ['src/cli/commands/open.ts', `if (state.leaseProvider === 'limrun') open();`], - ['src/commands/apps.ts', `if ('browserstack' !== verification['provider']) return;`], - [ - 'src/cli/commands/connect.ts', - `if (verification?.provider === 'aws-device-farm') reconnect();`, - ], - ['src/cli/commands/session.ts', `switch (state.leaseProvider) { case 'proxy': connect(); }`], - ['src/cli/connection/provider-policy.ts', `export const supported = provider === 'limrun';`], - [ - 'src/cli/commands/devices.ts', - `if (connectionProviderSupportsApps(state.leaseProvider)) list();`, - ], - ]), - ); - - assert.deepEqual( - violations.map(({ file, line }) => ({ file, line })), - [ - { file: 'src/cli/commands/open.ts', line: 1 }, - { file: 'src/commands/apps.ts', line: 1 }, - { file: 'src/cli/commands/connect.ts', line: 1 }, - { file: 'src/cli/commands/session.ts', line: 1 }, - ], - ); -}); diff --git a/scripts/layering/command-provider-policy.ts b/scripts/layering/command-provider-policy.ts deleted file mode 100644 index fbbc0959f7..0000000000 --- a/scripts/layering/command-provider-policy.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { parseSync } from 'oxc-parser'; -import { memberName, visitAst } from './layering-ast.ts'; -import type { LayeringViolation } from './model.ts'; - -export const COMMAND_PROVIDER_POLICY_RULE = 'R71 command-provider-policy'; - -const COMMAND_ROOTS = ['src/commands/', 'src/cli/commands/']; -const PROVIDER_FIELDS = new Set(['leaseProvider', 'provider']); -const EQUALITY_OPERATORS = new Set(['==', '===', '!=', '!==']); - -function isCommandFile(file: string): boolean { - return COMMAND_ROOTS.some((root) => file.startsWith(root)); -} - -function isStringLiteral(node: unknown): boolean { - if (node === null || typeof node !== 'object') return false; - const value = node as Record; - return value.type === 'Literal' && typeof value.value === 'string'; -} - -function isProviderField(node: unknown): boolean { - if (node === null || typeof node !== 'object') return false; - const value = node as Record; - if (value.type === 'ChainExpression') return isProviderField(value.expression); - return value.type === 'MemberExpression' && PROVIDER_FIELDS.has(memberName(value) ?? ''); -} - -function isProviderIdentityComparison(node: Record): boolean { - if (node.type !== 'BinaryExpression' || !EQUALITY_OPERATORS.has(String(node.operator))) { - return false; - } - return ( - (isProviderField(node.left) && isStringLiteral(node.right)) || - (isStringLiteral(node.left) && isProviderField(node.right)) - ); -} - -function isProviderIdentitySwitch(node: Record): boolean { - if (node.type !== 'SwitchStatement' || !isProviderField(node.discriminant)) return false; - const cases = Array.isArray(node.cases) ? node.cases : []; - return cases.some((entry) => { - if (entry === null || typeof entry !== 'object') return false; - return isStringLiteral((entry as Record).test); - }); -} - -export function commandProviderPolicyViolations( - sources: ReadonlyMap, -): LayeringViolation[] { - const violations: LayeringViolation[] = []; - for (const [file, source] of sources) { - if (!isCommandFile(file)) continue; - const program = parseSync(file, source).program as Record; - visitAst(program, (node) => { - if (!isProviderIdentityComparison(node) && !isProviderIdentitySwitch(node)) return; - const offset = typeof node.start === 'number' ? node.start : 0; - violations.push({ - rule: COMMAND_PROVIDER_POLICY_RULE, - file, - line: source.slice(0, offset).split('\n').length, - message: - 'command branches on provider identity; declare a semantic capability in src/cli/connection/provider-policy.ts', - }); - }); - } - return violations; -} From e9725b6e2103d9eafb8806a7e62526b8df5358cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 14:22:59 +0200 Subject: [PATCH 08/11] fix: enforce provider-independent commands --- oxlint.config.ts | 26 +++++++++ src/cli/commands/connection-presentation.ts | 31 +++++----- src/cli/commands/connection-runtime.ts | 57 +++++++------------ src/cli/commands/connection.ts | 42 ++++++-------- src/cli/commands/react-devtools.ts | 4 +- .../connection/connect-provider-adapters.ts | 17 +++--- src/cli/connection/provider-policy.test.ts | 14 +++++ src/cli/connection/provider-policy.ts | 40 ++++++++++++- src/cli/connection/provider-session-result.ts | 7 +++ src/remote/remote-connection-state.ts | 30 +++++++++- 10 files changed, 180 insertions(+), 88 deletions(-) create mode 100644 src/cli/connection/provider-session-result.ts diff --git a/oxlint.config.ts b/oxlint.config.ts index b1eb981ec0..0f4a3f73a0 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -93,6 +93,32 @@ export default defineConfig({ ], }, ], + 'no-restricted-properties': [ + 'error', + { + property: 'leaseProvider', + message: + 'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.', + }, + ], + }, + }, + { + files: ['src/cli/commands/**/*.ts'], + rules: { + 'no-restricted-properties': [ + 'error', + { + property: 'leaseProvider', + message: + 'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.', + }, + { + property: 'provider', + message: + 'Connection commands must ask src/cli/connection/provider-policy.ts for provider capabilities.', + }, + ], }, }, { diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index b4b770c340..ae983054de 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -1,10 +1,12 @@ -import { fingerprint, type RemoteConnectionState } from '../../remote/remote-connection-state.ts'; +import { + fingerprint, + remoteConnectionProviderOutput, + type RemoteConnectionState, +} from '../../remote/remote-connection-state.ts'; import type { ConnectVerification } from '../connection/connect-provider-adapters.ts'; import { - connectionProviderLeaseKind, - connectionProviderRequiresAppAttachment, - connectionProviderSupportsArtifacts, - connectionProviderSupportsDeferredAppSelection, + connectionProviderCapabilitiesForLease, + connectionProviderCapabilitiesForVerification, } from '../connection/provider-policy.ts'; import { shellQuoteIfNeeded } from '@agent-device/host-kit/command'; @@ -36,7 +38,8 @@ export function buildLeasePreparationNotice( verification?: ConnectVerification, ): LeasePreparationNotice | undefined { if (state.leaseId) return undefined; - const leaseKind = connectionProviderLeaseKind(state.leaseProvider); + const capabilities = connectionProviderCapabilitiesForLease(state); + const leaseKind = capabilities.leaseKind; if (leaseKind === 'proxy') { return { status: 'deferred', @@ -45,7 +48,7 @@ export function buildLeasePreparationNotice( 'No live device session has been created. Run devices to inspect inventory without allocating, then open when ready.', }; } - if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + if (capabilities.supportsDeferredAppSelection) { return { status: 'deferred', nextSteps: buildConnectWorkflow(state, verification).nextSteps, @@ -145,7 +148,7 @@ export function serializeConnectionState(options: { leaseAllocated: Boolean(state.leaseId), leaseId: state.leaseId, leaseBackend: state.leaseBackend, - leaseProvider: state.leaseProvider, + ...remoteConnectionProviderOutput(state), platform: state.platform, target: state.target, remoteConfig: state.remoteConfigPath, @@ -234,7 +237,8 @@ function buildUnscopedConnectWorkflow( state: RemoteConnectionState, verification?: ConnectVerification, ): Pick { - const leaseKind = connectionProviderLeaseKind(state.leaseProvider); + const capabilities = connectionProviderCapabilitiesForLease(state); + const leaseKind = capabilities.leaseKind; if (leaseKind === 'proxy') { return { nextSteps: [ @@ -246,7 +250,7 @@ function buildUnscopedConnectWorkflow( if (!verification && leaseKind === 'direct-device-provider') { return { nextSteps: defaultDirectProviderLifecycle() }; } - if (connectionProviderSupportsDeferredAppSelection(verification?.provider)) { + if (connectionProviderCapabilitiesForVerification(verification).supportsDeferredAppSelection) { return { nextSteps: ['agent-device apps', 'agent-device open '], }; @@ -267,7 +271,7 @@ function requiresInstall(verification?: ConnectVerification): boolean { } function supportsProviderArtifacts(verification?: ConnectVerification): boolean { - return connectionProviderSupportsArtifacts(verification?.provider); + return connectionProviderCapabilitiesForVerification(verification).supportsArtifacts; } function missingAttachedAppRecovery(verification?: ConnectVerification): string[] { @@ -346,8 +350,9 @@ function appIdPlaceholder(platform: RemoteConnectionState['platform']): string { } function missingAppLabel(state: RemoteConnectionState): string { - if (connectionProviderRequiresAppAttachment(state.leaseProvider)) return 'not attached'; - if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + const capabilities = connectionProviderCapabilitiesForLease(state); + if (capabilities.requiresAppAttachment) return 'not attached'; + if (capabilities.supportsDeferredAppSelection) { return 'not installed yet'; } return 'not available'; diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index b68331142f..c643edde45 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -19,6 +19,7 @@ import { buildRemoteConnectionDaemonState, buildRemoteConnectionRequestMetadata, hashRemoteConfigFile, + mergeRemoteConnectionRequestMetadata, readRemoteConnectionState, writeRemoteConnectionState, type RemoteConnectionState, @@ -33,12 +34,7 @@ import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; -import { - connectionProviderLeaseKind, - connectionProviderRequiresRemoteDaemon, - connectionProviderSupportsDeferredAppSelection, - connectionProviderUsesCloudWebDriverLease, -} from '../connection/provider-policy.ts'; +import { connectionProviderCapabilitiesForLease } from '../connection/provider-policy.ts'; import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; @@ -116,7 +112,7 @@ export async function materializeRemoteConnectionForCommand(options: { ); const nextFlags = { ...mergedFlags, session: state.session }; if ( - connectionProviderSupportsDeferredAppSelection(state.leaseProvider) && + connectionProviderCapabilitiesForLease(state).supportsDeferredAppSelection && command === PUBLIC_COMMANDS.open && typeof options.positionals?.[0] === 'string' ) { @@ -362,13 +358,12 @@ function buildMaterializedLeaseState( leaseBackend: LeaseBackend, flags: CliFlags, ): RemoteConnectionState { + const connection = mergeRemoteConnectionRequestMetadata(lease, state); return { ...state, leaseId: lease.leaseId, leaseBackend, - leaseProvider: lease.leaseProvider ?? state.leaseProvider, - clientId: lease.clientId ?? state.clientId, - deviceKey: lease.deviceKey ?? state.deviceKey, + ...connection, platform: state.platform ?? flags.platform, target: state.target ?? flags.target, updatedAt: new Date().toISOString(), @@ -388,13 +383,14 @@ type ConnectionLeasePolicy = { }; function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { - if (connectionProviderLeaseKind(state.leaseProvider) === 'proxy') { + const capabilities = connectionProviderCapabilitiesForLease(state); + if (capabilities.leaseKind === 'proxy') { return PROXY_CONNECTION_LEASE_POLICY; } - if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + if (capabilities.supportsDeferredAppSelection) { return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; } - if (connectionProviderUsesCloudWebDriverLease(state.leaseProvider)) { + if (capabilities.usesCloudWebDriverLease) { return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; } return DEFAULT_CONNECTION_LEASE_POLICY; @@ -525,9 +521,7 @@ export async function releaseRemoteConnectionLease( daemonAuthToken, daemonTransport: state.daemon?.transport, daemonServerMode: state.daemon?.serverMode, - leaseProvider: state.leaseProvider, - clientId: state.clientId, - deviceKey: state.deviceKey, + ...buildRemoteConnectionRequestMetadata(state), }); return result; } @@ -676,14 +670,13 @@ async function releaseAcquiredLeaseOnWriteFailure( ): Promise { if (!lease) return; try { + const connection = mergeRemoteConnectionRequestMetadata(state, lease); await client.leases.release({ tenant: state.tenant, runId: state.runId, leaseId: lease.leaseId, leaseBackend: state.leaseBackend ?? lease.backend, - leaseProvider: state.leaseProvider ?? lease.leaseProvider, - clientId: state.clientId ?? lease.clientId, - deviceKey: state.deviceKey ?? lease.deviceKey, + ...connection, }); } catch { // Preserve the state-write failure; cleanup is best-effort. @@ -780,7 +773,10 @@ function createRemoteConnectionStateFromFlags( 'remote command requires runId in remote config or via --run-id .', ); } - if (!flags.daemonBaseUrl && connectionProviderRequiresRemoteDaemon(profile.leaseProvider)) { + if ( + !flags.daemonBaseUrl && + connectionProviderCapabilitiesForLease(profile).requiresRemoteDaemon + ) { throw new AppError( 'INVALID_ARGS', 'remote command requires daemonBaseUrl in remote config, config, env, or --daemon-base-url.', @@ -797,9 +793,7 @@ function createRemoteConnectionStateFromFlags( runId: flags.runId, leaseId: flags.leaseId, leaseBackend: flags.leaseBackend ?? resolveRequestedLeaseBackend(flags), - leaseProvider: profile.leaseProvider, - clientId: profile.clientId, - deviceKey: profile.deviceKey, + ...profile, platform: flags.platform, target: flags.target, connectedAt: now, @@ -814,14 +808,13 @@ async function allocateOrReuseLease( policy: ConnectionLeasePolicy, flags: CliFlags, ): Promise<{ lease: Lease; acquired: boolean }> { + const connection = buildRemoteConnectionRequestMetadata(state); if (state.leaseId && state.leaseBackend === leaseBackend) { const existing = await heartbeatOrAllocateLease(client, state.leaseId, { tenant: state.tenant, runId: state.runId, leaseBackend, - leaseProvider: state.leaseProvider, - clientId: state.clientId, - deviceKey: state.deviceKey, + ...connection, ttlMs: policy.ttlMs(state), }); if (existing) return { lease: existing, acquired: false }; @@ -830,9 +823,7 @@ async function allocateOrReuseLease( tenant: state.tenant, runId: state.runId, leaseBackend, - leaseProvider: state.leaseProvider, - clientId: state.clientId, - deviceKey: state.deviceKey, + ...connection, ttlMs: policy.ttlMs(state), platform: state.platform ?? flags.platform, target: state.target ?? flags.target, @@ -981,14 +972,8 @@ async function heartbeatOrAllocateLease( ): Promise { try { return await client.leases.heartbeat({ - tenant: scope.tenant, - runId: scope.runId, + ...scope, leaseId, - leaseBackend: scope.leaseBackend, - leaseProvider: scope.leaseProvider, - clientId: scope.clientId, - deviceKey: scope.deviceKey, - ttlMs: scope.ttlMs, }); } catch (error) { if (isInactiveLeaseError(error)) return undefined; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 2efdff6ca2..67c275043c 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -6,7 +6,10 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; import { readActiveConnectionState, + buildRemoteConnectionRequestMetadata, + mergeRemoteConnectionRequestMetadata, readRemoteConnectionState, + remoteConnectionLeaseIdentityMatches, removeRemoteConnectionState, writeRemoteConnectionState, type RemoteConnectionState, @@ -15,14 +18,15 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { connectProviderNamesForError, - connectionProviderRequiresRemoteDaemon, + connectionProviderCapabilitiesForLease, isConnectProviderName, type ConnectProvider, } from '../connection/provider-policy.ts'; import { resolveConnectProviderProfile, - verifyConnectProvider, + verifyResolvedConnectProvider, } from '../connection/connect-provider-adapters.ts'; +import { providerSessionResult } from '../connection/provider-session-result.ts'; import { hasDeferredMetroConfig, releaseRemoteConnectionLease, @@ -69,11 +73,7 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, connection: connectionMetadata, daemon: context.daemon, }); - const verification = await verifyConnectProvider({ - provider: resolved.provider, - flags: connectFlags, - env: process.env, - }); + const verification = await verifyResolvedConnectProvider(resolved); const state = buildConnectedState({ flags: connectFlags, scope, @@ -126,7 +126,7 @@ function readRequiredConnectScope( } if ( !flags.daemonBaseUrl && - connectionProviderRequiresRemoteDaemon(connectionMetadata?.leaseProvider) + connectionProviderCapabilitiesForLease(connectionMetadata ?? {}).requiresRemoteDaemon ) { throw new AppError( 'INVALID_ARGS', @@ -185,12 +185,12 @@ function buildConnectionLeaseBinding( RemoteConnectionState, 'clientId' | 'deviceKey' | 'leaseBackend' | 'leaseId' | 'leaseProvider' > { + const connection = mergeRemoteConnectionRequestMetadata(connectionMetadata ?? {}, previous ?? {}); return { leaseId: previous?.leaseId, leaseBackend: previous?.leaseBackend ?? resolveRequestedLeaseBackend(flags), - leaseProvider: connectionMetadata?.leaseProvider ?? previous?.leaseProvider, - clientId: connectionMetadata?.clientId ?? previous?.clientId, - deviceKey: previous?.deviceKey ?? connectionMetadata?.deviceKey, + ...connection, + deviceKey: previous?.deviceKey ?? connection.deviceKey, }; } @@ -241,12 +241,7 @@ function readRemoteConfigConnectionMetadata( cwd: process.cwd(), env: process.env, }).profile; - const metadata = { - leaseProvider: profile.leaseProvider, - clientId: profile.clientId, - deviceKey: profile.deviceKey, - }; - return Object.values(metadata).some((value) => value !== undefined) ? metadata : undefined; + return buildRemoteConnectionRequestMetadata(profile); } export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { @@ -260,9 +255,9 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) let providerData: CloudProviderSessionResult | undefined; if (state.leaseId || state.runtime || state.metro) { try { - providerData = ( - await client.sessions.close({ session: connectedSession, shutdown: flags.shutdown }) - ).provider; + providerData = providerSessionResult( + await client.sessions.close({ session: connectedSession, shutdown: flags.shutdown }), + ); } catch { // Disconnect is idempotent; the session may already be closed. } @@ -274,7 +269,7 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) try { const release = await releaseRemoteConnectionLease(client, state, flags.daemonAuthToken); released = release.released; - providerData ??= release.provider; + providerData ??= providerSessionResult(release); } catch { // Bridges may release on close or be unreachable; local state still needs cleanup. } @@ -430,13 +425,12 @@ function optionalConnectionFieldsMatch( state: RemoteConnectionState, options: Parameters[1], ): boolean { - return [ + const fieldsMatch = [ [state.leaseBackend, options.desiredLeaseBackend], [state.platform, options.flags.platform], [state.target, options.flags.target], - [state.leaseProvider, options.connection?.leaseProvider], - [state.clientId, options.connection?.clientId], ].every(([left, right]) => right === undefined || left === right); + return fieldsMatch && remoteConnectionLeaseIdentityMatches(state, options.connection); } function isSameDaemonState( diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index bfe31db566..68c1dbcc48 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -6,7 +6,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { isRemoteBridgeBackend } from './remote-bridge.ts'; import type { CliFlags } from '@agent-device/contracts/command'; -import { connectionProviderSupportsDirectPortReverse } from '../connection/provider-policy.ts'; +import { connectionProviderCapabilitiesForLease } from '../connection/provider-policy.ts'; const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; @@ -169,7 +169,7 @@ function shouldConfigureDirectReverse( const { flags } = options; if (!flags) return false; return ( - connectionProviderSupportsDirectPortReverse(flags.leaseProvider) && + connectionProviderCapabilitiesForLease(flags).supportsDirectPortReverse && flags.leaseBackend === 'android-instance' && flags.metroProxyBaseUrl === undefined && options.configureDirectPortReverse !== undefined diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 572fbf5b63..27d9c5c564 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -112,13 +112,10 @@ export async function resolveConnectProviderProfile(options: { return { ...profile, provider }; } -export async function verifyConnectProvider(options: { - provider?: ConnectProvider; - flags: CliFlags; - env?: EnvMap; -}): Promise { - const env = options.env ?? process.env; - if (!options.provider) { +export async function verifyResolvedConnectProvider( + resolved: ResolvedConnectProfile, +): Promise { + if (!resolved.provider) { return { service: 'remote provider', status: 'configured', @@ -126,9 +123,9 @@ export async function verifyConnectProvider(options: { 'Remote connection profile loaded. Access is checked by the first remote command.', }; } - return await CONNECT_PROVIDER_ADAPTERS[options.provider].verify({ - flags: options.flags, - env, + return await CONNECT_PROVIDER_ADAPTERS[resolved.provider].verify({ + flags: resolved.flags, + env: process.env, }); } diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index e80a01a5e4..738f30fc2d 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { + connectionProviderCapabilitiesForLease, + connectionProviderCapabilitiesForVerification, connectionProviderRequiresAppAttachment, connectionProviderSupportsArtifacts, connectionProviderSupportsDeferredAppSelection, @@ -28,3 +30,15 @@ test('provider capabilities stay declared outside command implementations', () = assert.equal(connectionProviderUsesCloudWebDriverLease('aws-device-farm'), true); assert.equal(connectionProviderUsesCloudWebDriverLease('limrun'), false); }); + +test('provider carriers resolve to semantic capabilities before commands inspect them', () => { + assert.equal( + connectionProviderCapabilitiesForLease({ leaseProvider: 'limrun' }) + .supportsDeferredAppSelection, + true, + ); + assert.equal( + connectionProviderCapabilitiesForVerification({ provider: 'browserstack' }).supportsArtifacts, + true, + ); +}); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index bebe9ad8ee..bc57074c49 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -7,6 +7,16 @@ import { export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; +type ConnectionProviderCapabilities = { + leaseKind: 'proxy' | 'direct-device-provider' | 'remote-provider'; + requiresAppAttachment: boolean; + requiresRemoteDaemon: boolean; + supportsArtifacts: boolean; + supportsDeferredAppSelection: boolean; + supportsDirectPortReverse: boolean; + usesCloudWebDriverLease: boolean; +}; + const DEFERRED_APP_SELECTION_PROVIDERS = new Set(['limrun']); export function isConnectProviderName(value: string | undefined): value is ConnectProvider { @@ -29,7 +39,7 @@ export function connectProviderNamesForError(): string { ].join(', '); } -export function connectionProviderRequiresRemoteDaemon(provider: string | undefined): boolean { +function connectionProviderRequiresRemoteDaemon(provider: string | undefined): boolean { return !isDirectDeviceConnectProvider(provider); } @@ -55,10 +65,36 @@ export function connectionProviderUsesCloudWebDriverLease(provider: string | und return isCloudWebDriverProviderName(provider); } -export function connectionProviderLeaseKind( +function connectionProviderLeaseKind( provider: string | undefined, ): 'proxy' | 'direct-device-provider' | 'remote-provider' { if (provider === 'proxy') return 'proxy'; if (isDirectDeviceConnectProvider(provider)) return 'direct-device-provider'; return 'remote-provider'; } + +export function connectionProviderCapabilitiesForLease(source: { + leaseProvider?: string; +}): ConnectionProviderCapabilities { + return connectionProviderCapabilities(source.leaseProvider); +} + +export function connectionProviderCapabilitiesForVerification( + verification: { provider?: string } | undefined, +): ConnectionProviderCapabilities { + return connectionProviderCapabilities(verification?.provider); +} + +function connectionProviderCapabilities( + provider: string | undefined, +): ConnectionProviderCapabilities { + return { + leaseKind: connectionProviderLeaseKind(provider), + requiresAppAttachment: connectionProviderRequiresAppAttachment(provider), + requiresRemoteDaemon: connectionProviderRequiresRemoteDaemon(provider), + supportsArtifacts: connectionProviderSupportsArtifacts(provider), + supportsDeferredAppSelection: connectionProviderSupportsDeferredAppSelection(provider), + supportsDirectPortReverse: connectionProviderSupportsDirectPortReverse(provider), + usesCloudWebDriverLease: connectionProviderUsesCloudWebDriverLease(provider), + }; +} diff --git a/src/cli/connection/provider-session-result.ts b/src/cli/connection/provider-session-result.ts new file mode 100644 index 0000000000..a4df9ac954 --- /dev/null +++ b/src/cli/connection/provider-session-result.ts @@ -0,0 +1,7 @@ +import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; + +export function providerSessionResult(response: { + provider?: CloudProviderSessionResult; +}): CloudProviderSessionResult | undefined { + return response.provider; +} diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index 1b49adbdbb..f39e4a78ef 100644 --- a/src/remote/remote-connection-state.ts +++ b/src/remote/remote-connection-state.ts @@ -170,11 +170,39 @@ export function resolveRemoteConnectionDefaults(options: { } export function buildRemoteConnectionRequestMetadata( - state: RemoteConnectionState, + state: RemoteConnectionRequestMetadata, ): RemoteConnectionRequestMetadata | undefined { return leaseScopeToConnectionMetadata(leaseScopeFromOptions(state)); } +export function mergeRemoteConnectionRequestMetadata( + primary: RemoteConnectionRequestMetadata, + fallback: RemoteConnectionRequestMetadata, +): RemoteConnectionRequestMetadata { + return { + leaseProvider: primary.leaseProvider ?? fallback.leaseProvider, + clientId: primary.clientId ?? fallback.clientId, + deviceKey: primary.deviceKey ?? fallback.deviceKey, + }; +} + +export function remoteConnectionProviderOutput( + state: RemoteConnectionState, +): Pick { + return { leaseProvider: state.leaseProvider }; +} + +export function remoteConnectionLeaseIdentityMatches( + state: RemoteConnectionState, + metadata: RemoteConnectionRequestMetadata | undefined, +): boolean { + if (!metadata) return true; + return ( + (metadata.leaseProvider === undefined || state.leaseProvider === metadata.leaseProvider) && + (metadata.clientId === undefined || state.clientId === metadata.clientId) + ); +} + export function hashRemoteConfigFile(configPath: string): string { try { return crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); From fe1ce5b9d4e06eccca52df77e9abf5b1768dfc50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 15:47:08 +0200 Subject: [PATCH 09/11] fix: enforce provider catalog lease admission --- src/__tests__/provider-device-runtime.test.ts | 2 + .../__tests__/parity.test.ts | 16 +++- .../daemon-command-descriptor.ts | 9 ++- src/core/command-descriptor/registry.ts | 6 +- .../__tests__/daemon-command-registry.test.ts | 22 ++++-- .../__tests__/request-admission.test.ts | 38 ++++++++++ .../request-router-apps-admission.test.ts | 76 +++++++++++++++++++ src/daemon/daemon-command-registry.ts | 8 +- src/daemon/lease-lifecycle.ts | 5 +- src/daemon/request-admission.ts | 5 +- src/daemon/request-execution-scope.ts | 2 + src/daemon/request-router.ts | 4 + src/daemon/server/daemon-runtime.ts | 1 + src/provider-device-runtime.ts | 4 + 14 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 src/daemon/__tests__/request-router-apps-admission.test.ts diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 327ec6b5a7..625fd8e45e 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -28,6 +28,7 @@ test('provider device runtime registry delegates lifecycle, inventory, and inter }); await requestProviders.recoverExpiredLease?.(world.lease); assert.deepEqual(requestProviders.recoverableProviderIds, ['hit']); + assert.deepEqual(requestProviders.providerAppCatalogIds, ['hit']); assert.deepEqual(world.recoveredLeases, [world.lease]); assert.deepEqual( await requestProviders.deviceInventorySource?.discover( @@ -142,6 +143,7 @@ function makeProviderRuntimeWorld() { interactor, portReverseResult: { provider: 'hit' }, }); + hitRuntime.appCatalog = async () => []; hitRuntime.recoverExpiredLease = async (expiredLease) => { recoveredLeases.push(expiredLease); }; diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 9c2e5b1e7b..152e338e56 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -30,6 +30,7 @@ import { const DAEMON_FUNCTION_TRAITS = [ 'allowSessionlessDefaultDevice', 'skipSessionlessProviderDevice', + 'sessionlessLeaseAdmissionExempt', ] as const; // Public commands that intentionally have no daemon route — they live only in the @@ -104,12 +105,21 @@ test('derived daemon descriptors preserve closure traits by presence and behavio const live = liveByCommand.get(derived.command); assert.ok(live, `${derived.command} present in hand table`); for (const trait of DAEMON_FUNCTION_TRAITS) { - const derivedFn = derived[trait] as ((req: DaemonRequest) => boolean) | undefined; - const liveFn = live[trait] as ((req: DaemonRequest) => boolean) | undefined; + const derivedFn = derived[trait] as + | ((req: DaemonRequest, context: { providerAppCatalogIds: readonly string[] }) => boolean) + | undefined; + const liveFn = live[trait] as + | ((req: DaemonRequest, context: { providerAppCatalogIds: readonly string[] }) => boolean) + | undefined; assert.equal(typeof derivedFn, typeof liveFn, `${derived.command} ${trait} presence`); if (typeof liveFn === 'function' && typeof derivedFn === 'function') { for (const request of sampleRequests(derived.command)) { - assert.equal(derivedFn(request), liveFn(request), `${derived.command} ${trait} behavior`); + const context = { providerAppCatalogIds: ['limrun'] }; + assert.equal( + derivedFn(request, context), + liveFn(request, context), + `${derived.command} ${trait} behavior`, + ); } } } diff --git a/src/core/command-descriptor/daemon-command-descriptor.ts b/src/core/command-descriptor/daemon-command-descriptor.ts index f93498b227..c0174ee747 100644 --- a/src/core/command-descriptor/daemon-command-descriptor.ts +++ b/src/core/command-descriptor/daemon-command-descriptor.ts @@ -21,6 +21,10 @@ export type DaemonRefFrameEffect = | RefFrameEffect | ((req: TRequest) => RefFrameEffect); +export type SessionlessLeaseAdmissionContext = Readonly<{ + providerAppCatalogIds: readonly string[]; +}>; + /** * Daemon route + request-policy traits for one command. Generic over the request * the closure traits read so core can declare the shape in terms of @@ -50,5 +54,8 @@ export type DaemonCommandDescriptor = { preferExplicitDeviceOverExistingSession?: boolean; allowSessionlessDefaultDevice?: (req: TRequest) => boolean; skipSessionlessProviderDevice?: (req: TRequest) => boolean; - sessionlessLeaseAdmissionExempt?: (req: TRequest) => boolean; + sessionlessLeaseAdmissionExempt?: ( + req: TRequest, + context: SessionlessLeaseAdmissionContext, + ) => boolean; }; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 0c9f7903ac..6b4036b60d 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -12,6 +12,7 @@ import { } from './timeout-policy.ts'; import { resolvePostActionObservationSupport } from './post-action-observation.ts'; import type { PostActionObservationSupport } from './post-action-observation.ts'; +import type { DaemonCommandDescriptor } from './daemon-command-descriptor.ts'; import { deployAppUse, readyMaterializeAndDeployAppUse, @@ -179,9 +180,12 @@ const isShardedTestRequest = (req: DispatchedCommand): boolean => const isPlainCloseRequest = (req: DispatchedCommand): boolean => (req.positionals?.length ?? 0) === 0; -const isDeferredProviderAppCatalogRequest = (req: DispatchedCommand): boolean => +const isDeferredProviderAppCatalogRequest: NonNullable< + DaemonCommandDescriptor['sessionlessLeaseAdmissionExempt'] +> = (req, context) => req.flags?.leaseId === undefined && typeof req.flags?.leaseProvider === 'string' && + context.providerAppCatalogIds.includes(req.flags.leaseProvider) && (req.flags?.platform === 'android' || req.flags?.platform === 'ios'); // ADR 0014 request-sensitive ref-frame resolvers. The action is the leading diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index 603ae4f5be..d5916ffa9b 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -82,17 +82,23 @@ test('daemon command registry preserves request admission traits', () => { assert.equal(isLeaseAdmissionExempt(PUBLIC_COMMANDS.open), false); assert.equal(shouldLockSessionExecution(PUBLIC_COMMANDS.open), true); assert.equal( - isSessionlessLeaseAdmissionExempt({ - ...makeRequest(PUBLIC_COMMANDS.apps), - flags: { platform: 'android', leaseProvider: 'limrun' }, - }), + isSessionlessLeaseAdmissionExempt( + { + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun' }, + }, + { providerAppCatalogIds: ['limrun'] }, + ), true, ); assert.equal( - isSessionlessLeaseAdmissionExempt({ - ...makeRequest(PUBLIC_COMMANDS.apps), - flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, - }), + isSessionlessLeaseAdmissionExempt( + { + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, + }, + { providerAppCatalogIds: ['limrun'] }, + ), false, ); }); diff --git a/src/daemon/__tests__/request-admission.test.ts b/src/daemon/__tests__/request-admission.test.ts index 9cbe7c1b5c..48d43d2c17 100644 --- a/src/daemon/__tests__/request-admission.test.ts +++ b/src/daemon/__tests__/request-admission.test.ts @@ -120,6 +120,44 @@ test('non-close commands on a tenant-isolated session still require a lease id', ); }); +test.each(['bogus', 'proxy', 'browserstack'])( + 'sessionless apps for non-catalog provider %s still requires a tenant lease', + (leaseProvider) => { + const registry = new LeaseRegistry(); + + assert.throws( + () => + assertRequestLeaseAdmission( + makeRequest({ + command: 'apps', + flags: { platform: 'ios', leaseProvider }, + meta: { tenantId: 'tenant-a', runId: 'run-1', sessionIsolation: 'tenant' }, + }), + registry, + undefined, + ), + /tenant isolation requires lease id/, + ); + }, +); + +test('sessionless apps admits a provider declared by the runtime app catalog', () => { + const registry = new LeaseRegistry(); + + const result = assertRequestLeaseAdmission( + makeRequest({ + command: 'apps', + flags: { platform: 'ios', leaseProvider: 'limrun' }, + meta: { tenantId: 'tenant-a', runId: 'run-1', sessionIsolation: 'tenant' }, + }), + registry, + undefined, + { providerAppCatalogIds: ['limrun'] }, + ); + + assert.equal(result, undefined); +}); + test('close still admits and heartbeats a real active lease', () => { let now = 1_000; const registry = new LeaseRegistry({ now: () => now }); diff --git a/src/daemon/__tests__/request-router-apps-admission.test.ts b/src/daemon/__tests__/request-router-apps-admission.test.ts new file mode 100644 index 0000000000..06eb486200 --- /dev/null +++ b/src/daemon/__tests__/request-router-apps-admission.test.ts @@ -0,0 +1,76 @@ +import { expect, test, vi } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import type { DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import type { DaemonRequest } from '../types.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +function createAppsAdmissionHarness(apps: readonly string[] | undefined) { + const providerAppCatalog = vi.fn(async () => apps); + const inspectFacts = vi.fn(async () => { + throw new Error('apps catalog must not inspect device facts'); + }); + const bind = vi.fn(async () => { + throw new Error('apps catalog must not bind a device'); + }); + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore: makeSessionStore('agent-device-apps-admission-'), + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: { + inspectFacts, + bind, + shutdown: async () => {}, + } satisfies DeviceRuntimeGateway, + providerAppCatalog, + providerAppCatalogIds: ['limrun'], + trackDownloadableArtifact: () => 'artifact-id', + }); + return { handler, providerAppCatalog, inspectFacts, bind }; +} + +function appsRequest(leaseProvider: string): DaemonRequest { + return { + token: 'test-token', + session: 'default', + command: 'apps', + positionals: [], + flags: { platform: 'ios', leaseProvider }, + meta: { tenantId: 'tenant-a', runId: 'run-1', sessionIsolation: 'tenant' }, + }; +} + +test.each(['bogus', 'proxy', 'browserstack'])( + 'tenant apps rejects non-catalog provider %s before provider or device access', + async (leaseProvider) => { + const { handler, providerAppCatalog, inspectFacts, bind } = + createAppsAdmissionHarness(undefined); + const response = await handler(appsRequest(leaseProvider)); + + expect(response.ok).toBe(false); + expect(response.ok === false && response.error.message).toMatch( + /tenant isolation requires lease id/, + ); + expect(providerAppCatalog).not.toHaveBeenCalled(); + expect(inspectFacts).not.toHaveBeenCalled(); + expect(bind).not.toHaveBeenCalled(); + }, +); + +test('tenant apps admits the runtime-declared catalog provider without device access', async () => { + const { handler, providerAppCatalog, inspectFacts, bind } = createAppsAdmissionHarness([ + 'Example.app.zip', + ]); + const response = await handler(appsRequest('limrun')); + + expect(response).toEqual({ ok: true, data: { apps: ['Example.app.zip'] } }); + expect(providerAppCatalog).toHaveBeenCalledTimes(1); + expect(inspectFacts).not.toHaveBeenCalled(); + expect(bind).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index 7d22448795..bc06f8e662 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -1,6 +1,7 @@ import { type DaemonCommandDescriptor, type DaemonCommandRoute, + type SessionlessLeaseAdmissionContext, type SessionCommandKind, } from '../core/command-descriptor/daemon-command-descriptor.ts'; import { deriveDaemonCommandDescriptors } from '../core/command-descriptor/derive.ts'; @@ -83,9 +84,12 @@ export function usesSessionlessDefaultProviderDevice(req: DaemonRequest): boolea return typeof allow === 'function' ? allow(req) : false; } -export function isSessionlessLeaseAdmissionExempt(req: DaemonRequest): boolean { +export function isSessionlessLeaseAdmissionExempt( + req: DaemonRequest, + context: SessionlessLeaseAdmissionContext, +): boolean { const exempt = getDaemonCommandDescriptor(req.command)?.sessionlessLeaseAdmissionExempt; - return typeof exempt === 'function' ? exempt(req) : false; + return typeof exempt === 'function' ? exempt(req, context) : false; } /** diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index b8fbfc243e..444b3ec5a4 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -104,10 +104,13 @@ export function admitRequestLeaseForLockedScope(params: { sessionName: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; + providerAppCatalogIds?: readonly string[]; }): DaemonRequest { const { sessionName, sessionStore, leaseRegistry } = params; const existingSession = sessionStore.get(sessionName); - const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession); + const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession, { + providerAppCatalogIds: params.providerAppCatalogIds, + }); if (!activeLease) return params.req; const nextReq = { diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index ba0846b350..c87b7af4b0 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -65,6 +65,7 @@ export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, session?: SessionState, + options: Readonly<{ providerAppCatalogIds?: readonly string[] }> = {}, ): DeviceLease | undefined { if (isLeaseAdmissionExempt(req.command)) { return undefined; @@ -75,7 +76,9 @@ export function assertRequestLeaseAdmission( if ( session === undefined && !requestLeaseScope.leaseId && - isSessionlessLeaseAdmissionExempt(req) + isSessionlessLeaseAdmissionExempt(req, { + providerAppCatalogIds: options.providerAppCatalogIds ?? [], + }) ) { return undefined; } diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index b66fe746e4..f75c4b3fd8 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -116,6 +116,7 @@ export async function createRequestExecutionScope(params: { deviceRuntimeGateway?: DeviceRuntimeGateway; platformRequestScope?: PlatformRequestScope; platformResourceCleanup?: PlatformResourceCleanup; + providerAppCatalogIds?: readonly string[]; }): Promise { const { sessionStore, leaseRegistry } = params; let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); @@ -235,6 +236,7 @@ export async function createRequestExecutionScope(params: { sessionName, sessionStore, leaseRegistry, + providerAppCatalogIds: params.providerAppCatalogIds, }); scope.req = scopedReq; return await task(); diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 03644cde56..137fb1b309 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -91,6 +91,7 @@ export type RequestRouterDeps = { leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; providerAppCatalog?: ProviderAppCatalog; + providerAppCatalogIds?: readonly string[]; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; @@ -152,6 +153,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseLifecycleProvider, cloudArtifactProvider, providerAppCatalog, + providerAppCatalogIds, androidObservation = unavailableAndroidObservation, platformResourceCleanup = unavailablePlatformResourceCleanup, providerDeviceRuntimeScope, @@ -218,6 +220,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope, platformResourceCleanup, + providerAppCatalogIds, }); return await executeRequestScope(scope); }), @@ -344,6 +347,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope: createPlatformRequestScope(scopedReq), platformResourceCleanup, + providerAppCatalogIds, }); // The outer replay keeps its stable session lock plus the device lock // from the first device binding through response projection and ref diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index f2f90f3ba4..6a16dd8526 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -334,6 +334,7 @@ export async function startDaemonRuntime( leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, cloudArtifactProvider, providerAppCatalog, + providerAppCatalogIds: providerRuntimeProviders.providerAppCatalogIds, deviceInventoryGateways, deviceRuntimeGateway, appLogAdmissionLedger, diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index bb58f2acf3..99485d9f91 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -44,6 +44,7 @@ export type ProviderDeviceRuntimeRequestProviders = { providerRuntimeIds: readonly string[]; providerRuntimeRequiredIds: readonly string[]; recoverableProviderIds: readonly string[]; + providerAppCatalogIds: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifactProvider?: CloudArtifactProvider; @@ -108,6 +109,9 @@ export function createProviderDeviceRuntimeRequestProviders( recoverableProviderIds: runtimes .filter((runtime) => runtime.recoverExpiredLease !== undefined) .map((runtime) => runtime.provider), + providerAppCatalogIds: runtimes + .filter((runtime) => runtime.appCatalog !== undefined) + .map((runtime) => runtime.provider), recoverExpiredLease: composeExpiredLeaseRecovery(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), providerAppCatalog: composeProviderAppCatalog(runtimes), From 9146fb754b4d736e855e4a60c8a8a8063d9d49d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 16:12:36 +0200 Subject: [PATCH 10/11] refactor: deepen provider app catalog admission --- packages/contracts/src/device-provider.ts | 9 ++++-- packages/contracts/src/facades/device.ts | 1 + .../contracts/src/provider-device-runtime.ts | 4 +-- packages/provider-limrun/src/runtime.ts | 5 ++-- src/__tests__/provider-device-runtime.test.ts | 11 ++++++- src/cli-schema/cli-help.ts | 2 +- src/commands/management/app.ts | 2 +- .../__tests__/parity.test.ts | 17 ++++------- .../daemon-command-descriptor.ts | 11 ++++--- src/core/command-descriptor/registry.ts | 29 ++++++++++-------- .../__tests__/daemon-command-registry.test.ts | 30 ++++++++----------- .../__tests__/request-admission.test.ts | 8 ++++- .../__tests__/request-handler-chain.test.ts | 5 +++- .../request-router-apps-admission.test.ts | 30 +++++++++++++------ src/daemon/daemon-command-registry.ts | 10 +++---- .../session-inventory-apps-runtime.test.ts | 12 ++++++-- src/daemon/handlers/session-inventory.ts | 5 ++-- src/daemon/lease-lifecycle.ts | 10 +++++-- src/daemon/request-admission.ts | 22 ++++++++++---- src/daemon/request-execution-scope.ts | 5 ++-- src/daemon/request-router.ts | 6 ++-- src/daemon/server/daemon-runtime.ts | 1 - src/provider-device-runtime.ts | 30 +++++++++++-------- 23 files changed, 158 insertions(+), 107 deletions(-) diff --git a/packages/contracts/src/device-provider.ts b/packages/contracts/src/device-provider.ts index 034ae34aa1..c0db10d243 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -72,7 +72,12 @@ export type ProviderAppCatalogQuery = Readonly<{ platform: 'android' | 'ios'; }>; -export type ProviderAppCatalog = ( +export type ProviderAppCatalogHandler = ( query: ProviderAppCatalogQuery, signal?: AbortSignal, -) => Promise; +) => Promise; + +export type ProviderAppCatalog = Readonly<{ + supports(provider: string): boolean; + list: ProviderAppCatalogHandler; +}>; diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 8d70558c44..903d00a7f7 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -25,6 +25,7 @@ export type { LeaseLifecycleContext, LeaseLifecycleProvider, ProviderAppCatalog, + ProviderAppCatalogHandler, ProviderAppCatalogQuery, ProviderDeviceInventoryOutcome, ProviderDeviceInventorySource, diff --git a/packages/contracts/src/provider-device-runtime.ts b/packages/contracts/src/provider-device-runtime.ts index 838a098fc9..683c95fbd0 100644 --- a/packages/contracts/src/provider-device-runtime.ts +++ b/packages/contracts/src/provider-device-runtime.ts @@ -4,7 +4,7 @@ import type { DeviceInventoryProvider, DeviceLease, LeaseLifecycleProvider, - ProviderAppCatalog, + ProviderAppCatalogHandler, } from './device-provider.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; @@ -37,7 +37,7 @@ export type ProviderDeviceRuntime = { leaseLifecycle: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifacts?: CloudArtifactProvider; - appCatalog?: ProviderAppCatalog; + appCatalog?: ProviderAppCatalogHandler; deviceInventoryProvider: DeviceInventoryProvider; ownsDevice(device: DeviceInfo): boolean; getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor | undefined; diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 580c8d0a9e..ce3035348d 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -5,7 +5,7 @@ import type { DeviceLease, LeaseLifecycleProvider, LeaseLifecycleContext, - ProviderAppCatalog, + ProviderAppCatalogHandler, ProviderDeviceInstallOptions, ProviderDeviceInstallResult, ProviderDeviceRuntime, @@ -100,8 +100,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { release: async (lease) => await this.release(lease), }; - readonly appCatalog: ProviderAppCatalog = async (query, signal) => { - if (query.provider !== this.provider) return undefined; + readonly appCatalog: ProviderAppCatalogHandler = async (query, signal) => { const { listLimrunAppAssets } = await import('./app-catalog.ts'); return (await listLimrunAppAssets(this.limrun, query.platform, signal)).map( (asset) => asset.name, diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 625fd8e45e..210c57d8fb 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -28,7 +28,16 @@ test('provider device runtime registry delegates lifecycle, inventory, and inter }); await requestProviders.recoverExpiredLease?.(world.lease); assert.deepEqual(requestProviders.recoverableProviderIds, ['hit']); - assert.deepEqual(requestProviders.providerAppCatalogIds, ['hit']); + assert.equal(requestProviders.providerAppCatalog?.supports('hit'), true); + assert.equal(requestProviders.providerAppCatalog?.supports('miss'), false); + assert.deepEqual( + await requestProviders.providerAppCatalog?.list({ provider: 'hit', platform: 'ios' }), + [], + ); + await assert.rejects( + () => requestProviders.providerAppCatalog!.list({ provider: 'miss', platform: 'ios' }), + /does not expose an app catalog/, + ); assert.deepEqual(world.recoveredLeases, [world.lease]); assert.deepEqual( await requestProviders.deviceInventorySource?.discover( diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index be471100e8..769b9f807c 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -736,7 +736,7 @@ Providers: After direct-provider connect: Read the printed Device, App, Next, and workflow-note lines. They are also available as verification/device/app/liveSession/nextSteps/notes in --json output. BrowserStack and AWS Device Farm create the hosted session on open. open needs the installed package or bundle identifier, not the app artifact name or ARN. - Before Limrun allocation, apps lists compatible uploaded app assets without creating an instance. open creates the instance with that asset, resolves its installed app id, and launches it. install remains available when the app comes from a fresh local path or URL. + Before provider allocation, apps lists compatible uploaded app assets without creating an instance when the selected provider exposes a catalog. open creates the instance with that asset, resolves its installed app id, and launches it. install remains available when the app comes from a fresh local path or URL. AWS Device Farm cannot install after allocation. If connect reports no attached app, run its printed reconnect command, which includes --session --force, before open. Do not run devices as a pre-open catalog probe for direct providers; it can allocate the deferred provider session. Limrun is the exception for apps: before allocation it lists uploaded assets for the selected platform. diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 4de2fddc0a..f976bc2176 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -190,7 +190,7 @@ export const appsCommandFacet = defineCommandFacet({ text: { summary: 'List installed apps or deferred provider app assets', cliDetail: - 'Before Limrun allocation, lists uploaded app assets for the selected platform. On a live device, defaults to user-installed apps; use --all to include system/OEM apps.', + 'Before provider allocation, lists uploaded app assets when the selected provider exposes a catalog. On a live device, defaults to user-installed apps; use --all to include system/OEM apps.', }, metadata: appsCommandMetadata, definition: appsCommandDefinition, diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 152e338e56..d4902942d9 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -30,7 +30,7 @@ import { const DAEMON_FUNCTION_TRAITS = [ 'allowSessionlessDefaultDevice', 'skipSessionlessProviderDevice', - 'sessionlessLeaseAdmissionExempt', + 'sessionlessLeaseAdmissionExemption', ] as const; // Public commands that intentionally have no daemon route — they live only in the @@ -105,19 +105,14 @@ test('derived daemon descriptors preserve closure traits by presence and behavio const live = liveByCommand.get(derived.command); assert.ok(live, `${derived.command} present in hand table`); for (const trait of DAEMON_FUNCTION_TRAITS) { - const derivedFn = derived[trait] as - | ((req: DaemonRequest, context: { providerAppCatalogIds: readonly string[] }) => boolean) - | undefined; - const liveFn = live[trait] as - | ((req: DaemonRequest, context: { providerAppCatalogIds: readonly string[] }) => boolean) - | undefined; + const derivedFn = derived[trait] as ((req: DaemonRequest) => unknown) | undefined; + const liveFn = live[trait] as ((req: DaemonRequest) => unknown) | undefined; assert.equal(typeof derivedFn, typeof liveFn, `${derived.command} ${trait} presence`); if (typeof liveFn === 'function' && typeof derivedFn === 'function') { for (const request of sampleRequests(derived.command)) { - const context = { providerAppCatalogIds: ['limrun'] }; - assert.equal( - derivedFn(request, context), - liveFn(request, context), + assert.deepEqual( + derivedFn(request), + liveFn(request), `${derived.command} ${trait} behavior`, ); } diff --git a/src/core/command-descriptor/daemon-command-descriptor.ts b/src/core/command-descriptor/daemon-command-descriptor.ts index c0174ee747..de16e50316 100644 --- a/src/core/command-descriptor/daemon-command-descriptor.ts +++ b/src/core/command-descriptor/daemon-command-descriptor.ts @@ -21,9 +21,9 @@ export type DaemonRefFrameEffect = | RefFrameEffect | ((req: TRequest) => RefFrameEffect); -export type SessionlessLeaseAdmissionContext = Readonly<{ - providerAppCatalogIds: readonly string[]; -}>; +export type SessionlessLeaseAdmissionExemption = + | Readonly<{ kind: 'unconditional' }> + | Readonly<{ kind: 'provider-app-catalog'; provider: string }>; /** * Daemon route + request-policy traits for one command. Generic over the request @@ -54,8 +54,7 @@ export type DaemonCommandDescriptor = { preferExplicitDeviceOverExistingSession?: boolean; allowSessionlessDefaultDevice?: (req: TRequest) => boolean; skipSessionlessProviderDevice?: (req: TRequest) => boolean; - sessionlessLeaseAdmissionExempt?: ( + sessionlessLeaseAdmissionExemption?: ( req: TRequest, - context: SessionlessLeaseAdmissionContext, - ) => boolean; + ) => SessionlessLeaseAdmissionExemption | undefined; }; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 6b4036b60d..ddff9c5620 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -177,16 +177,21 @@ const isShardedTestRequest = (req: DispatchedCommand): boolean => // no-lease-anywhere admission bypass in request-admission.ts. `close ` // resolves its device straight from flags when there's no session and must // stay behind full lease/tenant admission. -const isPlainCloseRequest = (req: DispatchedCommand): boolean => - (req.positionals?.length ?? 0) === 0; - -const isDeferredProviderAppCatalogRequest: NonNullable< - DaemonCommandDescriptor['sessionlessLeaseAdmissionExempt'] -> = (req, context) => - req.flags?.leaseId === undefined && - typeof req.flags?.leaseProvider === 'string' && - context.providerAppCatalogIds.includes(req.flags.leaseProvider) && - (req.flags?.platform === 'android' || req.flags?.platform === 'ios'); +const resolvePlainCloseLeaseAdmissionExemption = ( + req: DispatchedCommand, +): { kind: 'unconditional' } | undefined => + (req.positionals?.length ?? 0) === 0 ? { kind: 'unconditional' } : undefined; + +const resolveDeferredProviderAppCatalogLeaseAdmissionExemption: NonNullable< + DaemonCommandDescriptor['sessionlessLeaseAdmissionExemption'] +> = (req) => { + const provider = req.flags?.leaseProvider; + return req.flags?.leaseId === undefined && + typeof provider === 'string' && + (req.flags?.platform === 'android' || req.flags?.platform === 'ios') + ? { kind: 'provider-app-catalog', provider } + : undefined; +}; // ADR 0014 request-sensitive ref-frame resolvers. The action is the leading // positional (see keyboard/alert daemon writers in src/commands/system/index.ts @@ -567,7 +572,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, - sessionlessLeaseAdmissionExempt: isDeferredProviderAppCatalogRequest, + sessionlessLeaseAdmissionExemption: resolveDeferredProviderAppCatalogLeaseAdmissionExemption, }, platformExecution: { kind: 'device-runtime', uses: [appsRuntimeUse] as const }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -924,7 +929,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'may-invalidate', allowInvalidRecording: true, saveScriptFlagOwner: true, - sessionlessLeaseAdmissionExempt: isPlainCloseRequest, + sessionlessLeaseAdmissionExemption: resolvePlainCloseLeaseAdmissionExemption, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index d5916ffa9b..435d2281fc 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -7,7 +7,7 @@ import { getDaemonCommandRoute, getSessionCommandKind, isLeaseAdmissionExempt, - isSessionlessLeaseAdmissionExempt, + resolveSessionlessLeaseAdmissionExemption, shouldBlockForInvalidRecording, shouldGuardAndroidBlockingDialog, shouldLockSessionExecution, @@ -81,25 +81,19 @@ test('daemon command registry preserves request admission traits', () => { assert.equal(shouldValidateSessionSelector(INTERNAL_COMMANDS.leaseAllocate), true); assert.equal(isLeaseAdmissionExempt(PUBLIC_COMMANDS.open), false); assert.equal(shouldLockSessionExecution(PUBLIC_COMMANDS.open), true); - assert.equal( - isSessionlessLeaseAdmissionExempt( - { - ...makeRequest(PUBLIC_COMMANDS.apps), - flags: { platform: 'android', leaseProvider: 'limrun' }, - }, - { providerAppCatalogIds: ['limrun'] }, - ), - true, + assert.deepEqual( + resolveSessionlessLeaseAdmissionExemption({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun' }, + }), + { kind: 'provider-app-catalog', provider: 'limrun' }, ); assert.equal( - isSessionlessLeaseAdmissionExempt( - { - ...makeRequest(PUBLIC_COMMANDS.apps), - flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, - }, - { providerAppCatalogIds: ['limrun'] }, - ), - false, + resolveSessionlessLeaseAdmissionExemption({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, + }), + undefined, ); }); diff --git a/src/daemon/__tests__/request-admission.test.ts b/src/daemon/__tests__/request-admission.test.ts index 48d43d2c17..f0f79c8315 100644 --- a/src/daemon/__tests__/request-admission.test.ts +++ b/src/daemon/__tests__/request-admission.test.ts @@ -4,6 +4,12 @@ import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts' import { LeaseRegistry } from '../lease-registry.ts'; import { assertRequestLeaseAdmission } from '../request-admission.ts'; import type { DaemonRequest } from '../types.ts'; +import type { ProviderAppCatalog } from '@agent-device/contracts/device'; + +const limrunAppCatalog: ProviderAppCatalog = { + supports: (provider) => provider === 'limrun', + list: async () => [], +}; function makeRequest(overrides: Partial = {}): DaemonRequest { return { @@ -152,7 +158,7 @@ test('sessionless apps admits a provider declared by the runtime app catalog', ( }), registry, undefined, - { providerAppCatalogIds: ['limrun'] }, + { providerAppCatalog: limrunAppCatalog }, ); assert.equal(result, undefined); diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index 8f8057c457..0ca50b6417 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -107,7 +107,10 @@ test('request handler chain forwards the deferred provider app catalog to invent req.flags = { platform: 'android', leaseProvider: 'limrun' }; const response = await runRequestHandlerChain({ ...makeChainParams(req), - providerAppCatalog: async () => ['Example.apk'], + providerAppCatalog: { + supports: (provider) => provider === 'limrun', + list: async () => ['Example.apk'], + }, }); assert.deepEqual(response, { ok: true, data: { apps: ['Example.apk'] } }); diff --git a/src/daemon/__tests__/request-router-apps-admission.test.ts b/src/daemon/__tests__/request-router-apps-admission.test.ts index 06eb486200..716a38876f 100644 --- a/src/daemon/__tests__/request-router-apps-admission.test.ts +++ b/src/daemon/__tests__/request-router-apps-admission.test.ts @@ -9,8 +9,12 @@ import { LeaseRegistry } from '../lease-registry.ts'; import type { DaemonRequest } from '../types.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; -function createAppsAdmissionHarness(apps: readonly string[] | undefined) { - const providerAppCatalog = vi.fn(async () => apps); +function createAppsAdmissionHarness(apps: readonly string[] = []) { + const listProviderApps = vi.fn(async () => apps); + const providerAppCatalog = { + supports: vi.fn((provider: string) => provider === 'limrun'), + list: listProviderApps, + }; const inspectFacts = vi.fn(async () => { throw new Error('apps catalog must not inspect device facts'); }); @@ -29,10 +33,9 @@ function createAppsAdmissionHarness(apps: readonly string[] | undefined) { shutdown: async () => {}, } satisfies DeviceRuntimeGateway, providerAppCatalog, - providerAppCatalogIds: ['limrun'], trackDownloadableArtifact: () => 'artifact-id', }); - return { handler, providerAppCatalog, inspectFacts, bind }; + return { handler, listProviderApps, inspectFacts, bind }; } function appsRequest(leaseProvider: string): DaemonRequest { @@ -49,28 +52,37 @@ function appsRequest(leaseProvider: string): DaemonRequest { test.each(['bogus', 'proxy', 'browserstack'])( 'tenant apps rejects non-catalog provider %s before provider or device access', async (leaseProvider) => { - const { handler, providerAppCatalog, inspectFacts, bind } = - createAppsAdmissionHarness(undefined); + const { handler, listProviderApps, inspectFacts, bind } = createAppsAdmissionHarness(undefined); const response = await handler(appsRequest(leaseProvider)); expect(response.ok).toBe(false); expect(response.ok === false && response.error.message).toMatch( /tenant isolation requires lease id/, ); - expect(providerAppCatalog).not.toHaveBeenCalled(); + expect(listProviderApps).not.toHaveBeenCalled(); expect(inspectFacts).not.toHaveBeenCalled(); expect(bind).not.toHaveBeenCalled(); }, ); test('tenant apps admits the runtime-declared catalog provider without device access', async () => { - const { handler, providerAppCatalog, inspectFacts, bind } = createAppsAdmissionHarness([ + const { handler, listProviderApps, inspectFacts, bind } = createAppsAdmissionHarness([ 'Example.app.zip', ]); const response = await handler(appsRequest('limrun')); expect(response).toEqual({ ok: true, data: { apps: ['Example.app.zip'] } }); - expect(providerAppCatalog).toHaveBeenCalledTimes(1); + expect(listProviderApps).toHaveBeenCalledTimes(1); + expect(inspectFacts).not.toHaveBeenCalled(); + expect(bind).not.toHaveBeenCalled(); +}); + +test('tenant apps treats an empty provider catalog as authoritative', async () => { + const { handler, listProviderApps, inspectFacts, bind } = createAppsAdmissionHarness(); + const response = await handler(appsRequest('limrun')); + + expect(response).toEqual({ ok: true, data: { apps: [] } }); + expect(listProviderApps).toHaveBeenCalledTimes(1); expect(inspectFacts).not.toHaveBeenCalled(); expect(bind).not.toHaveBeenCalled(); }); diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index bc06f8e662..191266bdd7 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -1,7 +1,7 @@ import { type DaemonCommandDescriptor, type DaemonCommandRoute, - type SessionlessLeaseAdmissionContext, + type SessionlessLeaseAdmissionExemption, type SessionCommandKind, } from '../core/command-descriptor/daemon-command-descriptor.ts'; import { deriveDaemonCommandDescriptors } from '../core/command-descriptor/derive.ts'; @@ -84,12 +84,10 @@ export function usesSessionlessDefaultProviderDevice(req: DaemonRequest): boolea return typeof allow === 'function' ? allow(req) : false; } -export function isSessionlessLeaseAdmissionExempt( +export function resolveSessionlessLeaseAdmissionExemption( req: DaemonRequest, - context: SessionlessLeaseAdmissionContext, -): boolean { - const exempt = getDaemonCommandDescriptor(req.command)?.sessionlessLeaseAdmissionExempt; - return typeof exempt === 'function' ? exempt(req, context) : false; +): SessionlessLeaseAdmissionExemption | undefined { + return getDaemonCommandDescriptor(req.command)?.sessionlessLeaseAdmissionExemption?.(req); } /** diff --git a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts index 0cd1d2081e..ee33f99d23 100644 --- a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts @@ -148,7 +148,10 @@ test('deferred provider apps returns uploaded assets without resolving a device' sessionStore, inspectFacts, bindDevice, - providerAppCatalog: listAvailableApps, + providerAppCatalog: { + supports: (provider) => provider === 'limrun', + list: listAvailableApps, + }, }); expect(response).toEqual({ @@ -170,7 +173,7 @@ test('deferred provider apps forwards request cancellation to the catalog', asyn const requestId = 'provider-app-catalog-abort'; const registration = registerRequestAbort(requestId); const reason = new Error('catalog canceled'); - const providerAppCatalog = vi.fn(async (_query, signal?: AbortSignal) => { + const listAvailableApps = vi.fn(async (_query, signal?: AbortSignal) => { expect(signal).toBe(registration?.controller.signal); return await new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); @@ -190,7 +193,10 @@ test('deferred provider apps forwards request cancellation to the catalog', asyn req, sessionName: req.session, sessionStore: makeSessionStore(), - providerAppCatalog, + providerAppCatalog: { + supports: (provider) => provider === 'limrun', + list: listAvailableApps, + }, }); registration?.controller.abort(reason); await expect(response).rejects.toBe(reason); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 8c1cf7fa4f..643b841ba9 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -353,14 +353,15 @@ async function resolveProviderAppCatalogResponse( const provider = leaseScope.leaseProvider; const platform = req.flags?.platform; if (!provider || (platform !== 'android' && platform !== 'ios')) return undefined; - const apps = await providerAppCatalog( + if (!providerAppCatalog.supports(provider)) return undefined; + const apps = await providerAppCatalog.list( { provider, platform, }, getRequestSignal(req.meta?.requestId), ); - return apps ? { ok: true, data: { apps: [...apps] } } : undefined; + return { ok: true, data: { apps: [...apps] } }; } async function inspectCapabilityFacts( diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index 444b3ec5a4..8500da8232 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -2,7 +2,11 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { leaseScopeToReleaseRequest } from '../core/lease-scope.ts'; import { clearDeviceClaim } from './device-claims.ts'; import type { LeaseRegistry } from './lease-registry.ts'; -import type { DeviceLease, LeaseLifecycleProvider } from '@agent-device/contracts/device'; +import type { + DeviceLease, + LeaseLifecycleProvider, + ProviderAppCatalog, +} from '@agent-device/contracts/device'; import { buildSessionLeaseFromRequest, type SessionLease } from './lease-context.ts'; import { assertRequestLeaseAdmission, @@ -104,12 +108,12 @@ export function admitRequestLeaseForLockedScope(params: { sessionName: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; - providerAppCatalogIds?: readonly string[]; + providerAppCatalog?: ProviderAppCatalog; }): DaemonRequest { const { sessionName, sessionStore, leaseRegistry } = params; const existingSession = sessionStore.get(sessionName); const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession, { - providerAppCatalogIds: params.providerAppCatalogIds, + providerAppCatalog: params.providerAppCatalog, }); if (!activeLease) return params.req; diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index c87b7af4b0..bb53b87116 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -3,8 +3,9 @@ import { normalizeTenantId, resolveSessionIsolationMode } from './config.ts'; import { isTenantOwnedSessionName, tenantScopedSessionName } from './session-tenant-scope.ts'; import { isLeaseAdmissionExempt, - isSessionlessLeaseAdmissionExempt, + resolveSessionlessLeaseAdmissionExemption, } from './daemon-command-registry.ts'; +import type { DeviceLease, ProviderAppCatalog } from '@agent-device/contracts/device'; import { DEFAULT_PROXY_LEASE_TTL_MS, findMissingProxyLeaseFields, @@ -14,7 +15,6 @@ import { } from './lease-context.ts'; import { leaseScopeToHeartbeatRequest } from '../core/lease-scope.ts'; import type { LeaseRegistry } from './lease-registry.ts'; -import type { DeviceLease } from '@agent-device/contracts/device'; import type { DaemonRequest, SessionState } from './types.ts'; export function scopeRequestSession(req: DaemonRequest): DaemonRequest { @@ -65,7 +65,7 @@ export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, session?: SessionState, - options: Readonly<{ providerAppCatalogIds?: readonly string[] }> = {}, + options: Readonly<{ providerAppCatalog?: ProviderAppCatalog }> = {}, ): DeviceLease | undefined { if (isLeaseAdmissionExempt(req.command)) { return undefined; @@ -76,9 +76,7 @@ export function assertRequestLeaseAdmission( if ( session === undefined && !requestLeaseScope.leaseId && - isSessionlessLeaseAdmissionExempt(req, { - providerAppCatalogIds: options.providerAppCatalogIds ?? [], - }) + hasSessionlessLeaseAdmissionExemption(req, options.providerAppCatalog) ) { return undefined; } @@ -98,6 +96,18 @@ export function assertRequestLeaseAdmission( return leaseRegistry.heartbeatLease(leaseScopeToHeartbeatRequest(heartbeatLeaseScope)); } +function hasSessionlessLeaseAdmissionExemption( + req: DaemonRequest, + providerAppCatalog: ProviderAppCatalog | undefined, +): boolean { + const exemption = resolveSessionlessLeaseAdmissionExemption(req); + if (exemption?.kind === 'unconditional') return true; + return ( + exemption?.kind === 'provider-app-catalog' && + providerAppCatalog?.supports(exemption.provider) === true + ); +} + export function assertRequestLeaseAdmissionPreflight(req: DaemonRequest): void { if (isLeaseAdmissionExempt(req.command)) return; assertProxyOpenLeaseMetadata(req, resolveLeaseScope(req)); diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index f75c4b3fd8..3ee72518b5 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -1,4 +1,5 @@ import type { CommandFlags } from '@agent-device/contracts/command'; +import type { ProviderAppCatalog } from '@agent-device/contracts/device'; import type { DaemonArtifactType } from '@agent-device/kernel/contracts'; import { emitDiagnostic, @@ -116,7 +117,7 @@ export async function createRequestExecutionScope(params: { deviceRuntimeGateway?: DeviceRuntimeGateway; platformRequestScope?: PlatformRequestScope; platformResourceCleanup?: PlatformResourceCleanup; - providerAppCatalogIds?: readonly string[]; + providerAppCatalog?: ProviderAppCatalog; }): Promise { const { sessionStore, leaseRegistry } = params; let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); @@ -236,7 +237,7 @@ export async function createRequestExecutionScope(params: { sessionName, sessionStore, leaseRegistry, - providerAppCatalogIds: params.providerAppCatalogIds, + providerAppCatalog: params.providerAppCatalog, }); scope.req = scopedReq; return await task(); diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 137fb1b309..fc38f22ebb 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -91,7 +91,6 @@ export type RequestRouterDeps = { leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; providerAppCatalog?: ProviderAppCatalog; - providerAppCatalogIds?: readonly string[]; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; @@ -153,7 +152,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseLifecycleProvider, cloudArtifactProvider, providerAppCatalog, - providerAppCatalogIds, androidObservation = unavailableAndroidObservation, platformResourceCleanup = unavailablePlatformResourceCleanup, providerDeviceRuntimeScope, @@ -220,7 +218,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope, platformResourceCleanup, - providerAppCatalogIds, + providerAppCatalog, }); return await executeRequestScope(scope); }), @@ -347,7 +345,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope: createPlatformRequestScope(scopedReq), platformResourceCleanup, - providerAppCatalogIds, + providerAppCatalog, }); // The outer replay keeps its stable session lock plus the device lock // from the first device binding through response projection and ref diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 6a16dd8526..f2f90f3ba4 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -334,7 +334,6 @@ export async function startDaemonRuntime( leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, cloudArtifactProvider, providerAppCatalog, - providerAppCatalogIds: providerRuntimeProviders.providerAppCatalogIds, deviceInventoryGateways, deviceRuntimeGateway, appLogAdmissionLedger, diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 99485d9f91..2b26812de7 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -44,7 +44,6 @@ export type ProviderDeviceRuntimeRequestProviders = { providerRuntimeIds: readonly string[]; providerRuntimeRequiredIds: readonly string[]; recoverableProviderIds: readonly string[]; - providerAppCatalogIds: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifactProvider?: CloudArtifactProvider; @@ -109,9 +108,6 @@ export function createProviderDeviceRuntimeRequestProviders( recoverableProviderIds: runtimes .filter((runtime) => runtime.recoverExpiredLease !== undefined) .map((runtime) => runtime.provider), - providerAppCatalogIds: runtimes - .filter((runtime) => runtime.appCatalog !== undefined) - .map((runtime) => runtime.provider), recoverExpiredLease: composeExpiredLeaseRecovery(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), providerAppCatalog: composeProviderAppCatalog(runtimes), @@ -220,14 +216,24 @@ function composeCloudArtifactProvider( function composeProviderAppCatalog( runtimes: ProviderDeviceRuntime[], ): ProviderAppCatalog | undefined { - if (!runtimes.some((runtime) => runtime.appCatalog !== undefined)) return undefined; - return async (query, signal) => { - for (const runtime of runtimes) { - if (!runtime.appCatalog || !runtimeMatchesProvider(runtime, query.provider)) continue; - const apps = await runtime.appCatalog(query, signal); - if (apps) return apps; - } - return undefined; + const catalogRuntimes = runtimes.filter((runtime) => runtime.appCatalog !== undefined); + if (catalogRuntimes.length === 0) return undefined; + return { + supports: (provider) => + catalogRuntimes.some((runtime) => runtimeMatchesProvider(runtime, provider)), + list: async (query, signal) => { + const runtime = catalogRuntimes.find((candidate) => + runtimeMatchesProvider(candidate, query.provider), + ); + if (!runtime?.appCatalog) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `Provider ${query.provider} does not expose an app catalog.`, + { provider: query.provider }, + ); + } + return await runtime.appCatalog(query, signal); + }, }; } From 2e6bf6bec8e56284a3bcea69d140abdd79779cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 22:10:30 +0200 Subject: [PATCH 11/11] refactor: simplify provider capability projection --- src/cli/connection/provider-policy.test.ts | 50 ++++++++----------- src/cli/connection/provider-policy.ts | 57 ++++++---------------- 2 files changed, 33 insertions(+), 74 deletions(-) diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index 738f30fc2d..508d5311a1 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -3,42 +3,30 @@ import { test } from 'vitest'; import { connectionProviderCapabilitiesForLease, connectionProviderCapabilitiesForVerification, - connectionProviderRequiresAppAttachment, - connectionProviderSupportsArtifacts, - connectionProviderSupportsDeferredAppSelection, - connectionProviderSupportsDirectPortReverse, - connectionProviderUsesCloudWebDriverLease, } from './provider-policy.ts'; -test('only providers declaring deferred app selection use app catalog before allocation', () => { - assert.equal(connectionProviderSupportsDeferredAppSelection('limrun'), true); - assert.equal(connectionProviderSupportsDeferredAppSelection('browserstack'), false); - assert.equal(connectionProviderSupportsDeferredAppSelection('aws-device-farm'), false); - assert.equal(connectionProviderSupportsDeferredAppSelection('proxy'), false); - assert.equal(connectionProviderSupportsDeferredAppSelection(undefined), false); -}); - -test('provider capabilities stay declared outside command implementations', () => { - assert.equal(connectionProviderRequiresAppAttachment('aws-device-farm'), true); - assert.equal(connectionProviderRequiresAppAttachment('browserstack'), false); - assert.equal(connectionProviderSupportsArtifacts('aws-device-farm'), true); - assert.equal(connectionProviderSupportsArtifacts('browserstack'), true); - assert.equal(connectionProviderSupportsArtifacts('limrun'), false); - assert.equal(connectionProviderSupportsDirectPortReverse('limrun'), true); - assert.equal(connectionProviderSupportsDirectPortReverse('aws-device-farm'), false); - assert.equal(connectionProviderUsesCloudWebDriverLease('browserstack'), true); - assert.equal(connectionProviderUsesCloudWebDriverLease('aws-device-farm'), true); - assert.equal(connectionProviderUsesCloudWebDriverLease('limrun'), false); -}); - -test('provider carriers resolve to semantic capabilities before commands inspect them', () => { +test('provider carriers project provider identity into semantic capabilities', () => { + assert.deepEqual(connectionProviderCapabilitiesForLease({ leaseProvider: 'limrun' }), { + leaseKind: 'direct-device-provider', + requiresAppAttachment: false, + requiresRemoteDaemon: false, + supportsArtifacts: false, + supportsDeferredAppSelection: true, + supportsDirectPortReverse: true, + usesCloudWebDriverLease: false, + }); + const browserStack = connectionProviderCapabilitiesForVerification({ + provider: 'browserstack', + }); + assert.equal(browserStack.supportsArtifacts, true); + assert.equal(browserStack.usesCloudWebDriverLease, true); assert.equal( - connectionProviderCapabilitiesForLease({ leaseProvider: 'limrun' }) - .supportsDeferredAppSelection, + connectionProviderCapabilitiesForLease({ leaseProvider: 'aws-device-farm' }) + .requiresAppAttachment, true, ); assert.equal( - connectionProviderCapabilitiesForVerification({ provider: 'browserstack' }).supportsArtifacts, - true, + connectionProviderCapabilitiesForLease({ leaseProvider: 'proxy' }).leaseKind, + 'proxy', ); }); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index bc57074c49..9ca1cdcd54 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -17,8 +17,6 @@ type ConnectionProviderCapabilities = { usesCloudWebDriverLease: boolean; }; -const DEFERRED_APP_SELECTION_PROVIDERS = new Set(['limrun']); - export function isConnectProviderName(value: string | undefined): value is ConnectProvider { return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); } @@ -39,40 +37,6 @@ export function connectProviderNamesForError(): string { ].join(', '); } -function connectionProviderRequiresRemoteDaemon(provider: string | undefined): boolean { - return !isDirectDeviceConnectProvider(provider); -} - -export function connectionProviderSupportsDeferredAppSelection( - provider: string | undefined, -): boolean { - return isDirectDeviceConnectProvider(provider) && DEFERRED_APP_SELECTION_PROVIDERS.has(provider); -} - -export function connectionProviderRequiresAppAttachment(provider: string | undefined): boolean { - return provider === CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm; -} - -export function connectionProviderSupportsArtifacts(provider: string | undefined): boolean { - return isCloudWebDriverProviderName(provider); -} - -export function connectionProviderSupportsDirectPortReverse(provider: string | undefined): boolean { - return provider === 'limrun'; -} - -export function connectionProviderUsesCloudWebDriverLease(provider: string | undefined): boolean { - return isCloudWebDriverProviderName(provider); -} - -function connectionProviderLeaseKind( - provider: string | undefined, -): 'proxy' | 'direct-device-provider' | 'remote-provider' { - if (provider === 'proxy') return 'proxy'; - if (isDirectDeviceConnectProvider(provider)) return 'direct-device-provider'; - return 'remote-provider'; -} - export function connectionProviderCapabilitiesForLease(source: { leaseProvider?: string; }): ConnectionProviderCapabilities { @@ -88,13 +52,20 @@ export function connectionProviderCapabilitiesForVerification( function connectionProviderCapabilities( provider: string | undefined, ): ConnectionProviderCapabilities { + const directDeviceProvider = isDirectDeviceConnectProvider(provider); + const cloudWebDriver = isCloudWebDriverProviderName(provider); return { - leaseKind: connectionProviderLeaseKind(provider), - requiresAppAttachment: connectionProviderRequiresAppAttachment(provider), - requiresRemoteDaemon: connectionProviderRequiresRemoteDaemon(provider), - supportsArtifacts: connectionProviderSupportsArtifacts(provider), - supportsDeferredAppSelection: connectionProviderSupportsDeferredAppSelection(provider), - supportsDirectPortReverse: connectionProviderSupportsDirectPortReverse(provider), - usesCloudWebDriverLease: connectionProviderUsesCloudWebDriverLease(provider), + leaseKind: + provider === 'proxy' + ? 'proxy' + : directDeviceProvider + ? 'direct-device-provider' + : 'remote-provider', + requiresAppAttachment: provider === CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + requiresRemoteDaemon: !directDeviceProvider, + supportsArtifacts: cloudWebDriver, + supportsDeferredAppSelection: provider === 'limrun', + supportsDirectPortReverse: provider === 'limrun', + usesCloudWebDriverLease: cloudWebDriver, }; }