diff --git a/oxlint.config.ts b/oxlint.config.ts index 58843a741..0f4a3f73a 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -71,6 +71,56 @@ 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.', + }, + ], + }, + ], + '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.', + }, + ], + }, + }, { files: [ 'packages/host-kit/src/internal/exec.ts', diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index 2e42310f7..4da6e55c6 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 provider app references 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', + resolveAppReference: (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 0db4ce162..bdca0620a 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; + resolveAppReference?(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, resolveOpenTargetReference(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 = resolveOpenApplicationReferences(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 resolveOpenTargetReference( + params: DirectApplicationLifecycleParams, + input: OpenTargetResolutionInput, +): OpenTargetResolutionInput { + if (!input.target || !params.resolveAppReference) return input; + return { ...input, target: params.resolveAppReference(input.target) }; +} + +function resolveOpenApplicationReferences( + params: DirectApplicationLifecycleParams, + input: OpenApplicationInput, +): OpenApplicationInput { + const resolve = params.resolveAppReference; + 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 4cf6c88e2..c0db10d24 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -66,3 +66,18 @@ export type ProviderDeviceInventorySource = Readonly<{ signal: AbortSignal, ): Promise; }>; + +export type ProviderAppCatalogQuery = Readonly<{ + provider: string; + platform: 'android' | 'ios'; +}>; + +export type ProviderAppCatalogHandler = ( + query: ProviderAppCatalogQuery, + signal?: AbortSignal, +) => 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 d8f9d7047..903d00a7f 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -24,6 +24,9 @@ export type { DeviceLease, LeaseLifecycleContext, LeaseLifecycleProvider, + ProviderAppCatalog, + ProviderAppCatalogHandler, + 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 755110272..683c95fbd 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, + ProviderAppCatalogHandler, } 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?: ProviderAppCatalogHandler; 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 000000000..668527484 --- /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 000000000..13028f8f1 --- /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().replaceAll(/[^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 0ffc696d4..696dc7d6d 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; + resolveAppReference?(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, + resolveAppReference: (app) => options.resolveAppReference?.(device, app) ?? app, configurePortReverse: options.configurePortReverse, }), runtimeFacts.operations, diff --git a/packages/provider-limrun/src/connection-verification.test.ts b/packages/provider-limrun/src/connection-verification.test.ts index 46feaaeda..24950f2a7 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 426708a0e..b277d1cdd 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 67dfe0d24..5a4d0757a 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>; + resolveAppReference?(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, + resolveAppReference: params.resolveAppReference, 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 5b705153f..4a22879d3 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 3b0cc201d..ce3035348 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, + ProviderAppCatalogHandler, 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,26 @@ 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: ProviderAppCatalogHandler = async (query, signal) => { + 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 +220,55 @@ 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 { + allocateLimrunAndroidSession, + allocateLimrunIosSession, + resolvePreinstalledAppId, + resolveRequestedLimrunAppAsset, + } = 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 +289,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 +332,13 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { return session?.platform === parsed.platform ? session : undefined; } + resolveAppReference(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 +387,7 @@ async function loadLimrunPlatformRuntime( ownsDevice: (device) => runtime.ownsDevice(device), hasLiveSession: (device) => runtime.hasLiveSession(device), getInteractor: (device, runner) => runtime.getInteractor(device, runner), + 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/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts new file mode 100644 index 000000000..2a77c8335 --- /dev/null +++ b/packages/provider-limrun/src/session-allocation.ts @@ -0,0 +1,146 @@ +import type Limrun from '@limrun/api'; +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 { + resolveInstalledAppIdForAsset, + resolveLimrunAppAsset, + type LimrunAppAsset, +} from './app-catalog.ts'; +import { createLimrunDeviceSession } from './device-session.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 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 { + 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 000000000..309a6c685 --- /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 b075fbafa..fb3f48616 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/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 327ec6b5a..210c57d8f 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -28,6 +28,16 @@ test('provider device runtime registry delegates lifecycle, inventory, and inter }); await requestProviders.recoverExpiredLease?.(world.lease); assert.deepEqual(requestProviders.recoverableProviderIds, ['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( @@ -142,6 +152,7 @@ function makeProviderRuntimeWorld() { interactor, portReverseResult: { provider: 'hit' }, }); + hitRuntime.appCatalog = async () => []; hitRuntime.recoverExpiredLease = async (expiredLease) => { recoveredLeases.push(expiredLease); }; diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index 8affb909c..2a2a9b24f 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 36bbc4ca9..769b9f807 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 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 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 7257a8f18..ae983054d 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -1,6 +1,13 @@ -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 } from '../connection/provider-policy.ts'; +import { + connectionProviderCapabilitiesForLease, + connectionProviderCapabilitiesForVerification, +} from '../connection/provider-policy.ts'; import { shellQuoteIfNeeded } from '@agent-device/host-kit/command'; export type ConnectReadiness = ConnectVerification & { @@ -31,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', @@ -40,6 +48,14 @@ export function buildLeasePreparationNotice( 'No live device session has been created. Run devices to inspect inventory without allocating, then open when ready.', }; } + if (capabilities.supportsDeferredAppSelection) { + 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', @@ -132,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, @@ -221,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: [ @@ -233,6 +250,11 @@ function buildUnscopedConnectWorkflow( if (!verification && leaseKind === 'direct-device-provider') { return { nextSteps: defaultDirectProviderLifecycle() }; } + if (connectionProviderCapabilitiesForVerification(verification).supportsDeferredAppSelection) { + return { + nextSteps: ['agent-device apps', 'agent-device open '], + }; + } const appMissing = verification?.app?.status === 'missing'; return { nextSteps: requiresInstall(verification) @@ -249,7 +271,7 @@ function requiresInstall(verification?: ConnectVerification): boolean { } function supportsProviderArtifacts(verification?: ConnectVerification): boolean { - return verification?.provider === 'browserstack' || verification?.provider === 'aws-device-farm'; + return connectionProviderCapabilitiesForVerification(verification).supportsArtifacts; } function missingAttachedAppRecovery(verification?: ConnectVerification): string[] { @@ -328,7 +350,10 @@ 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'; + 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 51a769b36..c643edde4 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,9 +34,8 @@ 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 { connectionProviderCapabilitiesForLease } 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([ @@ -111,6 +111,13 @@ export async function materializeRemoteConnectionForCommand(options: { remoteConfig.profile, ); const nextFlags = { ...mergedFlags, session: state.session }; + if ( + connectionProviderCapabilitiesForLease(state).supportsDeferredAppSelection && + 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; @@ -351,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(), @@ -377,13 +383,26 @@ type ConnectionLeasePolicy = { }; function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { - if (state.leaseProvider === 'proxy') return PROXY_CONNECTION_LEASE_POLICY; - if (isCloudWebDriverProviderName(state.leaseProvider)) { + const capabilities = connectionProviderCapabilitiesForLease(state); + if (capabilities.leaseKind === 'proxy') { + return PROXY_CONNECTION_LEASE_POLICY; + } + if (capabilities.supportsDeferredAppSelection) { + return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; + } + if (capabilities.usesCloudWebDriverLease) { return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; } return DEFAULT_CONNECTION_LEASE_POLICY; } +const DEFERRED_APP_SELECTION_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, @@ -502,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; } @@ -653,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. @@ -757,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.', @@ -774,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, @@ -791,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 }; @@ -807,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, @@ -958,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 2efdff6ca..67c275043 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 c0ce8ba03..68c1dbcc4 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 { 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}`; @@ -168,7 +169,7 @@ function shouldConfigureDirectReverse( const { flags } = options; if (!flags) return false; return ( - flags.leaseProvider === 'limrun' && + 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 572fbf5b6..27d9c5c56 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 new file mode 100644 index 000000000..508d5311a --- /dev/null +++ b/src/cli/connection/provider-policy.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + connectionProviderCapabilitiesForLease, + connectionProviderCapabilitiesForVerification, +} from './provider-policy.ts'; + +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: 'aws-device-farm' }) + .requiresAppAttachment, + true, + ); + assert.equal( + connectionProviderCapabilitiesForLease({ leaseProvider: 'proxy' }).leaseKind, + 'proxy', + ); +}); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 8bdcecc48..9ca1cdcd5 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; +}; + export function isConnectProviderName(value: string | undefined): value is ConnectProvider { return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); } @@ -27,14 +37,35 @@ export function connectProviderNamesForError(): string { ].join(', '); } -export function connectionProviderRequiresRemoteDaemon(provider: string | undefined): boolean { - return !isDirectDeviceConnectProvider(provider); +export function connectionProviderCapabilitiesForLease(source: { + leaseProvider?: string; +}): ConnectionProviderCapabilities { + return connectionProviderCapabilities(source.leaseProvider); +} + +export function connectionProviderCapabilitiesForVerification( + verification: { provider?: string } | undefined, +): ConnectionProviderCapabilities { + return connectionProviderCapabilities(verification?.provider); } -export function connectionProviderLeaseKind( +function connectionProviderCapabilities( provider: string | undefined, -): 'proxy' | 'direct-device-provider' | 'remote-provider' { - if (provider === 'proxy') return 'proxy'; - if (isDirectDeviceConnectProvider(provider)) return 'direct-device-provider'; - return 'remote-provider'; +): ConnectionProviderCapabilities { + const directDeviceProvider = isDirectDeviceConnectProvider(provider); + const cloudWebDriver = isCloudWebDriverProviderName(provider); + return { + 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, + }; } diff --git a/src/cli/connection/provider-session-result.ts b/src/cli/connection/provider-session-result.ts new file mode 100644 index 000000000..a4df9ac95 --- /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/commands/management/app.ts b/src/commands/management/app.ts index 3abfa9362..f976bc217 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 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/commands/management/output.ts b/src/commands/management/output.ts index 79eb03c3a..b7ef799f7 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/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 9c2e5b1e7..d4902942d 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', + 'sessionlessLeaseAdmissionExemption', ] as const; // Public commands that intentionally have no daemon route — they live only in the @@ -104,12 +105,16 @@ 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) => 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)) { - assert.equal(derivedFn(request), liveFn(request), `${derived.command} ${trait} behavior`); + 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 713fb39ff..de16e5031 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 SessionlessLeaseAdmissionExemption = + | Readonly<{ kind: 'unconditional' }> + | Readonly<{ kind: 'provider-app-catalog'; provider: 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,15 +54,7 @@ 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; + sessionlessLeaseAdmissionExemption?: ( + req: TRequest, + ) => SessionlessLeaseAdmissionExemption | undefined; }; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index c86dd28b0..ddff9c562 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, @@ -176,8 +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 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 @@ -558,6 +572,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, + sessionlessLeaseAdmissionExemption: resolveDeferredProviderAppCatalogLeaseAdmissionExemption, }, platformExecution: { kind: 'device-runtime', uses: [appsRuntimeUse] as const }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -914,7 +929,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'may-invalidate', allowInvalidRecording: true, saveScriptFlagOwner: true, - sessionlessPlainCloseAdmissionExempt: 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 dce44a493..435d2281f 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, + resolveSessionlessLeaseAdmissionExemption, 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.deepEqual( + resolveSessionlessLeaseAdmissionExemption({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun' }, + }), + { kind: 'provider-app-catalog', provider: 'limrun' }, + ); + assert.equal( + resolveSessionlessLeaseAdmissionExemption({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, + }), + undefined, + ); }); test('daemon command registry preserves replay and recording traits', () => { diff --git a/src/daemon/__tests__/request-admission.test.ts b/src/daemon/__tests__/request-admission.test.ts index 9cbe7c1b5..f0f79c831 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 { @@ -120,6 +126,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, + { providerAppCatalog: limrunAppCatalog }, + ); + + 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-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index 04e8fd91f..0ca50b641 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -102,6 +102,20 @@ 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: { + supports: (provider) => provider === 'limrun', + list: 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/__tests__/request-router-apps-admission.test.ts b/src/daemon/__tests__/request-router-apps-admission.test.ts new file mode 100644 index 000000000..716a38876 --- /dev/null +++ b/src/daemon/__tests__/request-router-apps-admission.test.ts @@ -0,0 +1,88 @@ +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[] = []) { + 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'); + }); + 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, + trackDownloadableArtifact: () => 'artifact-id', + }); + return { handler, listProviderApps, 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, 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(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, listProviderApps, inspectFacts, bind } = createAppsAdmissionHarness([ + 'Example.app.zip', + ]); + const response = await handler(appsRequest('limrun')); + + expect(response).toEqual({ ok: true, data: { apps: ['Example.app.zip'] } }); + 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 7c97d2221..191266bdd 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 SessionlessLeaseAdmissionExemption, type SessionCommandKind, } from '../core/command-descriptor/daemon-command-descriptor.ts'; import { deriveDaemonCommandDescriptors } from '../core/command-descriptor/derive.ts'; @@ -83,10 +84,10 @@ 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; - return typeof exempt === 'function' ? exempt(req) : false; +export function resolveSessionlessLeaseAdmissionExemption( + req: DaemonRequest, +): 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 2f71c204a..ee33f99d2 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,80 @@ 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: { + supports: (provider) => provider === 'limrun', + list: 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 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 }); + }); + }); + 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: { + supports: (provider) => provider === 'limrun', + list: listAvailableApps, + }, + }); + 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 192b0ff85..643b841ba 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,27 @@ 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; + if (!providerAppCatalog.supports(provider)) return undefined; + const apps = await providerAppCatalog.list( + { + provider, + platform, + }, + getRequestSignal(req.meta?.requestId), + ); + return { ok: true, data: { apps: [...apps] } }; +} + async function inspectCapabilityFacts( device: DeviceInfo, inspectFacts: InspectDeviceRuntimeFacts | undefined, diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 3a092a80f..abbef60b2 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/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index b8fbfc243..8500da823 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,10 +108,13 @@ export function admitRequestLeaseForLockedScope(params: { sessionName: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; + providerAppCatalog?: ProviderAppCatalog; }): 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, { + providerAppCatalog: params.providerAppCatalog, + }); if (!activeLease) return params.req; const nextReq = { diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 655350751..bb53b8711 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, - isSessionlessPlainCloseAdmissionExempt, + 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,6 +65,7 @@ export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, session?: SessionState, + options: Readonly<{ providerAppCatalog?: ProviderAppCatalog }> = {}, ): DeviceLease | undefined { if (isLeaseAdmissionExempt(req.command)) { return undefined; @@ -72,22 +73,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) + hasSessionlessLeaseAdmissionExemption(req, options.providerAppCatalog) ) { return undefined; } @@ -107,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 b66fe746e..3ee72518b 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,6 +117,7 @@ export async function createRequestExecutionScope(params: { deviceRuntimeGateway?: DeviceRuntimeGateway; platformRequestScope?: PlatformRequestScope; platformResourceCleanup?: PlatformResourceCleanup; + providerAppCatalog?: ProviderAppCatalog; }): Promise { const { sessionStore, leaseRegistry } = params; let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); @@ -235,6 +237,7 @@ export async function createRequestExecutionScope(params: { sessionName, sessionStore, leaseRegistry, + providerAppCatalog: params.providerAppCatalog, }); scope.req = scopedReq; return await task(); diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 3cb39940a..5bfb598e0 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 4228a4b80..fc38f22eb 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, @@ -216,6 +218,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope, platformResourceCleanup, + providerAppCatalog, }); return await executeRequestScope(scope); }), @@ -285,6 +288,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerRuntimeIds, providerRuntimeRequiredIds, cloudArtifactProvider, + providerAppCatalog, invoke: handleRequest, invokeReplayAction: allowReplayActions ? createReplayScopedActionInvoker(lockedScope, providerScope) @@ -341,6 +345,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope: createPlatformRequestScope(scopedReq), platformResourceCleanup, + 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 982ba0a01..f2f90f3ba 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 dde9afa82..2b26812de 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,30 @@ function composeCloudArtifactProvider( }; } +function composeProviderAppCatalog( + runtimes: ProviderDeviceRuntime[], +): ProviderAppCatalog | 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); + }, + }; +} + function composeDeviceInventorySource( runtimes: ProviderDeviceRuntime[], ): ProviderDeviceInventorySource | undefined { diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index 1b49adbdb..f39e4a78e 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');