From b147cd2927d26254b5933b245098841e0601e91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 27 Aug 2026 21:09:02 +0200 Subject: [PATCH 01/14] feat: open Limrun uploaded apps --- .../application-lifecycle-interaction.test.ts | 33 ++++- .../src/application-lifecycle-interaction.ts | 50 +++++-- packages/contracts/src/device-provider.ts | 10 ++ packages/contracts/src/facades/device.ts | 2 + .../contracts/src/provider-device-runtime.ts | 2 + .../provider-limrun/src/app-catalog.test.ts | 68 +++++++++ packages/provider-limrun/src/app-catalog.ts | 99 +++++++++++++ .../provider-limrun/src/app-log-runtime.ts | 2 + .../provider-limrun/src/app-preinstall.ts | 37 +++++ .../src/connection-verification.test.ts | 2 +- .../src/connection-verification.ts | 2 +- packages/provider-limrun/src/lifecycle.ts | 2 + .../src/runtime-dependencies.test.ts | 104 +++++++++++-- packages/provider-limrun/src/runtime.ts | 138 ++++++++---------- .../provider-limrun/src/session-allocation.ts | 116 +++++++++++++++ src/__tests__/cli-config-limrun.test.ts | 111 ++++++++++++++ src/__tests__/cloud-connect-profile.test.ts | 9 +- src/cli-schema/cli-help-topics.test.ts | 3 +- src/cli-schema/cli-help.ts | 7 +- src/cli/commands/connection-presentation.ts | 13 ++ src/cli/commands/connection-runtime.ts | 15 ++ src/commands/management/app.ts | 5 +- src/commands/management/output.ts | 4 +- .../daemon-command-descriptor.ts | 12 +- src/core/command-descriptor/registry.ts | 8 +- .../__tests__/daemon-command-registry.test.ts | 15 ++ .../__tests__/request-handler-chain.test.ts | 11 ++ src/daemon/daemon-command-registry.ts | 5 +- .../session-inventory-apps-runtime.test.ts | 75 ++++++++++ src/daemon/handlers/session-inventory.ts | 31 ++++ src/daemon/handlers/session.ts | 7 +- src/daemon/request-admission.ts | 15 +- src/daemon/request-handler-chain.ts | 4 +- src/daemon/request-router.ts | 5 +- src/daemon/server/daemon-runtime.ts | 2 + src/provider-device-runtime.ts | 17 +++ 36 files changed, 892 insertions(+), 149 deletions(-) create mode 100644 packages/provider-limrun/src/app-catalog.test.ts create mode 100644 packages/provider-limrun/src/app-catalog.ts create mode 100644 packages/provider-limrun/src/app-preinstall.ts create mode 100644 packages/provider-limrun/src/session-allocation.ts create mode 100644 src/__tests__/cli-config-limrun.test.ts diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index 2e42310f7c..6627c4d139 100644 --- a/packages/contracts/src/application-lifecycle-interaction.test.ts +++ b/packages/contracts/src/application-lifecycle-interaction.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } from 'vitest'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { Interactor } from './interactor-types.ts'; import type { OpenApplicationInput } from './application-lifecycle-runtime.ts'; @@ -91,6 +91,37 @@ test('direct lifecycle owners preserve the daemon runtime launch URL follow-up', expect(calls[1]?.options).toHaveProperty('launchArgs', undefined); }); +test('direct lifecycle owners resolve app aliases once before target dispatch', async () => { + const open = vi.fn(async () => undefined); + const binding = bindLocalApplicationLifecycleInteractor({ + device: WEB_DEVICE, + signal: new AbortController().signal, + resolveInteractor: async () => interactorWithOpen(open), + }); + const lifecycle = bindDirectApplicationLifecycle({ + binding, + owner: 'Provider', + openTargetIdentity: 'bundle-id', + resolveAppAlias: (app) => (app === 'Example.app.zip' ? 'com.example.app' : app), + }); + + await expect( + lifecycle.resolveOpenTarget({ target: 'Example.app.zip', surface: 'app' }), + ).resolves.toEqual({ appBundleId: 'com.example.app', appName: 'com.example.app' }); + await lifecycle.openApplication( + openInput({ + target: 'Example.app.zip', + positionals: ['Example.app.zip'], + appBundleId: 'Example.app.zip', + }), + ); + + expect(open).toHaveBeenCalledWith( + 'com.example.app', + expect.objectContaining({ appBundleId: 'com.example.app' }), + ); +}); + test.each([ { name: 'more than two positionals', diff --git a/packages/contracts/src/application-lifecycle-interaction.ts b/packages/contracts/src/application-lifecycle-interaction.ts index 0db4ce162f..7b277bb778 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -262,6 +262,7 @@ export type DirectApplicationLifecycleParams = Readonly<{ openTargetIdentity: DirectOpenTargetIdentity; /** Owners whose native open does not replace a running application close it first. */ closeBeforeRelaunch?: boolean; + resolveAppAlias?(app: string): string; /** Port reverse is the one non-direct operation a provider owner may still implement. */ configureProviderPortReverse?: ApplicationLifecycleRuntimeOperations['configureProviderPortReverse']; }>; @@ -282,7 +283,8 @@ export function bindDirectApplicationLifecycle( ); }; return Object.freeze({ - resolveOpenTarget: async (input) => resolveDirectOpenTarget(params.openTargetIdentity, input), + resolveOpenTarget: async (input) => + resolveDirectOpenTarget(params.openTargetIdentity, resolveOpenTargetAlias(params, input)), prepareApplicationOpen: async () => undefined, openApplication: async (input) => await openDirectApplication(params, input), applyRuntimeHints: unavailable, @@ -304,38 +306,64 @@ async function openDirectApplication( params: DirectApplicationLifecycleParams, input: OpenApplicationInput, ): Promise { + const resolvedInput = resolveOpenApplicationAliases(params, input); const { binding } = params; - const interactor = await binding.resolveInteractor(input.execution, input.appBundleId); - if (params.closeBeforeRelaunch && input.relaunch && input.target !== undefined) { + const interactor = await binding.resolveInteractor( + resolvedInput.execution, + resolvedInput.appBundleId, + ); + if (params.closeBeforeRelaunch && resolvedInput.relaunch && resolvedInput.target !== undefined) { await invokeApplicationClose({ device: binding.device, interactor, - positionals: [input.appBundleId ?? input.target], + positionals: [resolvedInput.appBundleId ?? resolvedInput.target], }); } await invokeApplicationOpen({ device: binding.device, interactor, - positionals: input.positionals, - appBundleId: input.appBundleId, - execution: input.execution, + positionals: resolvedInput.positionals, + appBundleId: resolvedInput.appBundleId, + execution: resolvedInput.execution, }); - const followUpUrl = followUpRuntimeLaunchUrl(input); + const followUpUrl = followUpRuntimeLaunchUrl(resolvedInput); if (followUpUrl) { await invokeApplicationOpen({ device: binding.device, interactor, positionals: [followUpUrl], - appBundleId: input.appBundleId, + appBundleId: resolvedInput.appBundleId, execution: { - ...input.execution, + ...resolvedInput.execution, clearAppState: undefined, launchConsole: undefined, launchArgs: undefined, }, }); } - return { appBundleId: input.appBundleId, timing: {} }; + return { appBundleId: resolvedInput.appBundleId, timing: {} }; +} + +function resolveOpenTargetAlias( + params: DirectApplicationLifecycleParams, + input: OpenTargetResolutionInput, +): OpenTargetResolutionInput { + if (!input.target || !params.resolveAppAlias) return input; + return { ...input, target: params.resolveAppAlias(input.target) }; +} + +function resolveOpenApplicationAliases( + params: DirectApplicationLifecycleParams, + input: OpenApplicationInput, +): OpenApplicationInput { + const resolve = params.resolveAppAlias; + if (!resolve) return input; + return { + ...input, + target: input.target ? resolve(input.target) : undefined, + positionals: input.positionals.map((value, index) => (index === 0 ? resolve(value) : value)), + appBundleId: input.appBundleId ? resolve(input.appBundleId) : undefined, + }; } export function followUpRuntimeLaunchUrl(input: OpenApplicationInput): string | undefined { diff --git a/packages/contracts/src/device-provider.ts b/packages/contracts/src/device-provider.ts index 4cf6c88e2a..034ae34aa1 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -66,3 +66,13 @@ export type ProviderDeviceInventorySource = Readonly<{ signal: AbortSignal, ): Promise; }>; + +export type ProviderAppCatalogQuery = Readonly<{ + provider: string; + platform: 'android' | 'ios'; +}>; + +export type ProviderAppCatalog = ( + query: ProviderAppCatalogQuery, + signal?: AbortSignal, +) => Promise; diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index d8f9d7047e..8d70558c44 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -24,6 +24,8 @@ export type { DeviceLease, LeaseLifecycleContext, LeaseLifecycleProvider, + ProviderAppCatalog, + ProviderAppCatalogQuery, ProviderDeviceInventoryOutcome, ProviderDeviceInventorySource, } from '../device-provider.ts'; diff --git a/packages/contracts/src/provider-device-runtime.ts b/packages/contracts/src/provider-device-runtime.ts index 755110272c..838a098fc9 100644 --- a/packages/contracts/src/provider-device-runtime.ts +++ b/packages/contracts/src/provider-device-runtime.ts @@ -4,6 +4,7 @@ import type { DeviceInventoryProvider, DeviceLease, LeaseLifecycleProvider, + ProviderAppCatalog, } from './device-provider.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; @@ -36,6 +37,7 @@ export type ProviderDeviceRuntime = { leaseLifecycle: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifacts?: CloudArtifactProvider; + appCatalog?: ProviderAppCatalog; deviceInventoryProvider: DeviceInventoryProvider; ownsDevice(device: DeviceInfo): boolean; getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor | undefined; diff --git a/packages/provider-limrun/src/app-catalog.test.ts b/packages/provider-limrun/src/app-catalog.test.ts new file mode 100644 index 0000000000..6685274847 --- /dev/null +++ b/packages/provider-limrun/src/app-catalog.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + listLimrunAppAssets, + resolveInstalledAppIdForAsset, + resolveLimrunAppAsset, +} from './app-catalog.ts'; + +describe('Limrun uploaded app catalog', () => { + test('lists only uploaded assets compatible with the requested platform', async () => { + const list = vi.fn(async () => [ + { id: 'android-explicit', name: 'build.bin', os: 'android', md5: 'a' }, + { id: 'android-apk', name: 'com.example.app.apk', md5: 'b' }, + { id: 'ios-zip', name: 'Example.app.zip', md5: 'c' }, + { id: 'pending', name: 'pending.apk' }, + { id: 'unknown', name: 'notes.txt', md5: 'd' }, + ]); + const limrun = { assets: { list } } as never; + + await expect(listLimrunAppAssets(limrun, 'android')).resolves.toEqual([ + { id: 'android-explicit', name: 'build.bin' }, + { id: 'android-apk', name: 'com.example.app.apk' }, + ]); + await expect(listLimrunAppAssets(limrun, 'ios')).resolves.toEqual([ + { id: 'ios-zip', name: 'Example.app.zip' }, + ]); + }); + + test('resolves an exact uploaded asset name and rejects platform mismatches', async () => { + const list = vi + .fn() + .mockResolvedValueOnce([ + { id: 'similar', name: 'Example.app.zip.backup.zip', md5: 'z' }, + { id: 'ios-app', name: 'Example.app.zip', md5: 'a' }, + ]) + .mockResolvedValueOnce([{ id: 'android-app', name: 'Example.apk', md5: 'b' }]); + const limrun = { assets: { list } } as never; + + await expect(resolveLimrunAppAsset(limrun, 'ios', 'Example.app.zip')).resolves.toEqual({ + id: 'ios-app', + name: 'Example.app.zip', + }); + await expect(resolveLimrunAppAsset(limrun, 'ios', 'Example.apk')).resolves.toBeUndefined(); + }); + + test('matches an uploaded iOS asset when the instance also contains Expo Go', () => { + expect( + resolveInstalledAppIdForAsset('easagentdevice.app.zip', [ + { id: 'dev.expo.easagentdevice', name: 'Agent Device' }, + { id: 'host.exp.Exponent', name: 'Expo Go' }, + ]), + ).toBe('dev.expo.easagentdevice'); + expect( + resolveInstalledAppIdForAsset('unrelated-build.zip', [ + { id: 'com.example.first' }, + { id: 'com.example.second' }, + ]), + ).toBeUndefined(); + }); + + test('rejects colliding exact installed identities', () => { + expect( + resolveInstalledAppIdForAsset('example.app.zip', [ + { id: 'com.first', name: 'Example' }, + { id: 'com.second.example', name: 'Second' }, + ]), + ).toBeUndefined(); + }); +}); diff --git a/packages/provider-limrun/src/app-catalog.ts b/packages/provider-limrun/src/app-catalog.ts new file mode 100644 index 0000000000..2f6c097dbc --- /dev/null +++ b/packages/provider-limrun/src/app-catalog.ts @@ -0,0 +1,99 @@ +import type Limrun from '@limrun/api'; +import type { Asset } from '@limrun/api/resources/assets'; +import { AppError } from '@agent-device/kernel/errors'; + +const APP_CATALOG_LIMIT = 1_000; + +export type LimrunAppAsset = Readonly<{ + id: string; + name: string; +}>; + +type InstalledAppIdentity = Readonly<{ id: string; name?: string }>; + +export async function listLimrunAppAssets( + limrun: Limrun, + platform: 'android' | 'ios', + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const assets = await limrun.assets.list({ limit: APP_CATALOG_LIMIT }, { signal }); + signal?.throwIfAborted(); + const apps: LimrunAppAsset[] = []; + for (const asset of assets) { + const app = toAvailableAppAsset(asset, platform); + if (app) apps.push(app); + } + return apps.sort((left, right) => left.name.localeCompare(right.name)); +} + +export async function resolveLimrunAppAsset( + limrun: Limrun, + platform: 'android' | 'ios', + name: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const assets = await limrun.assets.list({ limit: 2, nameFilter: name }, { signal }); + signal?.throwIfAborted(); + const matches = assets + .filter((asset) => asset.name === name) + .map((asset) => toAvailableAppAsset(asset, platform)) + .filter((asset): asset is LimrunAppAsset => asset !== undefined); + if (matches.length <= 1) return matches[0]; + throw new AppError('COMMAND_FAILED', `Limrun returned multiple uploaded apps named ${name}.`, { + app: name, + platform, + assetIds: matches.map((asset) => asset.id), + }); +} + +export function resolveInstalledAppIdForAsset( + assetName: string, + apps: readonly InstalledAppIdentity[], +): string | undefined { + const assetKey = appIdentityKey( + assetName.replace(/\.(?:tar\.gz|tgz|tar|zip|ipa|apk)$/i, '').replace(/\.app$/i, ''), + ); + if (assetKey.length < 5) return undefined; + const candidates = apps.map((app) => ({ app, keys: appIdentityValues(app) })); + const exact = candidates.filter(({ keys }) => keys.includes(assetKey)); + return exact.length === 1 ? exact[0]?.app.id : undefined; +} + +function toAvailableAppAsset( + asset: Asset, + requestedPlatform: 'android' | 'ios', +): LimrunAppAsset | undefined { + if (!asset.md5) return undefined; + const platform = resolveAssetPlatform(asset); + if (platform !== requestedPlatform) return undefined; + return { id: asset.id, name: asset.name }; +} + +function resolveAssetPlatform(asset: Asset): 'android' | 'ios' | undefined { + if (asset.os === 'android' || asset.os === 'ios') return asset.os; + const name = asset.name.toLowerCase(); + if (name.endsWith('.apk')) return 'android'; + if ( + name.endsWith('.ipa') || + name.endsWith('.zip') || + name.endsWith('.tar') || + name.endsWith('.tar.gz') || + name.endsWith('.tgz') + ) { + return 'ios'; + } + return undefined; +} + +function appIdentityValues(app: InstalledAppIdentity): string[] { + const terminalId = app.id.split(/[.:/]/).at(-1); + return [app.id, terminalId, app.name] + .filter((value): value is string => typeof value === 'string') + .map(appIdentityKey); +} + +function appIdentityKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ''); +} diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 0ffc696d47..b1a2ed8afb 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -59,6 +59,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit< runtimeInstance: string; ownsDevice(device: DeviceInfo): boolean; getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; + resolveAppAlias?(device: DeviceInfo, app: string): string; openCurrent(device: DeviceInfo): Promise; hasLiveSession(device: DeviceInfo): boolean; reconnect( @@ -280,6 +281,7 @@ function bindLimrunAppLogs( device, signal, getInteractor: options.getInteractor, + resolveAppAlias: (app) => options.resolveAppAlias?.(device, app) ?? app, configurePortReverse: options.configurePortReverse, }), runtimeFacts.operations, diff --git a/packages/provider-limrun/src/app-preinstall.ts b/packages/provider-limrun/src/app-preinstall.ts new file mode 100644 index 0000000000..02475629ec --- /dev/null +++ b/packages/provider-limrun/src/app-preinstall.ts @@ -0,0 +1,37 @@ +import type Limrun from '@limrun/api'; +import type { LeaseLifecycleContext } from '@agent-device/contracts/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { LimrunAndroidSession } from './android.ts'; +import { + resolveInstalledAppIdForAsset, + resolveLimrunAppAsset, + type LimrunAppAsset, +} from './app-catalog.ts'; +import { createLimrunDeviceSession } from './device-session.ts'; +import type { LimrunIosSession } from './ios.ts'; + +export async function resolveRequestedLimrunAppAsset( + limrun: Limrun, + platform: 'android' | 'ios', + context?: LeaseLifecycleContext, +): Promise { + const value = context?.flags?.providerApp; + const name = typeof value === 'string' ? value.trim() : ''; + if (!name) return undefined; + return await resolveLimrunAppAsset(limrun, platform, name, context?.signal); +} + +export async function resolvePreinstalledAppId( + session: LimrunAndroidSession | LimrunIosSession, + asset: LimrunAppAsset, +): Promise { + const deviceSession = createLimrunDeviceSession(session); + const apps = await deviceSession.listApps('user-installed'); + const matchedAppId = resolveInstalledAppIdForAsset(asset.name, apps); + if (matchedAppId) return matchedAppId; + throw new AppError( + 'COMMAND_FAILED', + `Limrun installed ${asset.name}, but its application identifier could not be resolved unambiguously.`, + { asset: asset.name, installedApps: apps.map((app) => app.id) }, + ); +} diff --git a/packages/provider-limrun/src/connection-verification.test.ts b/packages/provider-limrun/src/connection-verification.test.ts index 46feaaeda0..24950f2a78 100644 --- a/packages/provider-limrun/src/connection-verification.test.ts +++ b/packages/provider-limrun/src/connection-verification.test.ts @@ -42,7 +42,7 @@ test('Limrun verification reads the selected instance service without creating a }, app: { status: 'missing', - message: 'A new Limrun instance does not have your app yet.', + message: 'Run apps to choose an uploaded asset before allocation.', }, }); assert.deepEqual(mockState.androidList.mock.calls, [[{ limit: 1 }]]); diff --git a/packages/provider-limrun/src/connection-verification.ts b/packages/provider-limrun/src/connection-verification.ts index 426708a0e7..b277d1cddd 100644 --- a/packages/provider-limrun/src/connection-verification.ts +++ b/packages/provider-limrun/src/connection-verification.ts @@ -67,7 +67,7 @@ export async function verifyLimrunConnection( }, app: { status: 'missing', - message: 'A new Limrun instance does not have your app yet.', + message: 'Run apps to choose an uploaded asset before allocation.', }, }; } diff --git a/packages/provider-limrun/src/lifecycle.ts b/packages/provider-limrun/src/lifecycle.ts index 67dfe0d24d..b8995d6c1c 100644 --- a/packages/provider-limrun/src/lifecycle.ts +++ b/packages/provider-limrun/src/lifecycle.ts @@ -14,6 +14,7 @@ type LimrunLifecycleParams = Readonly<{ configurePortReverse( options: ProviderPortReverseOptions, ): Promise | undefined>; + resolveAppAlias?(app: string): string; }>; /** Limrun owns its live-session lifecycle, relaunch, and exact port-reverse mechanics. */ @@ -24,6 +25,7 @@ export function bindLimrunApplicationLifecycle( owner: 'Limrun', openTargetIdentity: 'bundle-id', closeBeforeRelaunch: true, + resolveAppAlias: params.resolveAppAlias, configureProviderPortReverse: async (input) => await params.configurePortReverse(input), binding: bindProviderApplicationLifecycleInteractor({ device: params.device, diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index 5b705153f7..4a22879d38 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -14,6 +14,7 @@ import type { const state = vi.hoisted(() => ({ constructorOptions: [] as Array<{ defaultHeaders?: Record }>, + androidCreateInputs: [] as unknown[], tunnelClose: vi.fn(), disconnect: vi.fn(), })); @@ -27,20 +28,31 @@ vi.mock('@limrun/api', () => ({ }; readonly androidInstances = { - create: vi.fn(async () => ({ - metadata: { id: 'android-instance-1' }, - status: { - token: 'instance-token', - apiUrl: 'https://android.example', - adbWebSocketUrl: 'wss://adb.example', - }, - })), + create: vi.fn(async (input: unknown) => { + state.androidCreateInputs.push(input); + return { + metadata: { id: 'android-instance-1' }, + status: { + token: 'instance-token', + apiUrl: 'https://android.example', + adbWebSocketUrl: 'wss://adb.example', + }, + }; + }), list: vi.fn(), delete: vi.fn(async () => undefined), }; readonly assets = { getOrUpload: vi.fn(), + list: vi.fn(async () => [ + { + id: 'asset-example', + name: 'Example.apk', + md5: 'uploaded', + os: 'android', + }, + ]), }; constructor(options: { defaultHeaders?: Record }) { @@ -108,6 +120,63 @@ test('factory uses the injected Android and host adapters as its construction se assert.equal(state.tunnelClose.mock.calls.length, 1); }); +test('allocation installs an exact uploaded asset before binding its application id', async () => { + state.androidCreateInputs.length = 0; + const fixture = createContractFixture(); + const runtime = createLimrunRuntime({ apiKey: 'lim_test_key' }, fixture.dependencies); + + try { + await runtime.leaseLifecycle.allocate?.(androidLease(), { + flags: { providerApp: 'Example.apk' }, + }); + + assert.deepEqual(state.androidCreateInputs[0], { + wait: true, + metadata: { + displayName: 'agent-device-team-a-run-a', + labels: { + source: 'agent-device-cli', + provider: 'limrun', + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + }, + }, + spec: { + initialAssets: [ + { + kind: 'App', + source: 'AssetIDs', + assetIds: ['asset-example'], + }, + ], + }, + }); + assert.equal(fixture.listApps.mock.calls[0]?.[1], 'user-installed'); + } finally { + await runtime.shutdown(); + } +}); + +test('allocation rejects an unrelated foreground app after preinstall', async () => { + const fixture = createContractFixture(); + fixture.listApps.mockResolvedValueOnce([{ id: 'com.foreground.app', name: 'Foreground' }]); + fixture.getForegroundApp.mockResolvedValueOnce({ + appId: 'com.foreground.app', + activity: '.MainActivity', + }); + const runtime = createLimrunRuntime({ apiKey: 'lim_test_key' }, fixture.dependencies); + + await assert.rejects( + async () => + await runtime.leaseLifecycle.allocate?.(androidLease(), { + flags: { providerApp: 'Example.apk' }, + }), + (error) => error instanceof AppError && error.code === 'COMMAND_FAILED', + ); + assert.equal(fixture.getForegroundApp.mock.calls.length, 0); +}); + function createContractFixture() { const adbCalls: string[][] = []; const activeReverseMappings: LimrunPortReverseMapping[] = []; @@ -120,6 +189,10 @@ function createContractFixture() { visible: false, inputOwner: 'unknown' as const, })); + const getForegroundApp = vi.fn(async () => ({ + appId: 'com.example.app', + activity: '.MainActivity', + })); const dependencies = { clientVersion: 'test-version', android: { @@ -128,10 +201,7 @@ function createContractFixture() { createInMemoryPortReverse(adb, activeReverseMappings), inferAppName: async () => 'Example', listApps, - getForegroundApp: async () => ({ - appId: 'com.example.app', - activity: '.MainActivity', - }), + getForegroundApp, getKeyboardState, dismissKeyboard: async () => ({ visible: false, @@ -155,7 +225,15 @@ function createContractFixture() { readBundleAppName: async () => undefined, }, } satisfies LimrunRuntimeDependencies; - return { adbCalls, createInteractor, dependencies, getKeyboardState, interactor, listApps }; + return { + adbCalls, + createInteractor, + dependencies, + getForegroundApp, + getKeyboardState, + interactor, + listApps, + }; } function createInMemoryPortReverse(adb: LimrunAdbExecutor, mappings: LimrunPortReverseMapping[]) { diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 3b0cc201d6..79f0ba36f3 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -4,6 +4,8 @@ import type { DeviceInventoryProvider, DeviceLease, LeaseLifecycleProvider, + LeaseLifecycleContext, + ProviderAppCatalog, ProviderDeviceInstallOptions, ProviderDeviceInstallResult, ProviderDeviceRuntime, @@ -16,22 +18,11 @@ import { cleanupLimrunAndroidAdbTunnel, configureLimrunAndroidPortReverse, createLimrunAndroidInteractor, - createLimrunAndroidSession, installLimrunAndroidApp, type LimrunAndroidSession, } from './android.ts'; -import { - buildLimrunDevice, - LIMRUN_PROVIDER, - parseLimrunDeviceId, - platformForLimrunLeaseBackend, -} from './device.ts'; -import { - createLimrunIosInteractor, - createLimrunIosSession, - installLimrunIosApp, - type LimrunIosSession, -} from './ios.ts'; +import { LIMRUN_PROVIDER, parseLimrunDeviceId, platformForLimrunLeaseBackend } from './device.ts'; +import { createLimrunIosInteractor, installLimrunIosApp, type LimrunIosSession } from './ios.ts'; import { createLimrunDeviceSession, type LimrunDeviceSession } from './device-session.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; import type { @@ -45,15 +36,7 @@ import type { LimrunAppLogReader } from './app-log-poller.ts'; import { buildLimrunClientOptions, LIMRUN_CLIENT_HEADER } from './client-options.ts'; import { resolveLimrunRuntimeInstance } from './runtime-instance.ts'; import type { LimrunRequestOperationDrain } from './request-cancellation.ts'; - -type LimrunInstance = { - metadata: { id: string }; - status: { - token: string; - apiUrl?: string; - adbWebSocketUrl?: string; - }; -}; +import type { LimrunAppAsset } from './app-catalog.ts'; type LimrunRuntimeSession = LimrunIosSession | LimrunAndroidSession; @@ -104,15 +87,27 @@ export function createLimrunRuntime( class LimrunRuntimeImplementation implements ProviderDeviceRuntime { private readonly limrun: Limrun; private readonly sessions = new Map(); + private readonly appAliases = new Map< + string, + Readonly<{ assetName: string; installedAppId: string }> + >(); private readonly options: LimrunRuntimeOptions; private readonly dependencies: LimrunRuntimeDependencies; readonly provider = LIMRUN_PROVIDER; readonly leaseLifecycle: LeaseLifecycleProvider = { - allocate: async (lease) => await this.allocate(lease), + allocate: async (lease, context) => await this.allocate(lease, context), release: async (lease) => await this.release(lease), }; + readonly appCatalog: ProviderAppCatalog = async (query, signal) => { + if (query.provider !== this.provider) return undefined; + const { listLimrunAppAssets } = await import('./app-catalog.ts'); + return (await listLimrunAppAssets(this.limrun, query.platform, signal)).map( + (asset) => asset.name, + ); + }; + readonly recoverExpiredLease: ProviderExpiredLeaseRecovery = async (lease) => { if (lease.leaseProvider !== this.provider || !platformForLimrunLeaseBackend(lease.backend)) { throw new AppError('UNSUPPORTED_OPERATION', 'Limrun cannot recover this expired lease.', { @@ -226,77 +221,53 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const sessions = [...this.sessions.values()]; await Promise.allSettled(sessions.map(async (session) => await this.terminateSession(session))); this.sessions.clear(); + this.appAliases.clear(); } - private async allocate(lease: DeviceLease): Promise | undefined> { + private async allocate( + lease: DeviceLease, + context?: LeaseLifecycleContext, + ): Promise | undefined> { if (lease.leaseProvider !== this.provider) return undefined; const platform = platformForLimrunLeaseBackend(lease.backend); if (!platform) return undefined; const existing = this.sessions.get(lease.leaseId); if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; + const { resolvePreinstalledAppId, resolveRequestedLimrunAppAsset } = + await import('./app-preinstall.ts'); + const { allocateLimrunAndroidSession, allocateLimrunIosSession } = + await import('./session-allocation.ts'); + const requestedAsset = await resolveRequestedLimrunAppAsset(this.limrun, platform, context); const session = platform === 'ios' - ? await this.createIosSession(lease) - : await this.createAndroidSession(lease); - this.sessions.set(lease.leaseId, session); - return { limrunInstanceId: session.instanceId, device: session.device }; - } - - private async createIosSession(lease: DeviceLease): Promise { - const instance = (await this.limrun.iosInstances.create({ - wait: true, - metadata: this.buildInstanceMetadata(lease), - spec: this.options.region ? { region: this.options.region } : {}, - })) as LimrunInstance; - try { - if (!instance.status.apiUrl) { - throw new AppError('COMMAND_FAILED', 'Limrun iOS instance did not expose apiUrl'); + ? await allocateLimrunIosSession(this.sessionAllocationParams(lease, requestedAsset)) + : await allocateLimrunAndroidSession(this.sessionAllocationParams(lease, requestedAsset)); + if (requestedAsset) { + try { + const installedAppId = await resolvePreinstalledAppId(session, requestedAsset); + this.appAliases.set(lease.leaseId, { + assetName: requestedAsset.name, + installedAppId, + }); + } catch (error) { + await this.terminateSession(session); + throw error; } - return await createLimrunIosSession( - { - lease, - instanceId: instance.metadata.id, - device: buildLimrunDevice('ios', lease, instance.metadata.id), - apiUrl: instance.status.apiUrl, - token: instance.status.token, - }, - this.dependencies, - ); - } catch (error) { - await this.limrun.iosInstances.delete(instance.metadata.id).catch(() => {}); - throw error; } + this.sessions.set(lease.leaseId, session); + return { limrunInstanceId: session.instanceId, device: session.device }; } - private async createAndroidSession(lease: DeviceLease): Promise { - const instance = (await this.limrun.androidInstances.create({ - wait: true, + private sessionAllocationParams(lease: DeviceLease, app?: LimrunAppAsset) { + return { + limrun: this.limrun, + lease, metadata: this.buildInstanceMetadata(lease), - spec: this.options.region ? { region: this.options.region } : {}, - })) as LimrunInstance; - try { - if (!instance.status.apiUrl || !instance.status.adbWebSocketUrl) { - throw new AppError( - 'COMMAND_FAILED', - 'Limrun Android instance did not expose API and ADB websocket endpoints', - ); - } - return await createLimrunAndroidSession( - { - lease, - instanceId: instance.metadata.id, - device: buildLimrunDevice('android', lease, instance.metadata.id), - apiUrl: instance.status.apiUrl, - adbUrl: instance.status.adbWebSocketUrl, - token: instance.status.token, - }, - this.dependencies, - ); - } catch (error) { - await this.limrun.androidInstances.delete(instance.metadata.id).catch(() => {}); - throw error; - } + region: this.options.region, + app, + dependencies: this.dependencies, + }; } private buildInstanceMetadata(lease: DeviceLease) { @@ -317,6 +288,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { if (!session) return await this.releaseRecoveredSession(lease); await this.terminateSession(session); this.sessions.delete(lease.leaseId); + this.appAliases.delete(lease.leaseId); return { limrunInstanceId: session.instanceId }; } @@ -359,6 +331,13 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { return session?.platform === parsed.platform ? session : undefined; } + resolveAppAlias(device: DeviceInfo, app: string): string { + const parsed = parseLimrunDeviceId(device.id); + if (!parsed) return app; + const alias = this.appAliases.get(parsed.leaseId); + return alias?.assetName === app ? alias.installedAppId : app; + } + currentAppLogReader(device: DeviceInfo): LimrunAppLogReader | undefined { const session = this.getSessionForDevice(device); if (!session) return undefined; @@ -407,6 +386,7 @@ async function loadLimrunPlatformRuntime( ownsDevice: (device) => runtime.ownsDevice(device), hasLiveSession: (device) => runtime.hasLiveSession(device), getInteractor: (device, runner) => runtime.getInteractor(device, runner), + resolveAppAlias: (device, app) => runtime.resolveAppAlias(device, app), openCurrent: async (device) => runtime.currentAppLogReader(device), reconnect: async (descriptor, signal) => await runtime.reconnectAppLogReader(descriptor, signal), diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts new file mode 100644 index 0000000000..5df042a1d6 --- /dev/null +++ b/packages/provider-limrun/src/session-allocation.ts @@ -0,0 +1,116 @@ +import type Limrun from '@limrun/api'; +import type { DeviceLease } from '@agent-device/contracts/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { createLimrunAndroidSession, type LimrunAndroidSession } from './android.ts'; +import { buildLimrunDevice } from './device.ts'; +import { createLimrunIosSession, type LimrunIosSession } from './ios.ts'; +import type { LimrunAppAsset } from './app-catalog.ts'; +import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; + +type LimrunInstance = { + metadata: { id: string }; + status: { + token: string; + apiUrl?: string; + adbWebSocketUrl?: string; + }; +}; + +type SessionAllocationParams = Readonly<{ + limrun: Limrun; + lease: DeviceLease; + metadata: Readonly<{ + displayName: string; + labels: Readonly>; + }>; + region?: string; + app?: LimrunAppAsset; + dependencies: LimrunRuntimeDependencies; +}>; + +export async function allocateLimrunIosSession( + params: SessionAllocationParams, +): Promise { + const instance = (await params.limrun.iosInstances.create({ + wait: true, + metadata: params.metadata, + spec: { + ...(params.region ? { region: params.region } : {}), + ...(params.app + ? { + initialAssets: [ + { + kind: 'App' as const, + source: 'AssetID' as const, + assetId: params.app.id, + launchMode: 'RelaunchIfRunning' as const, + }, + ], + } + : {}), + }, + })) as LimrunInstance; + try { + if (!instance.status.apiUrl) { + throw new AppError('COMMAND_FAILED', 'Limrun iOS instance did not expose apiUrl'); + } + return await createLimrunIosSession( + { + lease: params.lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('ios', params.lease, instance.metadata.id), + apiUrl: instance.status.apiUrl, + token: instance.status.token, + }, + params.dependencies, + ); + } catch (error) { + await params.limrun.iosInstances.delete(instance.metadata.id).catch(() => {}); + throw error; + } +} + +export async function allocateLimrunAndroidSession( + params: SessionAllocationParams, +): Promise { + const instance = (await params.limrun.androidInstances.create({ + wait: true, + metadata: params.metadata, + spec: { + ...(params.region ? { region: params.region } : {}), + ...(params.app + ? { + initialAssets: [ + { + kind: 'App' as const, + source: 'AssetIDs' as const, + assetIds: [params.app.id], + }, + ], + } + : {}), + }, + })) as LimrunInstance; + try { + if (!instance.status.apiUrl || !instance.status.adbWebSocketUrl) { + throw new AppError( + 'COMMAND_FAILED', + 'Limrun Android instance did not expose API and ADB websocket endpoints', + ); + } + return await createLimrunAndroidSession( + { + lease: params.lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('android', params.lease, instance.metadata.id), + apiUrl: instance.status.apiUrl, + token: instance.status.token, + adbUrl: instance.status.adbWebSocketUrl, + }, + params.dependencies, + ); + } catch (error) { + await params.limrun.androidInstances.delete(instance.metadata.id).catch(() => {}); + throw error; + } +} diff --git a/src/__tests__/cli-config-limrun.test.ts b/src/__tests__/cli-config-limrun.test.ts new file mode 100644 index 0000000000..309a6c685d --- /dev/null +++ b/src/__tests__/cli-config-limrun.test.ts @@ -0,0 +1,111 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + hashRemoteConfigFile, + readActiveConnectionState, + writeRemoteConnectionState, +} from '../remote/remote-connection-state.ts'; +import { runCliCapture } from './cli-capture.ts'; +import { makeTempWorkspace } from './cli-config-fixtures.ts'; + +test('Limrun apps lists uploaded assets without allocating an instance', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'limrun.remote.json'); + fs.writeFileSync(remoteConfig, '{}', 'utf8'); + const now = new Date().toISOString(); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'limrun-apps', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + tenant: 'limrun', + runId: 'run-apps', + leaseBackend: 'android-instance', + leaseProvider: 'limrun', + platform: 'android', + connectedAt: now, + updatedAt: now, + }, + }); + + const result = await runCliCapture(['apps', '--state-dir', stateDir, '--json'], { + cwd: project, + env: { HOME: home }, + defaultResponse: { ok: true, data: { apps: ['Example.apk'] } }, + }); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'apps'); + assert.equal(result.calls[0]?.flags?.leaseId, undefined); + assert.equal(result.calls[0]?.flags?.leaseProvider, 'limrun'); + assert.equal(readActiveConnectionState({ stateDir })?.leaseId, undefined); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('Limrun open allocates with the exact uploaded asset name', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'limrun.remote.json'); + fs.writeFileSync(remoteConfig, '{}', 'utf8'); + const now = new Date().toISOString(); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'limrun-open', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + tenant: 'limrun', + runId: 'run-open', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + platform: 'ios', + connectedAt: now, + updatedAt: now, + }, + }); + + const result = await runCliCapture( + ['open', 'Example.app.zip', '--state-dir', stateDir, '--json'], + { + cwd: project, + env: { HOME: home }, + sendToDaemon: async (req) => { + if (req.command === 'lease_allocate') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-limrun-open', + tenantId: 'limrun', + runId: 'run-open', + backend: 'ios-instance', + leaseProvider: 'limrun', + }, + }, + }; + } + if (req.command === 'open') return { ok: true, data: { appId: 'com.example.app' } }; + throw new Error(`unexpected daemon command: ${req.command}`); + }, + }, + ); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 2); + assert.equal(result.calls[0]?.command, 'lease_allocate'); + assert.equal(result.calls[0]?.flags?.providerApp, 'Example.app.zip'); + assert.equal(result.calls[1]?.command, 'open'); + assert.equal(result.calls[1]?.positionals?.[0], 'Example.app.zip'); + assert.equal(result.calls[1]?.flags?.providerApp, 'Example.app.zip'); + assert.equal(readActiveConnectionState({ stateDir })?.leaseId, 'lease-limrun-open'); + + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index b075fbafa6..fb3f486168 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -53,7 +53,7 @@ beforeEach(() => { }, app: { status: 'missing', - message: 'A new Limrun instance does not have your app yet.', + message: 'Run apps to choose an uploaded asset before allocation.', }, }); mockedVerifyWebDriverConnection.mockImplementation(async (options) => @@ -544,12 +544,13 @@ test('connect output makes verified configuration, deferred device allocation, a ); assert.match( result.stdout, - /App: not installed yet — A new Limrun instance does not have your app yet/, + /App: not installed yet — Run apps to choose an uploaded asset before allocation/, ); assert.match(result.stdout, /No live device session has been created/); assert.match(result.stdout, /Next:/); - assert.match(result.stdout, /agent-device install /); - assert.match(result.stdout, /agent-device open --relaunch/); + assert.match(result.stdout, /agent-device apps/); + assert.match(result.stdout, /agent-device open /); + assert.doesNotMatch(result.stdout, /agent-device install/); assert.doesNotMatch(result.stdout, /lease pending/); }); diff --git a/src/cli-schema/cli-help-topics.test.ts b/src/cli-schema/cli-help-topics.test.ts index 86f51af71e..87919449b6 100644 --- a/src/cli-schema/cli-help-topics.test.ts +++ b/src/cli-schema/cli-help-topics.test.ts @@ -446,7 +446,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 9d7754a388..ecf0b3d1cf 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -736,9 +736,9 @@ Providers: After direct-provider connect: Read the printed Device, App, Next, and workflow-note lines. They are also available as verification/device/app/liveSession/nextSteps/notes in --json output. BrowserStack and AWS Device Farm create the hosted session on open. open needs the installed package or bundle identifier, not the app artifact name or ARN. - A new Limrun instance has no user app. Run install first; install allocates the instance, then open launches the installed id. + Before Limrun allocation, apps lists compatible uploaded app assets without creating an instance. open creates the instance with that asset, resolves its installed app id, and launches it. install remains available when the app comes from a fresh local path or URL. AWS Device Farm cannot install after allocation. If connect reports no attached app, run its printed reconnect command, which includes --session --force, before open. - Do not run devices or apps as a pre-open catalog probe for direct providers; those commands can allocate the deferred provider session and only inspect that live device. + Do not run devices as a pre-open catalog probe for direct providers; it can allocate the deferred provider session. Limrun is the exception for apps: before allocation it lists uploaded assets for the selected platform. Device cloud interfaces: CLI is the canonical bootstrap path: connect limrun/browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. @@ -795,7 +795,8 @@ Limrun direct-device flow: agent-device connect limrun --platform android Limrun creates remote iOS simulators and Android emulators only. Do not pass local device selectors such as --udid, --serial, or --device. - agent-device open com.example.app + agent-device apps + agent-device open Example.apk agent-device snapshot -i agent-device close agent-device disconnect diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index 7257a8f184..2d9ceba8d5 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -40,6 +40,14 @@ export function buildLeasePreparationNotice( 'No live device session has been created. Run devices to inspect inventory without allocating, then open when ready.', }; } + if (state.leaseProvider === 'limrun') { + return { + status: 'deferred', + nextSteps: buildConnectWorkflow(state, verification).nextSteps, + message: + 'No live device session has been created. Run apps to inspect uploaded assets without allocating; open creates the instance.', + }; + } if (leaseKind === 'direct-device-provider') { return { status: 'deferred', @@ -233,6 +241,11 @@ function buildUnscopedConnectWorkflow( if (!verification && leaseKind === 'direct-device-provider') { return { nextSteps: defaultDirectProviderLifecycle() }; } + if (verification?.provider === 'limrun') { + return { + nextSteps: ['agent-device apps', 'agent-device open '], + }; + } const appMissing = verification?.app?.status === 'missing'; return { nextSteps: requiresInstall(verification) diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 51a769b367..90518d2356 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -111,6 +111,13 @@ export async function materializeRemoteConnectionForCommand(options: { remoteConfig.profile, ); const nextFlags = { ...mergedFlags, session: state.session }; + if ( + state.leaseProvider === 'limrun' && + command === PUBLIC_COMMANDS.open && + typeof options.positionals?.[0] === 'string' + ) { + nextFlags.providerApp = options.positionals[0]; + } let nextRuntime = selectCompatibleRuntime(state.runtime, nextFlags.platform) ?? options.runtime; let nextState = state; let changed = !existingState; @@ -378,12 +385,20 @@ type ConnectionLeasePolicy = { function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { if (state.leaseProvider === 'proxy') return PROXY_CONNECTION_LEASE_POLICY; + if (state.leaseProvider === 'limrun') return LIMRUN_CONNECTION_LEASE_POLICY; if (isCloudWebDriverProviderName(state.leaseProvider)) { return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; } return DEFAULT_CONNECTION_LEASE_POLICY; } +const LIMRUN_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { + shouldAllocate: (command) => + command !== PUBLIC_COMMANDS.apps && !leaseDeferredCommands.has(command), + ttlMs: () => undefined, + resolveLeaseState: async (options) => ({ state: options.state }), +}; + const DEFAULT_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => !leaseDeferredCommands.has(command), ttlMs: () => undefined, diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 3abfa93628..4de2fddc0a 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -188,8 +188,9 @@ const closeDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.close, (input) => export const appsCommandFacet = defineCommandFacet({ name: 'apps', text: { - summary: 'List installed apps', - cliDetail: 'Defaults to user-installed apps; use --all to include system/OEM apps.', + summary: 'List installed apps or deferred provider app assets', + cliDetail: + 'Before Limrun allocation, lists uploaded app assets for the selected platform. On a live device, defaults to user-installed apps; use --all to include system/OEM apps.', }, metadata: appsCommandMetadata, definition: appsCommandDefinition, diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 79eb03c3a2..b7ef799f7f 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -67,13 +67,13 @@ function appsCliOutput(params: { stderr: params.appsFilter === 'all' ? 'Showing all apps, including system apps.\n' - : 'Showing user-installed apps. Use --all to include system apps.\n', + : 'Showing user-installed apps or deferred provider app assets. Use --all to include system apps on a live device.\n', text: params.result.length > 0 ? params.result.join('\n') : params.appsFilter === 'all' ? 'No apps found.' - : 'No user-installed apps found.', + : 'No user apps or provider app assets found.', }; } diff --git a/src/core/command-descriptor/daemon-command-descriptor.ts b/src/core/command-descriptor/daemon-command-descriptor.ts index 2fa89e82c4..63649cdf94 100644 --- a/src/core/command-descriptor/daemon-command-descriptor.ts +++ b/src/core/command-descriptor/daemon-command-descriptor.ts @@ -51,15 +51,5 @@ export type DaemonCommandDescriptor = { preferExplicitDeviceOverExistingSession?: boolean; allowSessionlessDefaultDevice?: (req: TRequest) => boolean; skipSessionlessProviderDevice?: (req: TRequest) => boolean; - /** - * #2016: this request shape is eligible for the sessionless, - * no-lease-anywhere lease-admission bypass — a session that was never - * created (deferred `connect`, `open` never ran) has no lease to admit or - * release. Only `close` declares it, and only for the plain-close shape - * (no app-target positional): `close ` resolves its device straight - * from flags when there's no session, so it must stay behind full - * lease/tenant admission. Declared here so `request-admission.ts` asks the - * registry instead of reclassifying `req.command`/`req.positionals` itself. - */ - sessionlessPlainCloseAdmissionExempt?: (req: TRequest) => boolean; + sessionlessLeaseAdmissionExempt?: (req: TRequest) => boolean; }; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 84f342e23b..5835d0c082 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -179,6 +179,11 @@ const isShardedTestRequest = (req: DispatchedCommand): boolean => const isPlainCloseRequest = (req: DispatchedCommand): boolean => (req.positionals?.length ?? 0) === 0; +const isDeferredProviderAppCatalogRequest = (req: DispatchedCommand): boolean => + req.flags?.leaseId === undefined && + typeof req.flags?.leaseProvider === 'string' && + (req.flags?.platform === 'android' || req.flags?.platform === 'ios'); + // ADR 0014 request-sensitive ref-frame resolvers. The action is the leading // positional (see keyboard/alert daemon writers in src/commands/system/index.ts // and src/commands/capture/alert.ts). Only the read-only status probes preserve @@ -607,6 +612,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, + sessionlessLeaseAdmissionExempt: isDeferredProviderAppCatalogRequest, }, platformExecution: { kind: 'device-runtime', uses: [appsRuntimeUse] as const }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -980,7 +986,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'may-invalidate', allowInvalidRecording: true, saveScriptFlagOwner: true, - sessionlessPlainCloseAdmissionExempt: isPlainCloseRequest, + sessionlessLeaseAdmissionExempt: isPlainCloseRequest, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index 2446ba6eac..5113151443 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -8,6 +8,7 @@ import { getSessionCommandKind, isHumanControlMutation, isLeaseAdmissionExempt, + isSessionlessLeaseAdmissionExempt, shouldBlockForInvalidRecording, shouldGuardAndroidBlockingDialog, shouldLockSessionExecution, @@ -82,6 +83,20 @@ test('daemon command registry preserves request admission traits', () => { assert.equal(shouldValidateSessionSelector(INTERNAL_COMMANDS.leaseAllocate), true); assert.equal(isLeaseAdmissionExempt(PUBLIC_COMMANDS.open), false); assert.equal(shouldLockSessionExecution(PUBLIC_COMMANDS.open), true); + assert.equal( + isSessionlessLeaseAdmissionExempt({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun' }, + }), + true, + ); + assert.equal( + isSessionlessLeaseAdmissionExempt({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, + }), + false, + ); }); test('daemon command registry preserves replay and recording traits', () => { diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index 04e8fd91ff..8f8057c457 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -102,6 +102,17 @@ test('request handler chain routes trace commands to the record-trace family', a assert.equal(response?.data?.trace, 'started'); }); +test('request handler chain forwards the deferred provider app catalog to inventory', async () => { + const req = makeRequest('apps'); + req.flags = { platform: 'android', leaseProvider: 'limrun' }; + const response = await runRequestHandlerChain({ + ...makeChainParams(req), + providerAppCatalog: async () => ['Example.apk'], + }); + + assert.deepEqual(response, { ok: true, data: { apps: ['Example.apk'] } }); +}); + // R61 put `react-native dismiss-overlay` behind the owner's own `tapPoint` admission, and the // chain had never forwarded the request's runtime bindings to that route — so the dismissal leg // had been reaching a missing gateway ever since R48 moved it off the retired dispatcher. Only diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index 9c9c0f4b8d..8b2424fe30 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -95,9 +95,8 @@ export function usesSessionlessDefaultProviderDevice(req: DaemonRequest): boolea return typeof allow === 'function' ? allow(req) : false; } -/** #2016: whether this request qualifies for the sessionless plain-close lease-admission bypass. */ -export function isSessionlessPlainCloseAdmissionExempt(req: DaemonRequest): boolean { - const exempt = getDaemonCommandDescriptor(req.command)?.sessionlessPlainCloseAdmissionExempt; +export function isSessionlessLeaseAdmissionExempt(req: DaemonRequest): boolean { + const exempt = getDaemonCommandDescriptor(req.command)?.sessionlessLeaseAdmissionExempt; return typeof exempt === 'function' ? exempt(req) : false; } diff --git a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts index 2f71c204a2..0cd1d2081e 100644 --- a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts @@ -14,6 +14,10 @@ import type { BindDeviceRuntime, InspectDeviceRuntimeFacts, } from '../../request-runtime-binding.ts'; +import { + clearRequestAbortRegistration, + registerRequestAbort, +} from '@agent-device/host-kit/request'; const MACOS_DEVICE = { platform: 'apple' as const, @@ -123,3 +127,74 @@ test('macOS apps consumes generic readiness and app inventory through one runtim expect(ensureReady).toHaveBeenCalledOnce(); expect(listApps).toHaveBeenCalledOnce(); }); + +test('deferred provider apps returns uploaded assets without resolving a device', async () => { + const sessionStore = makeSessionStore(); + const listAvailableApps = vi.fn(async () => ['Example.apk', 'Settings.apk']); + const req: DaemonRequest = { + token: 'test-token', + session: 'limrun-apps', + command: 'apps', + positionals: [], + flags: { + platform: 'android', + leaseProvider: 'limrun', + }, + }; + + const response = await handleSessionInventoryCommands({ + req, + sessionName: req.session, + sessionStore, + inspectFacts, + bindDevice, + providerAppCatalog: listAvailableApps, + }); + + expect(response).toEqual({ + ok: true, + data: { apps: ['Example.apk', 'Settings.apk'] }, + }); + expect(listAvailableApps).toHaveBeenCalledWith( + { + provider: 'limrun', + platform: 'android', + }, + undefined, + ); + expect(inspectFacts).not.toHaveBeenCalled(); + expect(bindCount).toBe(0); +}); + +test('deferred provider apps forwards request cancellation to the catalog', async () => { + const requestId = 'provider-app-catalog-abort'; + const registration = registerRequestAbort(requestId); + const reason = new Error('catalog canceled'); + const providerAppCatalog = vi.fn(async (_query, signal?: AbortSignal) => { + expect(signal).toBe(registration?.controller.signal); + return await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }); + const req: DaemonRequest = { + token: 'test-token', + session: 'limrun-apps-abort', + command: 'apps', + positionals: [], + flags: { platform: 'ios', leaseProvider: 'limrun' }, + meta: { requestId }, + }; + + try { + const response = handleSessionInventoryCommands({ + req, + sessionName: req.session, + sessionStore: makeSessionStore(), + providerAppCatalog, + }); + registration?.controller.abort(reason); + await expect(response).rejects.toBe(reason); + } finally { + clearRequestAbortRegistration(registration); + } +}); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 192b0ff85f..8c1cf7fa4f 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -39,6 +39,9 @@ import { } from '@agent-device/contracts/platform-runtime-operations'; import { ensureAppsRuntimeReady, listAppsFromRuntime } from '../apps-runtime.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; +import type { ProviderAppCatalog } from '@agent-device/contracts/device'; +import { resolveLeaseScope } from '../lease-context.ts'; +import { getRequestSignal } from '@agent-device/host-kit/request'; export async function handleSessionInventoryCommands(params: { req: DaemonRequest; @@ -46,6 +49,7 @@ export async function handleSessionInventoryCommands(params: { sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; + providerAppCatalog?: ProviderAppCatalog; }): Promise { const { req, sessionName, sessionStore } = params; switch (req.command) { @@ -67,6 +71,7 @@ export async function handleSessionInventoryCommands(params: { sessionStore, bindDevice: params.bindDevice, inspectFacts: params.inspectFacts, + providerAppCatalog: params.providerAppCatalog, }); default: return null; @@ -302,9 +307,15 @@ async function handleAppsInventory(params: { sessionName: string; sessionStore: SessionStore; bindDevice?: BindDeviceRuntime; + providerAppCatalog?: ProviderAppCatalog; inspectFacts?: InspectDeviceRuntimeFacts; }): Promise { const { req, sessionName, sessionStore, bindDevice, inspectFacts } = params; + const providerCatalogResponse = await resolveProviderAppCatalogResponse( + req, + params.providerAppCatalog, + ); + if (providerCatalogResponse) return providerCatalogResponse; const resolution = await resolveInventoryCommandDevice({ req, sessionName, @@ -332,6 +343,26 @@ async function handleAppsInventory(params: { return appsInventoryResponse(apps); } +async function resolveProviderAppCatalogResponse( + req: DaemonRequest, + providerAppCatalog: ProviderAppCatalog | undefined, +): Promise { + if (!providerAppCatalog) return undefined; + const leaseScope = resolveLeaseScope(req); + if (leaseScope.leaseId) return undefined; + const provider = leaseScope.leaseProvider; + const platform = req.flags?.platform; + if (!provider || (platform !== 'android' && platform !== 'ios')) return undefined; + const apps = await providerAppCatalog( + { + provider, + platform, + }, + getRequestSignal(req.meta?.requestId), + ); + return apps ? { ok: true, data: { apps: [...apps] } } : undefined; +} + async function inspectCapabilityFacts( device: DeviceInfo, inspectFacts: InspectDeviceRuntimeFacts | undefined, diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 3a092a80ff..abbef60b28 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -19,7 +19,7 @@ import { handleDoctorCommand } from './session-doctor.ts'; import { handlePrepareCommand } from './session-prepare.ts'; import type { DescriptorSessionRouteCommandName } from '../../core/command-descriptor/registry.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; +import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; import type { BindDeviceRuntime, BindExactDeviceRuntime, @@ -41,6 +41,7 @@ export type SessionCommandInput = { sessionStore: SessionStore; leaseRegistry?: LeaseRegistry; leaseLifecycleProvider?: LeaseLifecycleProvider; + providerAppCatalog?: ProviderAppCatalog; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; /** @@ -76,6 +77,7 @@ const handleSessionInventoryCommandGroup: SessionCommandHandler = async ({ sessionStore, inspectFacts, bindDevice, + providerAppCatalog, }) => await handleSessionInventoryCommands({ req, @@ -83,6 +85,7 @@ const handleSessionInventoryCommandGroup: SessionCommandHandler = async ({ sessionStore, inspectFacts, bindDevice, + providerAppCatalog, }); const handleSessionStateCommandGroup: SessionCommandHandler = async ({ @@ -301,6 +304,7 @@ export async function handleSessionCommands( sessionStore, leaseRegistry = new LeaseRegistry(), leaseLifecycleProvider, + providerAppCatalog, invoke, invokeReplayAction, androidAdbExecutor, @@ -330,6 +334,7 @@ export async function handleSessionCommands( sessionStore, leaseRegistry, leaseLifecycleProvider, + providerAppCatalog, invoke, invokeReplayAction, androidAdbExecutor, diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 2f5628cd10..86b87f5e20 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -5,6 +5,7 @@ import { isLeaseAdmissionExempt, isHumanControlMutation, isSessionlessPlainCloseAdmissionExempt, + isSessionlessLeaseAdmissionExempt, } from './daemon-command-registry.ts'; import { DEFAULT_PROXY_LEASE_TTL_MS, @@ -73,22 +74,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) + (isSessionlessPlainCloseAdmissionExempt(req) || isSessionlessLeaseAdmissionExempt(req)) ) { return undefined; } diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 4ef68b81d3..ea4f90de26 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; /** @@ -169,6 +170,7 @@ async function runSessionHandler( sessionStore: params.sessionStore, leaseRegistry: params.leaseRegistry, leaseLifecycleProvider: params.leaseLifecycleProvider, + providerAppCatalog: params.providerAppCatalog, invoke: params.invoke, invokeReplayAction: params.invokeReplayAction, androidAdbExecutor: params.providerScope.androidAdbExecutor, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 4228a4b80e..03644cde56 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -1,6 +1,6 @@ import { withResolveTargetDeviceCacheScope } from '../core/dispatch-resolve.ts'; import { withDeviceInventoryContext } from '../request/device-inventory-context.ts'; -import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; +import type { LeaseLifecycleProvider, ProviderAppCatalog } from '@agent-device/contracts/device'; import type { ComposedDeviceInventoryGateways } from '@agent-device/contracts/platform-module'; import type { DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; @@ -90,6 +90,7 @@ export type RequestRouterDeps = { providerRuntimeRequiredIds?: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; + providerAppCatalog?: ProviderAppCatalog; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; @@ -150,6 +151,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerRuntimeRequiredIds, leaseLifecycleProvider, cloudArtifactProvider, + providerAppCatalog, androidObservation = unavailableAndroidObservation, platformResourceCleanup = unavailablePlatformResourceCleanup, providerDeviceRuntimeScope, @@ -285,6 +287,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { providerRuntimeIds, providerRuntimeRequiredIds, cloudArtifactProvider, + providerAppCatalog, invoke: handleRequest, invokeReplayAction: allowReplayActions ? createReplayScopedActionInvoker(lockedScope, providerScope) diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 1215c34066..241a3cda53 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -321,6 +321,7 @@ export async function startDaemonRuntime( }, }); const cloudArtifactProvider = providerRuntimeProviders.cloudArtifactProvider; + const providerAppCatalog = providerRuntimeProviders.providerAppCatalog; const deviceInventoryGateways = createPlatformDeviceInventoryGateways( providerRuntimeProviders.deviceInventorySource, ); @@ -332,6 +333,7 @@ export async function startDaemonRuntime( leaseRegistry, leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, cloudArtifactProvider, + providerAppCatalog, deviceInventoryGateways, deviceRuntimeGateway, appLogAdmissionLedger, diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index dde9afa826..bb58f2acf3 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -3,6 +3,7 @@ import type { DeviceLease, LeaseLifecycleContext, LeaseLifecycleProvider, + ProviderAppCatalog, ProviderDeviceRuntime, ProviderExpiredLeaseRecovery, } from '@agent-device/contracts/device'; @@ -46,6 +47,7 @@ export type ProviderDeviceRuntimeRequestProviders = { leaseLifecycleProvider?: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifactProvider?: CloudArtifactProvider; + providerAppCatalog?: ProviderAppCatalog; deviceInventorySource?: ProviderDeviceInventorySource; appleRunnerProvider?: AppleRunnerProviderResolver; appleRunnerScreenRecordingTransport?: AppleRunnerScreenRecordingTransportResolver; @@ -108,6 +110,7 @@ export function createProviderDeviceRuntimeRequestProviders( .map((runtime) => runtime.provider), recoverExpiredLease: composeExpiredLeaseRecovery(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), + providerAppCatalog: composeProviderAppCatalog(runtimes), deviceInventorySource: composeDeviceInventorySource(runtimes), appleRunnerProvider: composeAppleRunnerProviderResolver(runtimes), appleRunnerScreenRecordingTransport: @@ -210,6 +213,20 @@ function composeCloudArtifactProvider( }; } +function composeProviderAppCatalog( + runtimes: ProviderDeviceRuntime[], +): ProviderAppCatalog | undefined { + if (!runtimes.some((runtime) => runtime.appCatalog !== undefined)) return undefined; + return async (query, signal) => { + for (const runtime of runtimes) { + if (!runtime.appCatalog || !runtimeMatchesProvider(runtime, query.provider)) continue; + const apps = await runtime.appCatalog(query, signal); + if (apps) return apps; + } + return undefined; + }; +} + function composeDeviceInventorySource( runtimes: ProviderDeviceRuntime[], ): ProviderDeviceInventorySource | undefined { From ebb988451d699d936b3d77d7162a7578c70c121c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 08:54:18 +0200 Subject: [PATCH 02/14] refactor: generalize deferred provider app selection --- .../application-lifecycle-interaction.test.ts | 4 ++-- .../src/application-lifecycle-interaction.ts | 16 ++++++++-------- packages/provider-limrun/src/app-log-runtime.ts | 4 ++-- packages/provider-limrun/src/lifecycle.ts | 4 ++-- packages/provider-limrun/src/runtime.ts | 4 ++-- src/cli/commands/connection-runtime.ts | 13 +++++++++---- src/cli/connection/provider-policy.test.ts | 11 +++++++++++ src/cli/connection/provider-policy.ts | 8 ++++++++ 8 files changed, 44 insertions(+), 20 deletions(-) create mode 100644 src/cli/connection/provider-policy.test.ts diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index 6627c4d139..4da6e55c65 100644 --- a/packages/contracts/src/application-lifecycle-interaction.test.ts +++ b/packages/contracts/src/application-lifecycle-interaction.test.ts @@ -91,7 +91,7 @@ test('direct lifecycle owners preserve the daemon runtime launch URL follow-up', expect(calls[1]?.options).toHaveProperty('launchArgs', undefined); }); -test('direct lifecycle owners resolve app aliases once before target dispatch', async () => { +test('direct lifecycle owners resolve provider app references before target dispatch', async () => { const open = vi.fn(async () => undefined); const binding = bindLocalApplicationLifecycleInteractor({ device: WEB_DEVICE, @@ -102,7 +102,7 @@ test('direct lifecycle owners resolve app aliases once before target dispatch', binding, owner: 'Provider', openTargetIdentity: 'bundle-id', - resolveAppAlias: (app) => (app === 'Example.app.zip' ? 'com.example.app' : app), + resolveAppReference: (app) => (app === 'Example.app.zip' ? 'com.example.app' : app), }); await expect( diff --git a/packages/contracts/src/application-lifecycle-interaction.ts b/packages/contracts/src/application-lifecycle-interaction.ts index 7b277bb778..bdca0620ae 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -262,7 +262,7 @@ export type DirectApplicationLifecycleParams = Readonly<{ openTargetIdentity: DirectOpenTargetIdentity; /** Owners whose native open does not replace a running application close it first. */ closeBeforeRelaunch?: boolean; - resolveAppAlias?(app: string): string; + resolveAppReference?(app: string): string; /** Port reverse is the one non-direct operation a provider owner may still implement. */ configureProviderPortReverse?: ApplicationLifecycleRuntimeOperations['configureProviderPortReverse']; }>; @@ -284,7 +284,7 @@ export function bindDirectApplicationLifecycle( }; return Object.freeze({ resolveOpenTarget: async (input) => - resolveDirectOpenTarget(params.openTargetIdentity, resolveOpenTargetAlias(params, input)), + resolveDirectOpenTarget(params.openTargetIdentity, resolveOpenTargetReference(params, input)), prepareApplicationOpen: async () => undefined, openApplication: async (input) => await openDirectApplication(params, input), applyRuntimeHints: unavailable, @@ -306,7 +306,7 @@ async function openDirectApplication( params: DirectApplicationLifecycleParams, input: OpenApplicationInput, ): Promise { - const resolvedInput = resolveOpenApplicationAliases(params, input); + const resolvedInput = resolveOpenApplicationReferences(params, input); const { binding } = params; const interactor = await binding.resolveInteractor( resolvedInput.execution, @@ -344,19 +344,19 @@ async function openDirectApplication( return { appBundleId: resolvedInput.appBundleId, timing: {} }; } -function resolveOpenTargetAlias( +function resolveOpenTargetReference( params: DirectApplicationLifecycleParams, input: OpenTargetResolutionInput, ): OpenTargetResolutionInput { - if (!input.target || !params.resolveAppAlias) return input; - return { ...input, target: params.resolveAppAlias(input.target) }; + if (!input.target || !params.resolveAppReference) return input; + return { ...input, target: params.resolveAppReference(input.target) }; } -function resolveOpenApplicationAliases( +function resolveOpenApplicationReferences( params: DirectApplicationLifecycleParams, input: OpenApplicationInput, ): OpenApplicationInput { - const resolve = params.resolveAppAlias; + const resolve = params.resolveAppReference; if (!resolve) return input; return { ...input, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index b1a2ed8afb..696dc7d6da 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -59,7 +59,7 @@ export type LimrunPlatformRuntimeOwnerOptions = Omit< runtimeInstance: string; ownsDevice(device: DeviceInfo): boolean; getInteractor(device: DeviceInfo, runner?: RunnerContext): Interactor | undefined; - resolveAppAlias?(device: DeviceInfo, app: string): string; + resolveAppReference?(device: DeviceInfo, app: string): string; openCurrent(device: DeviceInfo): Promise; hasLiveSession(device: DeviceInfo): boolean; reconnect( @@ -281,7 +281,7 @@ function bindLimrunAppLogs( device, signal, getInteractor: options.getInteractor, - resolveAppAlias: (app) => options.resolveAppAlias?.(device, app) ?? app, + resolveAppReference: (app) => options.resolveAppReference?.(device, app) ?? app, configurePortReverse: options.configurePortReverse, }), runtimeFacts.operations, diff --git a/packages/provider-limrun/src/lifecycle.ts b/packages/provider-limrun/src/lifecycle.ts index b8995d6c1c..5a4d0757ac 100644 --- a/packages/provider-limrun/src/lifecycle.ts +++ b/packages/provider-limrun/src/lifecycle.ts @@ -14,7 +14,7 @@ type LimrunLifecycleParams = Readonly<{ configurePortReverse( options: ProviderPortReverseOptions, ): Promise | undefined>; - resolveAppAlias?(app: string): string; + resolveAppReference?(app: string): string; }>; /** Limrun owns its live-session lifecycle, relaunch, and exact port-reverse mechanics. */ @@ -25,7 +25,7 @@ export function bindLimrunApplicationLifecycle( owner: 'Limrun', openTargetIdentity: 'bundle-id', closeBeforeRelaunch: true, - resolveAppAlias: params.resolveAppAlias, + resolveAppReference: params.resolveAppReference, configureProviderPortReverse: async (input) => await params.configurePortReverse(input), binding: bindProviderApplicationLifecycleInteractor({ device: params.device, diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 79f0ba36f3..341dedd879 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -331,7 +331,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { return session?.platform === parsed.platform ? session : undefined; } - resolveAppAlias(device: DeviceInfo, app: string): string { + resolveAppReference(device: DeviceInfo, app: string): string { const parsed = parseLimrunDeviceId(device.id); if (!parsed) return app; const alias = this.appAliases.get(parsed.leaseId); @@ -386,7 +386,7 @@ async function loadLimrunPlatformRuntime( ownsDevice: (device) => runtime.ownsDevice(device), hasLiveSession: (device) => runtime.hasLiveSession(device), getInteractor: (device, runner) => runtime.getInteractor(device, runner), - resolveAppAlias: (device, app) => runtime.resolveAppAlias(device, app), + resolveAppReference: (device, app) => runtime.resolveAppReference(device, app), openCurrent: async (device) => runtime.currentAppLogReader(device), reconnect: async (descriptor, signal) => await runtime.reconnectAppLogReader(descriptor, signal), diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 90518d2356..6fe31951b3 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -33,7 +33,10 @@ import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; -import { connectionProviderRequiresRemoteDaemon } from '../connection/provider-policy.ts'; +import { + connectionProviderRequiresRemoteDaemon, + connectionProviderSupportsDeferredAppSelection, +} from '../connection/provider-policy.ts'; import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; import { isCloudWebDriverProviderName } from '@agent-device/provider-webdriver'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; @@ -112,7 +115,7 @@ export async function materializeRemoteConnectionForCommand(options: { ); const nextFlags = { ...mergedFlags, session: state.session }; if ( - state.leaseProvider === 'limrun' && + connectionProviderSupportsDeferredAppSelection(state.leaseProvider) && command === PUBLIC_COMMANDS.open && typeof options.positionals?.[0] === 'string' ) { @@ -385,14 +388,16 @@ type ConnectionLeasePolicy = { function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { if (state.leaseProvider === 'proxy') return PROXY_CONNECTION_LEASE_POLICY; - if (state.leaseProvider === 'limrun') return LIMRUN_CONNECTION_LEASE_POLICY; + if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; + } if (isCloudWebDriverProviderName(state.leaseProvider)) { return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; } return DEFAULT_CONNECTION_LEASE_POLICY; } -const LIMRUN_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { +const DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY: ConnectionLeasePolicy = { shouldAllocate: (command) => command !== PUBLIC_COMMANDS.apps && !leaseDeferredCommands.has(command), ttlMs: () => undefined, diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts new file mode 100644 index 0000000000..e6be84e13d --- /dev/null +++ b/src/cli/connection/provider-policy.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { connectionProviderSupportsDeferredAppSelection } from './provider-policy.ts'; + +test('only providers declaring deferred app selection use app catalog before allocation', () => { + assert.equal(connectionProviderSupportsDeferredAppSelection('limrun'), true); + assert.equal(connectionProviderSupportsDeferredAppSelection('browserstack'), false); + assert.equal(connectionProviderSupportsDeferredAppSelection('aws-device-farm'), false); + assert.equal(connectionProviderSupportsDeferredAppSelection('proxy'), false); + assert.equal(connectionProviderSupportsDeferredAppSelection(undefined), false); +}); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 8bdcecc48d..f489be1fea 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -7,6 +7,8 @@ import { export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; +const DEFERRED_APP_SELECTION_PROVIDERS = new Set(['limrun']); + export function isConnectProviderName(value: string | undefined): value is ConnectProvider { return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); } @@ -31,6 +33,12 @@ export function connectionProviderRequiresRemoteDaemon(provider: string | undefi return !isDirectDeviceConnectProvider(provider); } +export function connectionProviderSupportsDeferredAppSelection( + provider: string | undefined, +): boolean { + return isDirectDeviceConnectProvider(provider) && DEFERRED_APP_SELECTION_PROVIDERS.has(provider); +} + export function connectionProviderLeaseKind( provider: string | undefined, ): 'proxy' | 'direct-device-provider' | 'remote-provider' { From ec755a389a72701cb10e8921aa2fe0c423a6448f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 10:48:39 +0200 Subject: [PATCH 03/14] refactor: fold limrun preinstall into allocation --- .../provider-limrun/src/app-preinstall.ts | 37 ------------------- packages/provider-limrun/src/runtime.ts | 10 +++-- .../provider-limrun/src/session-allocation.ts | 34 ++++++++++++++++- 3 files changed, 38 insertions(+), 43 deletions(-) delete mode 100644 packages/provider-limrun/src/app-preinstall.ts diff --git a/packages/provider-limrun/src/app-preinstall.ts b/packages/provider-limrun/src/app-preinstall.ts deleted file mode 100644 index 02475629ec..0000000000 --- a/packages/provider-limrun/src/app-preinstall.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type Limrun from '@limrun/api'; -import type { LeaseLifecycleContext } from '@agent-device/contracts/device'; -import { AppError } from '@agent-device/kernel/errors'; -import type { LimrunAndroidSession } from './android.ts'; -import { - resolveInstalledAppIdForAsset, - resolveLimrunAppAsset, - type LimrunAppAsset, -} from './app-catalog.ts'; -import { createLimrunDeviceSession } from './device-session.ts'; -import type { LimrunIosSession } from './ios.ts'; - -export async function resolveRequestedLimrunAppAsset( - limrun: Limrun, - platform: 'android' | 'ios', - context?: LeaseLifecycleContext, -): Promise { - const value = context?.flags?.providerApp; - const name = typeof value === 'string' ? value.trim() : ''; - if (!name) return undefined; - return await resolveLimrunAppAsset(limrun, platform, name, context?.signal); -} - -export async function resolvePreinstalledAppId( - session: LimrunAndroidSession | LimrunIosSession, - asset: LimrunAppAsset, -): Promise { - const deviceSession = createLimrunDeviceSession(session); - const apps = await deviceSession.listApps('user-installed'); - const matchedAppId = resolveInstalledAppIdForAsset(asset.name, apps); - if (matchedAppId) return matchedAppId; - throw new AppError( - 'COMMAND_FAILED', - `Limrun installed ${asset.name}, but its application identifier could not be resolved unambiguously.`, - { asset: asset.name, installedApps: apps.map((app) => app.id) }, - ); -} diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 341dedd879..580c8d0a9e 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -234,10 +234,12 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const existing = this.sessions.get(lease.leaseId); if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; - const { resolvePreinstalledAppId, resolveRequestedLimrunAppAsset } = - await import('./app-preinstall.ts'); - const { allocateLimrunAndroidSession, allocateLimrunIosSession } = - await import('./session-allocation.ts'); + const { + allocateLimrunAndroidSession, + allocateLimrunIosSession, + resolvePreinstalledAppId, + resolveRequestedLimrunAppAsset, + } = await import('./session-allocation.ts'); const requestedAsset = await resolveRequestedLimrunAppAsset(this.limrun, platform, context); const session = platform === 'ios' diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts index 5df042a1d6..2a77c83352 100644 --- a/packages/provider-limrun/src/session-allocation.ts +++ b/packages/provider-limrun/src/session-allocation.ts @@ -1,10 +1,15 @@ import type Limrun from '@limrun/api'; -import type { DeviceLease } from '@agent-device/contracts/device'; +import type { DeviceLease, LeaseLifecycleContext } from '@agent-device/contracts/device'; import { AppError } from '@agent-device/kernel/errors'; import { createLimrunAndroidSession, type LimrunAndroidSession } from './android.ts'; import { buildLimrunDevice } from './device.ts'; import { createLimrunIosSession, type LimrunIosSession } from './ios.ts'; -import type { LimrunAppAsset } from './app-catalog.ts'; +import { + resolveInstalledAppIdForAsset, + resolveLimrunAppAsset, + type LimrunAppAsset, +} from './app-catalog.ts'; +import { createLimrunDeviceSession } from './device-session.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; type LimrunInstance = { @@ -28,6 +33,31 @@ type SessionAllocationParams = Readonly<{ dependencies: LimrunRuntimeDependencies; }>; +export async function resolveRequestedLimrunAppAsset( + limrun: Limrun, + platform: 'android' | 'ios', + context?: LeaseLifecycleContext, +): Promise { + const value = context?.flags?.providerApp; + const name = typeof value === 'string' ? value.trim() : ''; + if (!name) return undefined; + return await resolveLimrunAppAsset(limrun, platform, name, context?.signal); +} + +export async function resolvePreinstalledAppId( + session: LimrunAndroidSession | LimrunIosSession, + asset: LimrunAppAsset, +): Promise { + const apps = await createLimrunDeviceSession(session).listApps('user-installed'); + const matchedAppId = resolveInstalledAppIdForAsset(asset.name, apps); + if (matchedAppId) return matchedAppId; + throw new AppError( + 'COMMAND_FAILED', + `Limrun installed ${asset.name}, but its application identifier could not be resolved unambiguously.`, + { asset: asset.name, installedApps: apps.map((app) => app.id) }, + ); +} + export async function allocateLimrunIosSession( params: SessionAllocationParams, ): Promise { From e4ef192d72be4195cc65d9eb3c0a1cc8e1ee9697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 11:52:22 +0200 Subject: [PATCH 04/14] refactor: keep app selection provider independent --- src/cli/commands/connection-presentation.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index 2d9ceba8d5..2b3e558172 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -1,6 +1,9 @@ import { fingerprint, type RemoteConnectionState } from '../../remote/remote-connection-state.ts'; import type { ConnectVerification } from '../connection/connect-provider-adapters.ts'; -import { connectionProviderLeaseKind } from '../connection/provider-policy.ts'; +import { + connectionProviderLeaseKind, + connectionProviderSupportsDeferredAppSelection, +} from '../connection/provider-policy.ts'; import { shellQuoteIfNeeded } from '@agent-device/host-kit/command'; export type ConnectReadiness = ConnectVerification & { @@ -40,7 +43,7 @@ export function buildLeasePreparationNotice( 'No live device session has been created. Run devices to inspect inventory without allocating, then open when ready.', }; } - if (state.leaseProvider === 'limrun') { + if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { return { status: 'deferred', nextSteps: buildConnectWorkflow(state, verification).nextSteps, @@ -241,7 +244,7 @@ function buildUnscopedConnectWorkflow( if (!verification && leaseKind === 'direct-device-provider') { return { nextSteps: defaultDirectProviderLifecycle() }; } - if (verification?.provider === 'limrun') { + if (connectionProviderSupportsDeferredAppSelection(verification?.provider)) { return { nextSteps: ['agent-device apps', 'agent-device open '], }; @@ -342,6 +345,8 @@ function appIdPlaceholder(platform: RemoteConnectionState['platform']): string { function missingAppLabel(state: RemoteConnectionState): string { if (state.leaseProvider === 'aws-device-farm') return 'not attached'; - if (state.leaseProvider === 'limrun') return 'not installed yet'; + if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + return 'not installed yet'; + } return 'not available'; } From ae9bc8a208bd7680e0b9c3d6a2e85b1cbfd7353d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 12:09:12 +0200 Subject: [PATCH 05/14] refactor: enforce provider-independent commands --- scripts/layering/check.ts | 3 + .../layering/command-provider-policy.test.ts | 32 +++++++++ scripts/layering/command-provider-policy.ts | 67 +++++++++++++++++++ src/cli/commands/connection-presentation.ts | 6 +- src/cli/commands/connection-runtime.ts | 5 +- src/cli/commands/react-devtools.ts | 3 +- src/cli/connection/provider-policy.test.ts | 17 ++++- src/cli/connection/provider-policy.ts | 12 ++++ 8 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 scripts/layering/command-provider-policy.test.ts create mode 100644 scripts/layering/command-provider-policy.ts diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 072f002946..951068585a 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -106,6 +106,7 @@ import { sourceExecutionCompatibilityViolations } from './source-execution-polic import { sessionResourceOwnershipViolations } from './session-resource-ownership.ts'; import { replayOwnershipViolations } from './replay-ownership.ts'; import { applicationLifecycleOwnershipViolations } from './application-lifecycle-policy.ts'; +import { commandProviderPolicyViolations } from './command-provider-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', @@ -540,6 +541,7 @@ export const LAYERING_RULE_IDS = [ 'record-runtime-ownership', 'session-resource-ownership', 'application-lifecycle-ownership', + 'command-provider-policy', 'contracts-implementation-authority', 'substrate-domain-shape', 'selector-pipeline-ownership', @@ -567,6 +569,7 @@ export const LAYERING_RULES: Readonly> = { 'session-resource-ownership': (context) => sessionResourceOwnershipViolations(context.sources), 'application-lifecycle-ownership': (context) => applicationLifecycleOwnershipViolations(context.sources), + 'command-provider-policy': (context) => commandProviderPolicyViolations(context.sources), 'contracts-implementation-authority': (context) => checkContractsImplementationAuthority(context.sources), 'substrate-domain-shape': (context) => diff --git a/scripts/layering/command-provider-policy.test.ts b/scripts/layering/command-provider-policy.test.ts new file mode 100644 index 0000000000..c1e76e5391 --- /dev/null +++ b/scripts/layering/command-provider-policy.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { commandProviderPolicyViolations } from './command-provider-policy.ts'; + +test('command implementations cannot branch on provider identity', () => { + const violations = commandProviderPolicyViolations( + new Map([ + ['src/cli/commands/open.ts', `if (state.leaseProvider === 'limrun') open();`], + ['src/commands/apps.ts', `if ('browserstack' !== verification['provider']) return;`], + [ + 'src/cli/commands/connect.ts', + `if (verification?.provider === 'aws-device-farm') reconnect();`, + ], + ['src/cli/commands/session.ts', `switch (state.leaseProvider) { case 'proxy': connect(); }`], + ['src/cli/connection/provider-policy.ts', `export const supported = provider === 'limrun';`], + [ + 'src/cli/commands/devices.ts', + `if (connectionProviderSupportsApps(state.leaseProvider)) list();`, + ], + ]), + ); + + assert.deepEqual( + violations.map(({ file, line }) => ({ file, line })), + [ + { file: 'src/cli/commands/open.ts', line: 1 }, + { file: 'src/commands/apps.ts', line: 1 }, + { file: 'src/cli/commands/connect.ts', line: 1 }, + { file: 'src/cli/commands/session.ts', line: 1 }, + ], + ); +}); diff --git a/scripts/layering/command-provider-policy.ts b/scripts/layering/command-provider-policy.ts new file mode 100644 index 0000000000..fbbc0959f7 --- /dev/null +++ b/scripts/layering/command-provider-policy.ts @@ -0,0 +1,67 @@ +import { parseSync } from 'oxc-parser'; +import { memberName, visitAst } from './layering-ast.ts'; +import type { LayeringViolation } from './model.ts'; + +export const COMMAND_PROVIDER_POLICY_RULE = 'R71 command-provider-policy'; + +const COMMAND_ROOTS = ['src/commands/', 'src/cli/commands/']; +const PROVIDER_FIELDS = new Set(['leaseProvider', 'provider']); +const EQUALITY_OPERATORS = new Set(['==', '===', '!=', '!==']); + +function isCommandFile(file: string): boolean { + return COMMAND_ROOTS.some((root) => file.startsWith(root)); +} + +function isStringLiteral(node: unknown): boolean { + if (node === null || typeof node !== 'object') return false; + const value = node as Record; + return value.type === 'Literal' && typeof value.value === 'string'; +} + +function isProviderField(node: unknown): boolean { + if (node === null || typeof node !== 'object') return false; + const value = node as Record; + if (value.type === 'ChainExpression') return isProviderField(value.expression); + return value.type === 'MemberExpression' && PROVIDER_FIELDS.has(memberName(value) ?? ''); +} + +function isProviderIdentityComparison(node: Record): boolean { + if (node.type !== 'BinaryExpression' || !EQUALITY_OPERATORS.has(String(node.operator))) { + return false; + } + return ( + (isProviderField(node.left) && isStringLiteral(node.right)) || + (isStringLiteral(node.left) && isProviderField(node.right)) + ); +} + +function isProviderIdentitySwitch(node: Record): boolean { + if (node.type !== 'SwitchStatement' || !isProviderField(node.discriminant)) return false; + const cases = Array.isArray(node.cases) ? node.cases : []; + return cases.some((entry) => { + if (entry === null || typeof entry !== 'object') return false; + return isStringLiteral((entry as Record).test); + }); +} + +export function commandProviderPolicyViolations( + sources: ReadonlyMap, +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const [file, source] of sources) { + if (!isCommandFile(file)) continue; + const program = parseSync(file, source).program as Record; + visitAst(program, (node) => { + if (!isProviderIdentityComparison(node) && !isProviderIdentitySwitch(node)) return; + const offset = typeof node.start === 'number' ? node.start : 0; + violations.push({ + rule: COMMAND_PROVIDER_POLICY_RULE, + file, + line: source.slice(0, offset).split('\n').length, + message: + 'command branches on provider identity; declare a semantic capability in src/cli/connection/provider-policy.ts', + }); + }); + } + return violations; +} diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index 2b3e558172..b4b770c340 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -2,6 +2,8 @@ import { fingerprint, type RemoteConnectionState } from '../../remote/remote-con import type { ConnectVerification } from '../connection/connect-provider-adapters.ts'; import { connectionProviderLeaseKind, + connectionProviderRequiresAppAttachment, + connectionProviderSupportsArtifacts, connectionProviderSupportsDeferredAppSelection, } from '../connection/provider-policy.ts'; import { shellQuoteIfNeeded } from '@agent-device/host-kit/command'; @@ -265,7 +267,7 @@ function requiresInstall(verification?: ConnectVerification): boolean { } function supportsProviderArtifacts(verification?: ConnectVerification): boolean { - return verification?.provider === 'browserstack' || verification?.provider === 'aws-device-farm'; + return connectionProviderSupportsArtifacts(verification?.provider); } function missingAttachedAppRecovery(verification?: ConnectVerification): string[] { @@ -344,7 +346,7 @@ function appIdPlaceholder(platform: RemoteConnectionState['platform']): string { } function missingAppLabel(state: RemoteConnectionState): string { - if (state.leaseProvider === 'aws-device-farm') return 'not attached'; + if (connectionProviderRequiresAppAttachment(state.leaseProvider)) return 'not attached'; if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { return 'not installed yet'; } diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 6fe31951b3..fb5535a20b 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -34,6 +34,7 @@ import type { CloudProviderSessionResult } from '@agent-device/contracts/observa import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; import { + connectionProviderLeaseKind, connectionProviderRequiresRemoteDaemon, connectionProviderSupportsDeferredAppSelection, } from '../connection/provider-policy.ts'; @@ -387,7 +388,9 @@ type ConnectionLeasePolicy = { }; function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { - if (state.leaseProvider === 'proxy') return PROXY_CONNECTION_LEASE_POLICY; + if (connectionProviderLeaseKind(state.leaseProvider) === 'proxy') { + return PROXY_CONNECTION_LEASE_POLICY; + } if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; } diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index c0ce8ba032..bfe31db566 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -6,6 +6,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { isRemoteBridgeBackend } from './remote-bridge.ts'; import type { CliFlags } from '@agent-device/contracts/command'; +import { connectionProviderSupportsDirectPortReverse } from '../connection/provider-policy.ts'; const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; @@ -168,7 +169,7 @@ function shouldConfigureDirectReverse( const { flags } = options; if (!flags) return false; return ( - flags.leaseProvider === 'limrun' && + connectionProviderSupportsDirectPortReverse(flags.leaseProvider) && flags.leaseBackend === 'android-instance' && flags.metroProxyBaseUrl === undefined && options.configureDirectPortReverse !== undefined diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index e6be84e13d..a7dd8c1a15 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -1,6 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { connectionProviderSupportsDeferredAppSelection } from './provider-policy.ts'; +import { + connectionProviderRequiresAppAttachment, + connectionProviderSupportsArtifacts, + connectionProviderSupportsDeferredAppSelection, + connectionProviderSupportsDirectPortReverse, +} from './provider-policy.ts'; test('only providers declaring deferred app selection use app catalog before allocation', () => { assert.equal(connectionProviderSupportsDeferredAppSelection('limrun'), true); @@ -9,3 +14,13 @@ test('only providers declaring deferred app selection use app catalog before all assert.equal(connectionProviderSupportsDeferredAppSelection('proxy'), false); assert.equal(connectionProviderSupportsDeferredAppSelection(undefined), false); }); + +test('provider capabilities stay declared outside command implementations', () => { + assert.equal(connectionProviderRequiresAppAttachment('aws-device-farm'), true); + assert.equal(connectionProviderRequiresAppAttachment('browserstack'), false); + assert.equal(connectionProviderSupportsArtifacts('aws-device-farm'), true); + assert.equal(connectionProviderSupportsArtifacts('browserstack'), true); + assert.equal(connectionProviderSupportsArtifacts('limrun'), false); + assert.equal(connectionProviderSupportsDirectPortReverse('limrun'), true); + assert.equal(connectionProviderSupportsDirectPortReverse('aws-device-farm'), false); +}); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index f489be1fea..a6c02dd0c3 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -39,6 +39,18 @@ export function connectionProviderSupportsDeferredAppSelection( return isDirectDeviceConnectProvider(provider) && DEFERRED_APP_SELECTION_PROVIDERS.has(provider); } +export function connectionProviderRequiresAppAttachment(provider: string | undefined): boolean { + return provider === CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm; +} + +export function connectionProviderSupportsArtifacts(provider: string | undefined): boolean { + return isCloudWebDriverProviderName(provider); +} + +export function connectionProviderSupportsDirectPortReverse(provider: string | undefined): boolean { + return provider === 'limrun'; +} + export function connectionProviderLeaseKind( provider: string | undefined, ): 'proxy' | 'direct-device-provider' | 'remote-provider' { From bd5f1ba7ee2038cd9857489ade89ac4074df4a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 13:05:56 +0200 Subject: [PATCH 06/14] refactor: restrict provider imports from commands --- oxlint.config.ts | 24 +++++++++++++++++++++ packages/provider-limrun/src/app-catalog.ts | 2 +- src/cli/commands/connection-runtime.ts | 4 ++-- src/cli/connection/provider-policy.test.ts | 4 ++++ src/cli/connection/provider-policy.ts | 4 ++++ 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/oxlint.config.ts b/oxlint.config.ts index 58843a7412..b1eb981ec0 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -71,6 +71,30 @@ export default defineConfig({ ], }, }, + { + files: ['src/commands/**/*.ts', 'src/cli/commands/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'node:child_process', + message: + 'Use process helpers from @agent-device/host-kit/command instead of importing node:child_process directly.', + }, + ], + patterns: [ + { + group: ['@agent-device/provider-*'], + message: + 'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.', + }, + ], + }, + ], + }, + }, { files: [ 'packages/host-kit/src/internal/exec.ts', diff --git a/packages/provider-limrun/src/app-catalog.ts b/packages/provider-limrun/src/app-catalog.ts index 2f6c097dbc..13028f8f1e 100644 --- a/packages/provider-limrun/src/app-catalog.ts +++ b/packages/provider-limrun/src/app-catalog.ts @@ -95,5 +95,5 @@ function appIdentityValues(app: InstalledAppIdentity): string[] { } function appIdentityKey(value: string): string { - return value.toLowerCase().replace(/[^a-z0-9]+/g, ''); + return value.toLowerCase().replaceAll(/[^a-z0-9]+/g, ''); } diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index fb5535a20b..b68331142f 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -37,9 +37,9 @@ import { connectionProviderLeaseKind, connectionProviderRequiresRemoteDaemon, connectionProviderSupportsDeferredAppSelection, + connectionProviderUsesCloudWebDriverLease, } from '../connection/provider-policy.ts'; import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; -import { isCloudWebDriverProviderName } from '@agent-device/provider-webdriver'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; const leaseDeferredCommands = new Set([ @@ -394,7 +394,7 @@ function connectionLeasePolicyForState(state: RemoteConnectionState): Connection if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; } - if (isCloudWebDriverProviderName(state.leaseProvider)) { + if (connectionProviderUsesCloudWebDriverLease(state.leaseProvider)) { return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; } return DEFAULT_CONNECTION_LEASE_POLICY; diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index a7dd8c1a15..e80a01a5e4 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -5,6 +5,7 @@ import { connectionProviderSupportsArtifacts, connectionProviderSupportsDeferredAppSelection, connectionProviderSupportsDirectPortReverse, + connectionProviderUsesCloudWebDriverLease, } from './provider-policy.ts'; test('only providers declaring deferred app selection use app catalog before allocation', () => { @@ -23,4 +24,7 @@ test('provider capabilities stay declared outside command implementations', () = assert.equal(connectionProviderSupportsArtifacts('limrun'), false); assert.equal(connectionProviderSupportsDirectPortReverse('limrun'), true); assert.equal(connectionProviderSupportsDirectPortReverse('aws-device-farm'), false); + assert.equal(connectionProviderUsesCloudWebDriverLease('browserstack'), true); + assert.equal(connectionProviderUsesCloudWebDriverLease('aws-device-farm'), true); + assert.equal(connectionProviderUsesCloudWebDriverLease('limrun'), false); }); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index a6c02dd0c3..bebe9ad8ee 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -51,6 +51,10 @@ export function connectionProviderSupportsDirectPortReverse(provider: string | u return provider === 'limrun'; } +export function connectionProviderUsesCloudWebDriverLease(provider: string | undefined): boolean { + return isCloudWebDriverProviderName(provider); +} + export function connectionProviderLeaseKind( provider: string | undefined, ): 'proxy' | 'direct-device-provider' | 'remote-provider' { From c3c5106982b54b89dec4fca7a4c62f48eaa24137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 13:19:31 +0200 Subject: [PATCH 07/14] refactor: keep provider guard lightweight --- scripts/layering/check.ts | 3 - .../layering/command-provider-policy.test.ts | 32 --------- scripts/layering/command-provider-policy.ts | 67 ------------------- 3 files changed, 102 deletions(-) delete mode 100644 scripts/layering/command-provider-policy.test.ts delete mode 100644 scripts/layering/command-provider-policy.ts diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 951068585a..072f002946 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -106,7 +106,6 @@ import { sourceExecutionCompatibilityViolations } from './source-execution-polic import { sessionResourceOwnershipViolations } from './session-resource-ownership.ts'; import { replayOwnershipViolations } from './replay-ownership.ts'; import { applicationLifecycleOwnershipViolations } from './application-lifecycle-policy.ts'; -import { commandProviderPolicyViolations } from './command-provider-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', @@ -541,7 +540,6 @@ export const LAYERING_RULE_IDS = [ 'record-runtime-ownership', 'session-resource-ownership', 'application-lifecycle-ownership', - 'command-provider-policy', 'contracts-implementation-authority', 'substrate-domain-shape', 'selector-pipeline-ownership', @@ -569,7 +567,6 @@ export const LAYERING_RULES: Readonly> = { 'session-resource-ownership': (context) => sessionResourceOwnershipViolations(context.sources), 'application-lifecycle-ownership': (context) => applicationLifecycleOwnershipViolations(context.sources), - 'command-provider-policy': (context) => commandProviderPolicyViolations(context.sources), 'contracts-implementation-authority': (context) => checkContractsImplementationAuthority(context.sources), 'substrate-domain-shape': (context) => diff --git a/scripts/layering/command-provider-policy.test.ts b/scripts/layering/command-provider-policy.test.ts deleted file mode 100644 index c1e76e5391..0000000000 --- a/scripts/layering/command-provider-policy.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'node:test'; -import { commandProviderPolicyViolations } from './command-provider-policy.ts'; - -test('command implementations cannot branch on provider identity', () => { - const violations = commandProviderPolicyViolations( - new Map([ - ['src/cli/commands/open.ts', `if (state.leaseProvider === 'limrun') open();`], - ['src/commands/apps.ts', `if ('browserstack' !== verification['provider']) return;`], - [ - 'src/cli/commands/connect.ts', - `if (verification?.provider === 'aws-device-farm') reconnect();`, - ], - ['src/cli/commands/session.ts', `switch (state.leaseProvider) { case 'proxy': connect(); }`], - ['src/cli/connection/provider-policy.ts', `export const supported = provider === 'limrun';`], - [ - 'src/cli/commands/devices.ts', - `if (connectionProviderSupportsApps(state.leaseProvider)) list();`, - ], - ]), - ); - - assert.deepEqual( - violations.map(({ file, line }) => ({ file, line })), - [ - { file: 'src/cli/commands/open.ts', line: 1 }, - { file: 'src/commands/apps.ts', line: 1 }, - { file: 'src/cli/commands/connect.ts', line: 1 }, - { file: 'src/cli/commands/session.ts', line: 1 }, - ], - ); -}); diff --git a/scripts/layering/command-provider-policy.ts b/scripts/layering/command-provider-policy.ts deleted file mode 100644 index fbbc0959f7..0000000000 --- a/scripts/layering/command-provider-policy.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { parseSync } from 'oxc-parser'; -import { memberName, visitAst } from './layering-ast.ts'; -import type { LayeringViolation } from './model.ts'; - -export const COMMAND_PROVIDER_POLICY_RULE = 'R71 command-provider-policy'; - -const COMMAND_ROOTS = ['src/commands/', 'src/cli/commands/']; -const PROVIDER_FIELDS = new Set(['leaseProvider', 'provider']); -const EQUALITY_OPERATORS = new Set(['==', '===', '!=', '!==']); - -function isCommandFile(file: string): boolean { - return COMMAND_ROOTS.some((root) => file.startsWith(root)); -} - -function isStringLiteral(node: unknown): boolean { - if (node === null || typeof node !== 'object') return false; - const value = node as Record; - return value.type === 'Literal' && typeof value.value === 'string'; -} - -function isProviderField(node: unknown): boolean { - if (node === null || typeof node !== 'object') return false; - const value = node as Record; - if (value.type === 'ChainExpression') return isProviderField(value.expression); - return value.type === 'MemberExpression' && PROVIDER_FIELDS.has(memberName(value) ?? ''); -} - -function isProviderIdentityComparison(node: Record): boolean { - if (node.type !== 'BinaryExpression' || !EQUALITY_OPERATORS.has(String(node.operator))) { - return false; - } - return ( - (isProviderField(node.left) && isStringLiteral(node.right)) || - (isStringLiteral(node.left) && isProviderField(node.right)) - ); -} - -function isProviderIdentitySwitch(node: Record): boolean { - if (node.type !== 'SwitchStatement' || !isProviderField(node.discriminant)) return false; - const cases = Array.isArray(node.cases) ? node.cases : []; - return cases.some((entry) => { - if (entry === null || typeof entry !== 'object') return false; - return isStringLiteral((entry as Record).test); - }); -} - -export function commandProviderPolicyViolations( - sources: ReadonlyMap, -): LayeringViolation[] { - const violations: LayeringViolation[] = []; - for (const [file, source] of sources) { - if (!isCommandFile(file)) continue; - const program = parseSync(file, source).program as Record; - visitAst(program, (node) => { - if (!isProviderIdentityComparison(node) && !isProviderIdentitySwitch(node)) return; - const offset = typeof node.start === 'number' ? node.start : 0; - violations.push({ - rule: COMMAND_PROVIDER_POLICY_RULE, - file, - line: source.slice(0, offset).split('\n').length, - message: - 'command branches on provider identity; declare a semantic capability in src/cli/connection/provider-policy.ts', - }); - }); - } - return violations; -} From 80968e1cabcaf6db4333ea4fad28c849e589312d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 14:22:59 +0200 Subject: [PATCH 08/14] fix: enforce provider-independent commands --- oxlint.config.ts | 26 +++++++++ src/cli/commands/connection-presentation.ts | 31 +++++----- src/cli/commands/connection-runtime.ts | 57 +++++++------------ src/cli/commands/connection.ts | 42 ++++++-------- src/cli/commands/react-devtools.ts | 4 +- .../connection/connect-provider-adapters.ts | 17 +++--- src/cli/connection/provider-policy.test.ts | 14 +++++ src/cli/connection/provider-policy.ts | 40 ++++++++++++- src/cli/connection/provider-session-result.ts | 7 +++ src/remote/remote-connection-state.ts | 30 +++++++++- 10 files changed, 180 insertions(+), 88 deletions(-) create mode 100644 src/cli/connection/provider-session-result.ts diff --git a/oxlint.config.ts b/oxlint.config.ts index b1eb981ec0..0f4a3f73a0 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -93,6 +93,32 @@ export default defineConfig({ ], }, ], + 'no-restricted-properties': [ + 'error', + { + property: 'leaseProvider', + message: + 'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.', + }, + ], + }, + }, + { + files: ['src/cli/commands/**/*.ts'], + rules: { + 'no-restricted-properties': [ + 'error', + { + property: 'leaseProvider', + message: + 'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.', + }, + { + property: 'provider', + message: + 'Connection commands must ask src/cli/connection/provider-policy.ts for provider capabilities.', + }, + ], }, }, { diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index b4b770c340..ae983054de 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -1,10 +1,12 @@ -import { fingerprint, type RemoteConnectionState } from '../../remote/remote-connection-state.ts'; +import { + fingerprint, + remoteConnectionProviderOutput, + type RemoteConnectionState, +} from '../../remote/remote-connection-state.ts'; import type { ConnectVerification } from '../connection/connect-provider-adapters.ts'; import { - connectionProviderLeaseKind, - connectionProviderRequiresAppAttachment, - connectionProviderSupportsArtifacts, - connectionProviderSupportsDeferredAppSelection, + connectionProviderCapabilitiesForLease, + connectionProviderCapabilitiesForVerification, } from '../connection/provider-policy.ts'; import { shellQuoteIfNeeded } from '@agent-device/host-kit/command'; @@ -36,7 +38,8 @@ export function buildLeasePreparationNotice( verification?: ConnectVerification, ): LeasePreparationNotice | undefined { if (state.leaseId) return undefined; - const leaseKind = connectionProviderLeaseKind(state.leaseProvider); + const capabilities = connectionProviderCapabilitiesForLease(state); + const leaseKind = capabilities.leaseKind; if (leaseKind === 'proxy') { return { status: 'deferred', @@ -45,7 +48,7 @@ export function buildLeasePreparationNotice( 'No live device session has been created. Run devices to inspect inventory without allocating, then open when ready.', }; } - if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + if (capabilities.supportsDeferredAppSelection) { return { status: 'deferred', nextSteps: buildConnectWorkflow(state, verification).nextSteps, @@ -145,7 +148,7 @@ export function serializeConnectionState(options: { leaseAllocated: Boolean(state.leaseId), leaseId: state.leaseId, leaseBackend: state.leaseBackend, - leaseProvider: state.leaseProvider, + ...remoteConnectionProviderOutput(state), platform: state.platform, target: state.target, remoteConfig: state.remoteConfigPath, @@ -234,7 +237,8 @@ function buildUnscopedConnectWorkflow( state: RemoteConnectionState, verification?: ConnectVerification, ): Pick { - const leaseKind = connectionProviderLeaseKind(state.leaseProvider); + const capabilities = connectionProviderCapabilitiesForLease(state); + const leaseKind = capabilities.leaseKind; if (leaseKind === 'proxy') { return { nextSteps: [ @@ -246,7 +250,7 @@ function buildUnscopedConnectWorkflow( if (!verification && leaseKind === 'direct-device-provider') { return { nextSteps: defaultDirectProviderLifecycle() }; } - if (connectionProviderSupportsDeferredAppSelection(verification?.provider)) { + if (connectionProviderCapabilitiesForVerification(verification).supportsDeferredAppSelection) { return { nextSteps: ['agent-device apps', 'agent-device open '], }; @@ -267,7 +271,7 @@ function requiresInstall(verification?: ConnectVerification): boolean { } function supportsProviderArtifacts(verification?: ConnectVerification): boolean { - return connectionProviderSupportsArtifacts(verification?.provider); + return connectionProviderCapabilitiesForVerification(verification).supportsArtifacts; } function missingAttachedAppRecovery(verification?: ConnectVerification): string[] { @@ -346,8 +350,9 @@ function appIdPlaceholder(platform: RemoteConnectionState['platform']): string { } function missingAppLabel(state: RemoteConnectionState): string { - if (connectionProviderRequiresAppAttachment(state.leaseProvider)) return 'not attached'; - if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + const capabilities = connectionProviderCapabilitiesForLease(state); + if (capabilities.requiresAppAttachment) return 'not attached'; + if (capabilities.supportsDeferredAppSelection) { return 'not installed yet'; } return 'not available'; diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index b68331142f..c643edde45 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -19,6 +19,7 @@ import { buildRemoteConnectionDaemonState, buildRemoteConnectionRequestMetadata, hashRemoteConfigFile, + mergeRemoteConnectionRequestMetadata, readRemoteConnectionState, writeRemoteConnectionState, type RemoteConnectionState, @@ -33,12 +34,7 @@ import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; -import { - connectionProviderLeaseKind, - connectionProviderRequiresRemoteDaemon, - connectionProviderSupportsDeferredAppSelection, - connectionProviderUsesCloudWebDriverLease, -} from '../connection/provider-policy.ts'; +import { connectionProviderCapabilitiesForLease } from '../connection/provider-policy.ts'; import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; @@ -116,7 +112,7 @@ export async function materializeRemoteConnectionForCommand(options: { ); const nextFlags = { ...mergedFlags, session: state.session }; if ( - connectionProviderSupportsDeferredAppSelection(state.leaseProvider) && + connectionProviderCapabilitiesForLease(state).supportsDeferredAppSelection && command === PUBLIC_COMMANDS.open && typeof options.positionals?.[0] === 'string' ) { @@ -362,13 +358,12 @@ function buildMaterializedLeaseState( leaseBackend: LeaseBackend, flags: CliFlags, ): RemoteConnectionState { + const connection = mergeRemoteConnectionRequestMetadata(lease, state); return { ...state, leaseId: lease.leaseId, leaseBackend, - leaseProvider: lease.leaseProvider ?? state.leaseProvider, - clientId: lease.clientId ?? state.clientId, - deviceKey: lease.deviceKey ?? state.deviceKey, + ...connection, platform: state.platform ?? flags.platform, target: state.target ?? flags.target, updatedAt: new Date().toISOString(), @@ -388,13 +383,14 @@ type ConnectionLeasePolicy = { }; function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { - if (connectionProviderLeaseKind(state.leaseProvider) === 'proxy') { + const capabilities = connectionProviderCapabilitiesForLease(state); + if (capabilities.leaseKind === 'proxy') { return PROXY_CONNECTION_LEASE_POLICY; } - if (connectionProviderSupportsDeferredAppSelection(state.leaseProvider)) { + if (capabilities.supportsDeferredAppSelection) { return DEFERRED_APP_SELECTION_CONNECTION_LEASE_POLICY; } - if (connectionProviderUsesCloudWebDriverLease(state.leaseProvider)) { + if (capabilities.usesCloudWebDriverLease) { return CLOUD_WEBDRIVER_CONNECTION_LEASE_POLICY; } return DEFAULT_CONNECTION_LEASE_POLICY; @@ -525,9 +521,7 @@ export async function releaseRemoteConnectionLease( daemonAuthToken, daemonTransport: state.daemon?.transport, daemonServerMode: state.daemon?.serverMode, - leaseProvider: state.leaseProvider, - clientId: state.clientId, - deviceKey: state.deviceKey, + ...buildRemoteConnectionRequestMetadata(state), }); return result; } @@ -676,14 +670,13 @@ async function releaseAcquiredLeaseOnWriteFailure( ): Promise { if (!lease) return; try { + const connection = mergeRemoteConnectionRequestMetadata(state, lease); await client.leases.release({ tenant: state.tenant, runId: state.runId, leaseId: lease.leaseId, leaseBackend: state.leaseBackend ?? lease.backend, - leaseProvider: state.leaseProvider ?? lease.leaseProvider, - clientId: state.clientId ?? lease.clientId, - deviceKey: state.deviceKey ?? lease.deviceKey, + ...connection, }); } catch { // Preserve the state-write failure; cleanup is best-effort. @@ -780,7 +773,10 @@ function createRemoteConnectionStateFromFlags( 'remote command requires runId in remote config or via --run-id .', ); } - if (!flags.daemonBaseUrl && connectionProviderRequiresRemoteDaemon(profile.leaseProvider)) { + if ( + !flags.daemonBaseUrl && + connectionProviderCapabilitiesForLease(profile).requiresRemoteDaemon + ) { throw new AppError( 'INVALID_ARGS', 'remote command requires daemonBaseUrl in remote config, config, env, or --daemon-base-url.', @@ -797,9 +793,7 @@ function createRemoteConnectionStateFromFlags( runId: flags.runId, leaseId: flags.leaseId, leaseBackend: flags.leaseBackend ?? resolveRequestedLeaseBackend(flags), - leaseProvider: profile.leaseProvider, - clientId: profile.clientId, - deviceKey: profile.deviceKey, + ...profile, platform: flags.platform, target: flags.target, connectedAt: now, @@ -814,14 +808,13 @@ async function allocateOrReuseLease( policy: ConnectionLeasePolicy, flags: CliFlags, ): Promise<{ lease: Lease; acquired: boolean }> { + const connection = buildRemoteConnectionRequestMetadata(state); if (state.leaseId && state.leaseBackend === leaseBackend) { const existing = await heartbeatOrAllocateLease(client, state.leaseId, { tenant: state.tenant, runId: state.runId, leaseBackend, - leaseProvider: state.leaseProvider, - clientId: state.clientId, - deviceKey: state.deviceKey, + ...connection, ttlMs: policy.ttlMs(state), }); if (existing) return { lease: existing, acquired: false }; @@ -830,9 +823,7 @@ async function allocateOrReuseLease( tenant: state.tenant, runId: state.runId, leaseBackend, - leaseProvider: state.leaseProvider, - clientId: state.clientId, - deviceKey: state.deviceKey, + ...connection, ttlMs: policy.ttlMs(state), platform: state.platform ?? flags.platform, target: state.target ?? flags.target, @@ -981,14 +972,8 @@ async function heartbeatOrAllocateLease( ): Promise { try { return await client.leases.heartbeat({ - tenant: scope.tenant, - runId: scope.runId, + ...scope, leaseId, - leaseBackend: scope.leaseBackend, - leaseProvider: scope.leaseProvider, - clientId: scope.clientId, - deviceKey: scope.deviceKey, - ttlMs: scope.ttlMs, }); } catch (error) { if (isInactiveLeaseError(error)) return undefined; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 2efdff6ca2..67c275043c 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -6,7 +6,10 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; import { readActiveConnectionState, + buildRemoteConnectionRequestMetadata, + mergeRemoteConnectionRequestMetadata, readRemoteConnectionState, + remoteConnectionLeaseIdentityMatches, removeRemoteConnectionState, writeRemoteConnectionState, type RemoteConnectionState, @@ -15,14 +18,15 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { connectProviderNamesForError, - connectionProviderRequiresRemoteDaemon, + connectionProviderCapabilitiesForLease, isConnectProviderName, type ConnectProvider, } from '../connection/provider-policy.ts'; import { resolveConnectProviderProfile, - verifyConnectProvider, + verifyResolvedConnectProvider, } from '../connection/connect-provider-adapters.ts'; +import { providerSessionResult } from '../connection/provider-session-result.ts'; import { hasDeferredMetroConfig, releaseRemoteConnectionLease, @@ -69,11 +73,7 @@ export const connectCommand: ClientCommandHandler = async ({ positionals, flags, connection: connectionMetadata, daemon: context.daemon, }); - const verification = await verifyConnectProvider({ - provider: resolved.provider, - flags: connectFlags, - env: process.env, - }); + const verification = await verifyResolvedConnectProvider(resolved); const state = buildConnectedState({ flags: connectFlags, scope, @@ -126,7 +126,7 @@ function readRequiredConnectScope( } if ( !flags.daemonBaseUrl && - connectionProviderRequiresRemoteDaemon(connectionMetadata?.leaseProvider) + connectionProviderCapabilitiesForLease(connectionMetadata ?? {}).requiresRemoteDaemon ) { throw new AppError( 'INVALID_ARGS', @@ -185,12 +185,12 @@ function buildConnectionLeaseBinding( RemoteConnectionState, 'clientId' | 'deviceKey' | 'leaseBackend' | 'leaseId' | 'leaseProvider' > { + const connection = mergeRemoteConnectionRequestMetadata(connectionMetadata ?? {}, previous ?? {}); return { leaseId: previous?.leaseId, leaseBackend: previous?.leaseBackend ?? resolveRequestedLeaseBackend(flags), - leaseProvider: connectionMetadata?.leaseProvider ?? previous?.leaseProvider, - clientId: connectionMetadata?.clientId ?? previous?.clientId, - deviceKey: previous?.deviceKey ?? connectionMetadata?.deviceKey, + ...connection, + deviceKey: previous?.deviceKey ?? connection.deviceKey, }; } @@ -241,12 +241,7 @@ function readRemoteConfigConnectionMetadata( cwd: process.cwd(), env: process.env, }).profile; - const metadata = { - leaseProvider: profile.leaseProvider, - clientId: profile.clientId, - deviceKey: profile.deviceKey, - }; - return Object.values(metadata).some((value) => value !== undefined) ? metadata : undefined; + return buildRemoteConnectionRequestMetadata(profile); } export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { @@ -260,9 +255,9 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) let providerData: CloudProviderSessionResult | undefined; if (state.leaseId || state.runtime || state.metro) { try { - providerData = ( - await client.sessions.close({ session: connectedSession, shutdown: flags.shutdown }) - ).provider; + providerData = providerSessionResult( + await client.sessions.close({ session: connectedSession, shutdown: flags.shutdown }), + ); } catch { // Disconnect is idempotent; the session may already be closed. } @@ -274,7 +269,7 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) try { const release = await releaseRemoteConnectionLease(client, state, flags.daemonAuthToken); released = release.released; - providerData ??= release.provider; + providerData ??= providerSessionResult(release); } catch { // Bridges may release on close or be unreachable; local state still needs cleanup. } @@ -430,13 +425,12 @@ function optionalConnectionFieldsMatch( state: RemoteConnectionState, options: Parameters[1], ): boolean { - return [ + const fieldsMatch = [ [state.leaseBackend, options.desiredLeaseBackend], [state.platform, options.flags.platform], [state.target, options.flags.target], - [state.leaseProvider, options.connection?.leaseProvider], - [state.clientId, options.connection?.clientId], ].every(([left, right]) => right === undefined || left === right); + return fieldsMatch && remoteConnectionLeaseIdentityMatches(state, options.connection); } function isSameDaemonState( diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index bfe31db566..68c1dbcc48 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -6,7 +6,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { isRemoteBridgeBackend } from './remote-bridge.ts'; import type { CliFlags } from '@agent-device/contracts/command'; -import { connectionProviderSupportsDirectPortReverse } from '../connection/provider-policy.ts'; +import { connectionProviderCapabilitiesForLease } from '../connection/provider-policy.ts'; const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; @@ -169,7 +169,7 @@ function shouldConfigureDirectReverse( const { flags } = options; if (!flags) return false; return ( - connectionProviderSupportsDirectPortReverse(flags.leaseProvider) && + connectionProviderCapabilitiesForLease(flags).supportsDirectPortReverse && flags.leaseBackend === 'android-instance' && flags.metroProxyBaseUrl === undefined && options.configureDirectPortReverse !== undefined diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 572fbf5b63..27d9c5c564 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -112,13 +112,10 @@ export async function resolveConnectProviderProfile(options: { return { ...profile, provider }; } -export async function verifyConnectProvider(options: { - provider?: ConnectProvider; - flags: CliFlags; - env?: EnvMap; -}): Promise { - const env = options.env ?? process.env; - if (!options.provider) { +export async function verifyResolvedConnectProvider( + resolved: ResolvedConnectProfile, +): Promise { + if (!resolved.provider) { return { service: 'remote provider', status: 'configured', @@ -126,9 +123,9 @@ export async function verifyConnectProvider(options: { 'Remote connection profile loaded. Access is checked by the first remote command.', }; } - return await CONNECT_PROVIDER_ADAPTERS[options.provider].verify({ - flags: options.flags, - env, + return await CONNECT_PROVIDER_ADAPTERS[resolved.provider].verify({ + flags: resolved.flags, + env: process.env, }); } diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index e80a01a5e4..738f30fc2d 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { + connectionProviderCapabilitiesForLease, + connectionProviderCapabilitiesForVerification, connectionProviderRequiresAppAttachment, connectionProviderSupportsArtifacts, connectionProviderSupportsDeferredAppSelection, @@ -28,3 +30,15 @@ test('provider capabilities stay declared outside command implementations', () = assert.equal(connectionProviderUsesCloudWebDriverLease('aws-device-farm'), true); assert.equal(connectionProviderUsesCloudWebDriverLease('limrun'), false); }); + +test('provider carriers resolve to semantic capabilities before commands inspect them', () => { + assert.equal( + connectionProviderCapabilitiesForLease({ leaseProvider: 'limrun' }) + .supportsDeferredAppSelection, + true, + ); + assert.equal( + connectionProviderCapabilitiesForVerification({ provider: 'browserstack' }).supportsArtifacts, + true, + ); +}); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index bebe9ad8ee..bc57074c49 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -7,6 +7,16 @@ import { export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; +type ConnectionProviderCapabilities = { + leaseKind: 'proxy' | 'direct-device-provider' | 'remote-provider'; + requiresAppAttachment: boolean; + requiresRemoteDaemon: boolean; + supportsArtifacts: boolean; + supportsDeferredAppSelection: boolean; + supportsDirectPortReverse: boolean; + usesCloudWebDriverLease: boolean; +}; + const DEFERRED_APP_SELECTION_PROVIDERS = new Set(['limrun']); export function isConnectProviderName(value: string | undefined): value is ConnectProvider { @@ -29,7 +39,7 @@ export function connectProviderNamesForError(): string { ].join(', '); } -export function connectionProviderRequiresRemoteDaemon(provider: string | undefined): boolean { +function connectionProviderRequiresRemoteDaemon(provider: string | undefined): boolean { return !isDirectDeviceConnectProvider(provider); } @@ -55,10 +65,36 @@ export function connectionProviderUsesCloudWebDriverLease(provider: string | und return isCloudWebDriverProviderName(provider); } -export function connectionProviderLeaseKind( +function connectionProviderLeaseKind( provider: string | undefined, ): 'proxy' | 'direct-device-provider' | 'remote-provider' { if (provider === 'proxy') return 'proxy'; if (isDirectDeviceConnectProvider(provider)) return 'direct-device-provider'; return 'remote-provider'; } + +export function connectionProviderCapabilitiesForLease(source: { + leaseProvider?: string; +}): ConnectionProviderCapabilities { + return connectionProviderCapabilities(source.leaseProvider); +} + +export function connectionProviderCapabilitiesForVerification( + verification: { provider?: string } | undefined, +): ConnectionProviderCapabilities { + return connectionProviderCapabilities(verification?.provider); +} + +function connectionProviderCapabilities( + provider: string | undefined, +): ConnectionProviderCapabilities { + return { + leaseKind: connectionProviderLeaseKind(provider), + requiresAppAttachment: connectionProviderRequiresAppAttachment(provider), + requiresRemoteDaemon: connectionProviderRequiresRemoteDaemon(provider), + supportsArtifacts: connectionProviderSupportsArtifacts(provider), + supportsDeferredAppSelection: connectionProviderSupportsDeferredAppSelection(provider), + supportsDirectPortReverse: connectionProviderSupportsDirectPortReverse(provider), + usesCloudWebDriverLease: connectionProviderUsesCloudWebDriverLease(provider), + }; +} diff --git a/src/cli/connection/provider-session-result.ts b/src/cli/connection/provider-session-result.ts new file mode 100644 index 0000000000..a4df9ac954 --- /dev/null +++ b/src/cli/connection/provider-session-result.ts @@ -0,0 +1,7 @@ +import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; + +export function providerSessionResult(response: { + provider?: CloudProviderSessionResult; +}): CloudProviderSessionResult | undefined { + return response.provider; +} diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index 1b49adbdbb..f39e4a78ef 100644 --- a/src/remote/remote-connection-state.ts +++ b/src/remote/remote-connection-state.ts @@ -170,11 +170,39 @@ export function resolveRemoteConnectionDefaults(options: { } export function buildRemoteConnectionRequestMetadata( - state: RemoteConnectionState, + state: RemoteConnectionRequestMetadata, ): RemoteConnectionRequestMetadata | undefined { return leaseScopeToConnectionMetadata(leaseScopeFromOptions(state)); } +export function mergeRemoteConnectionRequestMetadata( + primary: RemoteConnectionRequestMetadata, + fallback: RemoteConnectionRequestMetadata, +): RemoteConnectionRequestMetadata { + return { + leaseProvider: primary.leaseProvider ?? fallback.leaseProvider, + clientId: primary.clientId ?? fallback.clientId, + deviceKey: primary.deviceKey ?? fallback.deviceKey, + }; +} + +export function remoteConnectionProviderOutput( + state: RemoteConnectionState, +): Pick { + return { leaseProvider: state.leaseProvider }; +} + +export function remoteConnectionLeaseIdentityMatches( + state: RemoteConnectionState, + metadata: RemoteConnectionRequestMetadata | undefined, +): boolean { + if (!metadata) return true; + return ( + (metadata.leaseProvider === undefined || state.leaseProvider === metadata.leaseProvider) && + (metadata.clientId === undefined || state.clientId === metadata.clientId) + ); +} + export function hashRemoteConfigFile(configPath: string): string { try { return crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); From a5339b64166231e756e6a4cf3d7126cceadc61d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 15:47:08 +0200 Subject: [PATCH 09/14] fix: enforce provider catalog lease admission --- src/__tests__/provider-device-runtime.test.ts | 2 + .../__tests__/parity.test.ts | 16 +++- .../daemon-command-descriptor.ts | 9 ++- src/core/command-descriptor/registry.ts | 6 +- .../__tests__/daemon-command-registry.test.ts | 22 ++++-- .../__tests__/request-admission.test.ts | 38 ++++++++++ .../request-router-apps-admission.test.ts | 76 +++++++++++++++++++ src/daemon/daemon-command-registry.ts | 8 +- src/daemon/lease-lifecycle.ts | 5 +- src/daemon/request-admission.ts | 6 +- src/daemon/request-execution-scope.ts | 2 + src/daemon/request-router.ts | 4 + src/daemon/server/daemon-runtime.ts | 1 + src/provider-device-runtime.ts | 4 + 14 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 src/daemon/__tests__/request-router-apps-admission.test.ts diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 327ec6b5a7..625fd8e45e 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -28,6 +28,7 @@ test('provider device runtime registry delegates lifecycle, inventory, and inter }); await requestProviders.recoverExpiredLease?.(world.lease); assert.deepEqual(requestProviders.recoverableProviderIds, ['hit']); + assert.deepEqual(requestProviders.providerAppCatalogIds, ['hit']); assert.deepEqual(world.recoveredLeases, [world.lease]); assert.deepEqual( await requestProviders.deviceInventorySource?.discover( @@ -142,6 +143,7 @@ function makeProviderRuntimeWorld() { interactor, portReverseResult: { provider: 'hit' }, }); + hitRuntime.appCatalog = async () => []; hitRuntime.recoverExpiredLease = async (expiredLease) => { recoveredLeases.push(expiredLease); }; diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 71735fa54b..c9ddd6a330 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -30,6 +30,7 @@ import { const DAEMON_FUNCTION_TRAITS = [ 'allowSessionlessDefaultDevice', 'skipSessionlessProviderDevice', + 'sessionlessLeaseAdmissionExempt', ] as const; // Public commands that intentionally have no daemon route — they live only in the @@ -104,12 +105,21 @@ test('derived daemon descriptors preserve closure traits by presence and behavio const live = liveByCommand.get(derived.command); assert.ok(live, `${derived.command} present in hand table`); for (const trait of DAEMON_FUNCTION_TRAITS) { - const derivedFn = derived[trait] as ((req: DaemonRequest) => boolean) | undefined; - const liveFn = live[trait] as ((req: DaemonRequest) => boolean) | undefined; + const derivedFn = derived[trait] as + | ((req: DaemonRequest, context: { providerAppCatalogIds: readonly string[] }) => boolean) + | undefined; + const liveFn = live[trait] as + | ((req: DaemonRequest, context: { providerAppCatalogIds: readonly string[] }) => boolean) + | undefined; assert.equal(typeof derivedFn, typeof liveFn, `${derived.command} ${trait} presence`); if (typeof liveFn === 'function' && typeof derivedFn === 'function') { for (const request of sampleRequests(derived.command)) { - assert.equal(derivedFn(request), liveFn(request), `${derived.command} ${trait} behavior`); + const context = { providerAppCatalogIds: ['limrun'] }; + assert.equal( + derivedFn(request, context), + liveFn(request, context), + `${derived.command} ${trait} behavior`, + ); } } } diff --git a/src/core/command-descriptor/daemon-command-descriptor.ts b/src/core/command-descriptor/daemon-command-descriptor.ts index 63649cdf94..6e2666f54c 100644 --- a/src/core/command-descriptor/daemon-command-descriptor.ts +++ b/src/core/command-descriptor/daemon-command-descriptor.ts @@ -22,6 +22,10 @@ export type DaemonRefFrameEffect = | RefFrameEffect | ((req: TRequest) => RefFrameEffect); +export type SessionlessLeaseAdmissionContext = Readonly<{ + providerAppCatalogIds: readonly string[]; +}>; + /** * Daemon route + request-policy traits for one command. Generic over the request * the closure traits read so core can declare the shape in terms of @@ -51,5 +55,8 @@ export type DaemonCommandDescriptor = { preferExplicitDeviceOverExistingSession?: boolean; allowSessionlessDefaultDevice?: (req: TRequest) => boolean; skipSessionlessProviderDevice?: (req: TRequest) => boolean; - sessionlessLeaseAdmissionExempt?: (req: TRequest) => boolean; + sessionlessLeaseAdmissionExempt?: ( + req: TRequest, + context: SessionlessLeaseAdmissionContext, + ) => boolean; }; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 5835d0c082..51c580b0e0 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -12,6 +12,7 @@ import { } from './timeout-policy.ts'; import { resolvePostActionObservationSupport } from './post-action-observation.ts'; import type { PostActionObservationSupport } from './post-action-observation.ts'; +import type { DaemonCommandDescriptor } from './daemon-command-descriptor.ts'; import { deployAppUse, readyMaterializeAndDeployAppUse, @@ -179,9 +180,12 @@ const isShardedTestRequest = (req: DispatchedCommand): boolean => const isPlainCloseRequest = (req: DispatchedCommand): boolean => (req.positionals?.length ?? 0) === 0; -const isDeferredProviderAppCatalogRequest = (req: DispatchedCommand): boolean => +const isDeferredProviderAppCatalogRequest: NonNullable< + DaemonCommandDescriptor['sessionlessLeaseAdmissionExempt'] +> = (req, context) => req.flags?.leaseId === undefined && typeof req.flags?.leaseProvider === 'string' && + context.providerAppCatalogIds.includes(req.flags.leaseProvider) && (req.flags?.platform === 'android' || req.flags?.platform === 'ios'); // ADR 0014 request-sensitive ref-frame resolvers. The action is the leading diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index 5113151443..d61d62a6a9 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -84,17 +84,23 @@ test('daemon command registry preserves request admission traits', () => { assert.equal(isLeaseAdmissionExempt(PUBLIC_COMMANDS.open), false); assert.equal(shouldLockSessionExecution(PUBLIC_COMMANDS.open), true); assert.equal( - isSessionlessLeaseAdmissionExempt({ - ...makeRequest(PUBLIC_COMMANDS.apps), - flags: { platform: 'android', leaseProvider: 'limrun' }, - }), + isSessionlessLeaseAdmissionExempt( + { + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun' }, + }, + { providerAppCatalogIds: ['limrun'] }, + ), true, ); assert.equal( - isSessionlessLeaseAdmissionExempt({ - ...makeRequest(PUBLIC_COMMANDS.apps), - flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, - }), + isSessionlessLeaseAdmissionExempt( + { + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, + }, + { providerAppCatalogIds: ['limrun'] }, + ), false, ); }); diff --git a/src/daemon/__tests__/request-admission.test.ts b/src/daemon/__tests__/request-admission.test.ts index 9cbe7c1b5c..48d43d2c17 100644 --- a/src/daemon/__tests__/request-admission.test.ts +++ b/src/daemon/__tests__/request-admission.test.ts @@ -120,6 +120,44 @@ test('non-close commands on a tenant-isolated session still require a lease id', ); }); +test.each(['bogus', 'proxy', 'browserstack'])( + 'sessionless apps for non-catalog provider %s still requires a tenant lease', + (leaseProvider) => { + const registry = new LeaseRegistry(); + + assert.throws( + () => + assertRequestLeaseAdmission( + makeRequest({ + command: 'apps', + flags: { platform: 'ios', leaseProvider }, + meta: { tenantId: 'tenant-a', runId: 'run-1', sessionIsolation: 'tenant' }, + }), + registry, + undefined, + ), + /tenant isolation requires lease id/, + ); + }, +); + +test('sessionless apps admits a provider declared by the runtime app catalog', () => { + const registry = new LeaseRegistry(); + + const result = assertRequestLeaseAdmission( + makeRequest({ + command: 'apps', + flags: { platform: 'ios', leaseProvider: 'limrun' }, + meta: { tenantId: 'tenant-a', runId: 'run-1', sessionIsolation: 'tenant' }, + }), + registry, + undefined, + { providerAppCatalogIds: ['limrun'] }, + ); + + assert.equal(result, undefined); +}); + test('close still admits and heartbeats a real active lease', () => { let now = 1_000; const registry = new LeaseRegistry({ now: () => now }); diff --git a/src/daemon/__tests__/request-router-apps-admission.test.ts b/src/daemon/__tests__/request-router-apps-admission.test.ts new file mode 100644 index 0000000000..06eb486200 --- /dev/null +++ b/src/daemon/__tests__/request-router-apps-admission.test.ts @@ -0,0 +1,76 @@ +import { expect, test, vi } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import type { DeviceRuntimeGateway } from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import type { DaemonRequest } from '../types.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +function createAppsAdmissionHarness(apps: readonly string[] | undefined) { + const providerAppCatalog = vi.fn(async () => apps); + const inspectFacts = vi.fn(async () => { + throw new Error('apps catalog must not inspect device facts'); + }); + const bind = vi.fn(async () => { + throw new Error('apps catalog must not bind a device'); + }); + const handler = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'daemon.log'), + token: 'test-token', + sessionStore: makeSessionStore('agent-device-apps-admission-'), + leaseRegistry: new LeaseRegistry(), + deviceInventoryGateways: createTestDeviceInventoryGateways(), + deviceRuntimeGateway: { + inspectFacts, + bind, + shutdown: async () => {}, + } satisfies DeviceRuntimeGateway, + providerAppCatalog, + providerAppCatalogIds: ['limrun'], + trackDownloadableArtifact: () => 'artifact-id', + }); + return { handler, providerAppCatalog, inspectFacts, bind }; +} + +function appsRequest(leaseProvider: string): DaemonRequest { + return { + token: 'test-token', + session: 'default', + command: 'apps', + positionals: [], + flags: { platform: 'ios', leaseProvider }, + meta: { tenantId: 'tenant-a', runId: 'run-1', sessionIsolation: 'tenant' }, + }; +} + +test.each(['bogus', 'proxy', 'browserstack'])( + 'tenant apps rejects non-catalog provider %s before provider or device access', + async (leaseProvider) => { + const { handler, providerAppCatalog, inspectFacts, bind } = + createAppsAdmissionHarness(undefined); + const response = await handler(appsRequest(leaseProvider)); + + expect(response.ok).toBe(false); + expect(response.ok === false && response.error.message).toMatch( + /tenant isolation requires lease id/, + ); + expect(providerAppCatalog).not.toHaveBeenCalled(); + expect(inspectFacts).not.toHaveBeenCalled(); + expect(bind).not.toHaveBeenCalled(); + }, +); + +test('tenant apps admits the runtime-declared catalog provider without device access', async () => { + const { handler, providerAppCatalog, inspectFacts, bind } = createAppsAdmissionHarness([ + 'Example.app.zip', + ]); + const response = await handler(appsRequest('limrun')); + + expect(response).toEqual({ ok: true, data: { apps: ['Example.app.zip'] } }); + expect(providerAppCatalog).toHaveBeenCalledTimes(1); + expect(inspectFacts).not.toHaveBeenCalled(); + expect(bind).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index 8b2424fe30..5cc511468a 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -1,6 +1,7 @@ import { type DaemonCommandDescriptor, type DaemonCommandRoute, + type SessionlessLeaseAdmissionContext, type SessionCommandKind, } from '../core/command-descriptor/daemon-command-descriptor.ts'; import { deriveDaemonCommandDescriptors } from '../core/command-descriptor/derive.ts'; @@ -95,9 +96,12 @@ export function usesSessionlessDefaultProviderDevice(req: DaemonRequest): boolea return typeof allow === 'function' ? allow(req) : false; } -export function isSessionlessLeaseAdmissionExempt(req: DaemonRequest): boolean { +export function isSessionlessLeaseAdmissionExempt( + req: DaemonRequest, + context: SessionlessLeaseAdmissionContext, +): boolean { const exempt = getDaemonCommandDescriptor(req.command)?.sessionlessLeaseAdmissionExempt; - return typeof exempt === 'function' ? exempt(req) : false; + return typeof exempt === 'function' ? exempt(req, context) : false; } /** diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index b8fbfc243e..444b3ec5a4 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -104,10 +104,13 @@ export function admitRequestLeaseForLockedScope(params: { sessionName: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; + providerAppCatalogIds?: readonly string[]; }): DaemonRequest { const { sessionName, sessionStore, leaseRegistry } = params; const existingSession = sessionStore.get(sessionName); - const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession); + const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession, { + providerAppCatalogIds: params.providerAppCatalogIds, + }); if (!activeLease) return params.req; const nextReq = { diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 86b87f5e20..b7b3dd68f1 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -67,6 +67,7 @@ export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, session?: SessionState, + options: Readonly<{ providerAppCatalogIds?: readonly string[] }> = {}, ): DeviceLease | undefined { if (isLeaseAdmissionExempt(req.command)) { return undefined; @@ -77,7 +78,10 @@ export function assertRequestLeaseAdmission( if ( session === undefined && !requestLeaseScope.leaseId && - (isSessionlessPlainCloseAdmissionExempt(req) || isSessionlessLeaseAdmissionExempt(req)) + (isSessionlessPlainCloseAdmissionExempt(req) || + isSessionlessLeaseAdmissionExempt(req, { + providerAppCatalogIds: options.providerAppCatalogIds ?? [], + })) ) { return undefined; } diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index da9fd447ad..62a2ec236b 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -117,6 +117,7 @@ export async function createRequestExecutionScope(params: { deviceRuntimeGateway?: DeviceRuntimeGateway; platformRequestScope?: PlatformRequestScope; platformResourceCleanup?: PlatformResourceCleanup; + providerAppCatalogIds?: readonly string[]; }): Promise { const { sessionStore, leaseRegistry } = params; let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); @@ -236,6 +237,7 @@ export async function createRequestExecutionScope(params: { sessionName, sessionStore, leaseRegistry, + providerAppCatalogIds: params.providerAppCatalogIds, }); scope.req = scopedReq; return isHumanControlMutation(scopedReq) diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 03644cde56..137fb1b309 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -91,6 +91,7 @@ export type RequestRouterDeps = { leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; providerAppCatalog?: ProviderAppCatalog; + providerAppCatalogIds?: readonly string[]; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; @@ -152,6 +153,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseLifecycleProvider, cloudArtifactProvider, providerAppCatalog, + providerAppCatalogIds, androidObservation = unavailableAndroidObservation, platformResourceCleanup = unavailablePlatformResourceCleanup, providerDeviceRuntimeScope, @@ -218,6 +220,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope, platformResourceCleanup, + providerAppCatalogIds, }); return await executeRequestScope(scope); }), @@ -344,6 +347,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope: createPlatformRequestScope(scopedReq), platformResourceCleanup, + providerAppCatalogIds, }); // The outer replay keeps its stable session lock plus the device lock // from the first device binding through response projection and ref diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 241a3cda53..8b790bd8ca 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -334,6 +334,7 @@ export async function startDaemonRuntime( leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, cloudArtifactProvider, providerAppCatalog, + providerAppCatalogIds: providerRuntimeProviders.providerAppCatalogIds, deviceInventoryGateways, deviceRuntimeGateway, appLogAdmissionLedger, diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index bb58f2acf3..99485d9f91 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -44,6 +44,7 @@ export type ProviderDeviceRuntimeRequestProviders = { providerRuntimeIds: readonly string[]; providerRuntimeRequiredIds: readonly string[]; recoverableProviderIds: readonly string[]; + providerAppCatalogIds: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifactProvider?: CloudArtifactProvider; @@ -108,6 +109,9 @@ export function createProviderDeviceRuntimeRequestProviders( recoverableProviderIds: runtimes .filter((runtime) => runtime.recoverExpiredLease !== undefined) .map((runtime) => runtime.provider), + providerAppCatalogIds: runtimes + .filter((runtime) => runtime.appCatalog !== undefined) + .map((runtime) => runtime.provider), recoverExpiredLease: composeExpiredLeaseRecovery(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), providerAppCatalog: composeProviderAppCatalog(runtimes), From b97aff1d3c66dd01bd7ad854a25b1a31cec75377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 16:12:36 +0200 Subject: [PATCH 10/14] refactor: deepen provider app catalog admission --- packages/contracts/src/device-provider.ts | 9 ++++-- packages/contracts/src/facades/device.ts | 1 + .../contracts/src/provider-device-runtime.ts | 4 +-- packages/provider-limrun/src/runtime.ts | 5 ++-- src/__tests__/provider-device-runtime.test.ts | 11 ++++++- src/cli-schema/cli-help.ts | 2 +- src/commands/management/app.ts | 2 +- .../__tests__/parity.test.ts | 17 ++++------- .../daemon-command-descriptor.ts | 11 ++++--- src/core/command-descriptor/registry.ts | 29 ++++++++++-------- .../__tests__/daemon-command-registry.test.ts | 30 ++++++++----------- .../__tests__/request-admission.test.ts | 8 ++++- .../__tests__/request-handler-chain.test.ts | 5 +++- .../request-router-apps-admission.test.ts | 30 +++++++++++++------ src/daemon/daemon-command-registry.ts | 10 +++---- .../session-inventory-apps-runtime.test.ts | 12 ++++++-- src/daemon/handlers/session-inventory.ts | 5 ++-- src/daemon/lease-lifecycle.ts | 10 +++++-- src/daemon/request-admission.ts | 22 ++++++++++---- src/daemon/request-execution-scope.ts | 5 ++-- src/daemon/request-router.ts | 6 ++-- src/daemon/server/daemon-runtime.ts | 1 - src/provider-device-runtime.ts | 30 +++++++++++-------- 23 files changed, 158 insertions(+), 107 deletions(-) diff --git a/packages/contracts/src/device-provider.ts b/packages/contracts/src/device-provider.ts index 034ae34aa1..c0db10d243 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -72,7 +72,12 @@ export type ProviderAppCatalogQuery = Readonly<{ platform: 'android' | 'ios'; }>; -export type ProviderAppCatalog = ( +export type ProviderAppCatalogHandler = ( query: ProviderAppCatalogQuery, signal?: AbortSignal, -) => Promise; +) => Promise; + +export type ProviderAppCatalog = Readonly<{ + supports(provider: string): boolean; + list: ProviderAppCatalogHandler; +}>; diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 8d70558c44..903d00a7f7 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -25,6 +25,7 @@ export type { LeaseLifecycleContext, LeaseLifecycleProvider, ProviderAppCatalog, + ProviderAppCatalogHandler, ProviderAppCatalogQuery, ProviderDeviceInventoryOutcome, ProviderDeviceInventorySource, diff --git a/packages/contracts/src/provider-device-runtime.ts b/packages/contracts/src/provider-device-runtime.ts index 838a098fc9..683c95fbd0 100644 --- a/packages/contracts/src/provider-device-runtime.ts +++ b/packages/contracts/src/provider-device-runtime.ts @@ -4,7 +4,7 @@ import type { DeviceInventoryProvider, DeviceLease, LeaseLifecycleProvider, - ProviderAppCatalog, + ProviderAppCatalogHandler, } from './device-provider.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; @@ -37,7 +37,7 @@ export type ProviderDeviceRuntime = { leaseLifecycle: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifacts?: CloudArtifactProvider; - appCatalog?: ProviderAppCatalog; + appCatalog?: ProviderAppCatalogHandler; deviceInventoryProvider: DeviceInventoryProvider; ownsDevice(device: DeviceInfo): boolean; getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor | undefined; diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 580c8d0a9e..ce3035348d 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -5,7 +5,7 @@ import type { DeviceLease, LeaseLifecycleProvider, LeaseLifecycleContext, - ProviderAppCatalog, + ProviderAppCatalogHandler, ProviderDeviceInstallOptions, ProviderDeviceInstallResult, ProviderDeviceRuntime, @@ -100,8 +100,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { release: async (lease) => await this.release(lease), }; - readonly appCatalog: ProviderAppCatalog = async (query, signal) => { - if (query.provider !== this.provider) return undefined; + readonly appCatalog: ProviderAppCatalogHandler = async (query, signal) => { const { listLimrunAppAssets } = await import('./app-catalog.ts'); return (await listLimrunAppAssets(this.limrun, query.platform, signal)).map( (asset) => asset.name, diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 625fd8e45e..210c57d8fb 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -28,7 +28,16 @@ test('provider device runtime registry delegates lifecycle, inventory, and inter }); await requestProviders.recoverExpiredLease?.(world.lease); assert.deepEqual(requestProviders.recoverableProviderIds, ['hit']); - assert.deepEqual(requestProviders.providerAppCatalogIds, ['hit']); + assert.equal(requestProviders.providerAppCatalog?.supports('hit'), true); + assert.equal(requestProviders.providerAppCatalog?.supports('miss'), false); + assert.deepEqual( + await requestProviders.providerAppCatalog?.list({ provider: 'hit', platform: 'ios' }), + [], + ); + await assert.rejects( + () => requestProviders.providerAppCatalog!.list({ provider: 'miss', platform: 'ios' }), + /does not expose an app catalog/, + ); assert.deepEqual(world.recoveredLeases, [world.lease]); assert.deepEqual( await requestProviders.deviceInventorySource?.discover( diff --git a/src/cli-schema/cli-help.ts b/src/cli-schema/cli-help.ts index ecf0b3d1cf..1b34962f3a 100644 --- a/src/cli-schema/cli-help.ts +++ b/src/cli-schema/cli-help.ts @@ -736,7 +736,7 @@ Providers: After direct-provider connect: Read the printed Device, App, Next, and workflow-note lines. They are also available as verification/device/app/liveSession/nextSteps/notes in --json output. BrowserStack and AWS Device Farm create the hosted session on open. open needs the installed package or bundle identifier, not the app artifact name or ARN. - Before Limrun allocation, apps lists compatible uploaded app assets without creating an instance. open creates the instance with that asset, resolves its installed app id, and launches it. install remains available when the app comes from a fresh local path or URL. + Before provider allocation, apps lists compatible uploaded app assets without creating an instance when the selected provider exposes a catalog. open creates the instance with that asset, resolves its installed app id, and launches it. install remains available when the app comes from a fresh local path or URL. AWS Device Farm cannot install after allocation. If connect reports no attached app, run its printed reconnect command, which includes --session --force, before open. Do not run devices as a pre-open catalog probe for direct providers; it can allocate the deferred provider session. Limrun is the exception for apps: before allocation it lists uploaded assets for the selected platform. diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 4de2fddc0a..f976bc2176 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -190,7 +190,7 @@ export const appsCommandFacet = defineCommandFacet({ text: { summary: 'List installed apps or deferred provider app assets', cliDetail: - 'Before Limrun allocation, lists uploaded app assets for the selected platform. On a live device, defaults to user-installed apps; use --all to include system/OEM apps.', + 'Before provider allocation, lists uploaded app assets when the selected provider exposes a catalog. On a live device, defaults to user-installed apps; use --all to include system/OEM apps.', }, metadata: appsCommandMetadata, definition: appsCommandDefinition, diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index c9ddd6a330..151ad8d94b 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -30,7 +30,7 @@ import { const DAEMON_FUNCTION_TRAITS = [ 'allowSessionlessDefaultDevice', 'skipSessionlessProviderDevice', - 'sessionlessLeaseAdmissionExempt', + 'sessionlessLeaseAdmissionExemption', ] as const; // Public commands that intentionally have no daemon route — they live only in the @@ -105,19 +105,14 @@ test('derived daemon descriptors preserve closure traits by presence and behavio const live = liveByCommand.get(derived.command); assert.ok(live, `${derived.command} present in hand table`); for (const trait of DAEMON_FUNCTION_TRAITS) { - const derivedFn = derived[trait] as - | ((req: DaemonRequest, context: { providerAppCatalogIds: readonly string[] }) => boolean) - | undefined; - const liveFn = live[trait] as - | ((req: DaemonRequest, context: { providerAppCatalogIds: readonly string[] }) => boolean) - | undefined; + const derivedFn = derived[trait] as ((req: DaemonRequest) => unknown) | undefined; + const liveFn = live[trait] as ((req: DaemonRequest) => unknown) | undefined; assert.equal(typeof derivedFn, typeof liveFn, `${derived.command} ${trait} presence`); if (typeof liveFn === 'function' && typeof derivedFn === 'function') { for (const request of sampleRequests(derived.command)) { - const context = { providerAppCatalogIds: ['limrun'] }; - assert.equal( - derivedFn(request, context), - liveFn(request, context), + assert.deepEqual( + derivedFn(request), + liveFn(request), `${derived.command} ${trait} behavior`, ); } diff --git a/src/core/command-descriptor/daemon-command-descriptor.ts b/src/core/command-descriptor/daemon-command-descriptor.ts index 6e2666f54c..460593941f 100644 --- a/src/core/command-descriptor/daemon-command-descriptor.ts +++ b/src/core/command-descriptor/daemon-command-descriptor.ts @@ -22,9 +22,9 @@ export type DaemonRefFrameEffect = | RefFrameEffect | ((req: TRequest) => RefFrameEffect); -export type SessionlessLeaseAdmissionContext = Readonly<{ - providerAppCatalogIds: readonly string[]; -}>; +export type SessionlessLeaseAdmissionExemption = + | Readonly<{ kind: 'unconditional' }> + | Readonly<{ kind: 'provider-app-catalog'; provider: string }>; /** * Daemon route + request-policy traits for one command. Generic over the request @@ -55,8 +55,7 @@ export type DaemonCommandDescriptor = { preferExplicitDeviceOverExistingSession?: boolean; allowSessionlessDefaultDevice?: (req: TRequest) => boolean; skipSessionlessProviderDevice?: (req: TRequest) => boolean; - sessionlessLeaseAdmissionExempt?: ( + sessionlessLeaseAdmissionExemption?: ( req: TRequest, - context: SessionlessLeaseAdmissionContext, - ) => boolean; + ) => SessionlessLeaseAdmissionExemption | undefined; }; diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 51c580b0e0..83e370d70e 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -177,16 +177,21 @@ const isShardedTestRequest = (req: DispatchedCommand): boolean => // no-lease-anywhere admission bypass in request-admission.ts. `close ` // resolves its device straight from flags when there's no session and must // stay behind full lease/tenant admission. -const isPlainCloseRequest = (req: DispatchedCommand): boolean => - (req.positionals?.length ?? 0) === 0; - -const isDeferredProviderAppCatalogRequest: NonNullable< - DaemonCommandDescriptor['sessionlessLeaseAdmissionExempt'] -> = (req, context) => - req.flags?.leaseId === undefined && - typeof req.flags?.leaseProvider === 'string' && - context.providerAppCatalogIds.includes(req.flags.leaseProvider) && - (req.flags?.platform === 'android' || req.flags?.platform === 'ios'); +const resolvePlainCloseLeaseAdmissionExemption = ( + req: DispatchedCommand, +): { kind: 'unconditional' } | undefined => + (req.positionals?.length ?? 0) === 0 ? { kind: 'unconditional' } : undefined; + +const resolveDeferredProviderAppCatalogLeaseAdmissionExemption: NonNullable< + DaemonCommandDescriptor['sessionlessLeaseAdmissionExemption'] +> = (req) => { + const provider = req.flags?.leaseProvider; + return req.flags?.leaseId === undefined && + typeof provider === 'string' && + (req.flags?.platform === 'android' || req.flags?.platform === 'ios') + ? { kind: 'provider-app-catalog', provider } + : undefined; +}; // ADR 0014 request-sensitive ref-frame resolvers. The action is the leading // positional (see keyboard/alert daemon writers in src/commands/system/index.ts @@ -616,7 +621,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ sessionKind: 'inventory', lockPolicySelectorOverride: true, preferExplicitDeviceOverExistingSession: true, - sessionlessLeaseAdmissionExempt: isDeferredProviderAppCatalogRequest, + sessionlessLeaseAdmissionExemption: resolveDeferredProviderAppCatalogLeaseAdmissionExemption, }, platformExecution: { kind: 'device-runtime', uses: [appsRuntimeUse] as const }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -990,7 +995,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ refFrameEffect: 'may-invalidate', allowInvalidRecording: true, saveScriptFlagOwner: true, - sessionlessLeaseAdmissionExempt: isPlainCloseRequest, + sessionlessLeaseAdmissionExemption: resolvePlainCloseLeaseAdmissionExemption, }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index d61d62a6a9..ef297ce5e0 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -8,7 +8,7 @@ import { getSessionCommandKind, isHumanControlMutation, isLeaseAdmissionExempt, - isSessionlessLeaseAdmissionExempt, + resolveSessionlessLeaseAdmissionExemption, shouldBlockForInvalidRecording, shouldGuardAndroidBlockingDialog, shouldLockSessionExecution, @@ -83,25 +83,19 @@ test('daemon command registry preserves request admission traits', () => { assert.equal(shouldValidateSessionSelector(INTERNAL_COMMANDS.leaseAllocate), true); assert.equal(isLeaseAdmissionExempt(PUBLIC_COMMANDS.open), false); assert.equal(shouldLockSessionExecution(PUBLIC_COMMANDS.open), true); - assert.equal( - isSessionlessLeaseAdmissionExempt( - { - ...makeRequest(PUBLIC_COMMANDS.apps), - flags: { platform: 'android', leaseProvider: 'limrun' }, - }, - { providerAppCatalogIds: ['limrun'] }, - ), - true, + assert.deepEqual( + resolveSessionlessLeaseAdmissionExemption({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun' }, + }), + { kind: 'provider-app-catalog', provider: 'limrun' }, ); assert.equal( - isSessionlessLeaseAdmissionExempt( - { - ...makeRequest(PUBLIC_COMMANDS.apps), - flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, - }, - { providerAppCatalogIds: ['limrun'] }, - ), - false, + resolveSessionlessLeaseAdmissionExemption({ + ...makeRequest(PUBLIC_COMMANDS.apps), + flags: { platform: 'android', leaseProvider: 'limrun', leaseId: 'lease-a' }, + }), + undefined, ); }); diff --git a/src/daemon/__tests__/request-admission.test.ts b/src/daemon/__tests__/request-admission.test.ts index 48d43d2c17..f0f79c8315 100644 --- a/src/daemon/__tests__/request-admission.test.ts +++ b/src/daemon/__tests__/request-admission.test.ts @@ -4,6 +4,12 @@ import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts' import { LeaseRegistry } from '../lease-registry.ts'; import { assertRequestLeaseAdmission } from '../request-admission.ts'; import type { DaemonRequest } from '../types.ts'; +import type { ProviderAppCatalog } from '@agent-device/contracts/device'; + +const limrunAppCatalog: ProviderAppCatalog = { + supports: (provider) => provider === 'limrun', + list: async () => [], +}; function makeRequest(overrides: Partial = {}): DaemonRequest { return { @@ -152,7 +158,7 @@ test('sessionless apps admits a provider declared by the runtime app catalog', ( }), registry, undefined, - { providerAppCatalogIds: ['limrun'] }, + { providerAppCatalog: limrunAppCatalog }, ); assert.equal(result, undefined); diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index 8f8057c457..0ca50b6417 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -107,7 +107,10 @@ test('request handler chain forwards the deferred provider app catalog to invent req.flags = { platform: 'android', leaseProvider: 'limrun' }; const response = await runRequestHandlerChain({ ...makeChainParams(req), - providerAppCatalog: async () => ['Example.apk'], + providerAppCatalog: { + supports: (provider) => provider === 'limrun', + list: async () => ['Example.apk'], + }, }); assert.deepEqual(response, { ok: true, data: { apps: ['Example.apk'] } }); diff --git a/src/daemon/__tests__/request-router-apps-admission.test.ts b/src/daemon/__tests__/request-router-apps-admission.test.ts index 06eb486200..716a38876f 100644 --- a/src/daemon/__tests__/request-router-apps-admission.test.ts +++ b/src/daemon/__tests__/request-router-apps-admission.test.ts @@ -9,8 +9,12 @@ import { LeaseRegistry } from '../lease-registry.ts'; import type { DaemonRequest } from '../types.ts'; import { createRequestHandler } from './test-device-runtime-gateway.ts'; -function createAppsAdmissionHarness(apps: readonly string[] | undefined) { - const providerAppCatalog = vi.fn(async () => apps); +function createAppsAdmissionHarness(apps: readonly string[] = []) { + const listProviderApps = vi.fn(async () => apps); + const providerAppCatalog = { + supports: vi.fn((provider: string) => provider === 'limrun'), + list: listProviderApps, + }; const inspectFacts = vi.fn(async () => { throw new Error('apps catalog must not inspect device facts'); }); @@ -29,10 +33,9 @@ function createAppsAdmissionHarness(apps: readonly string[] | undefined) { shutdown: async () => {}, } satisfies DeviceRuntimeGateway, providerAppCatalog, - providerAppCatalogIds: ['limrun'], trackDownloadableArtifact: () => 'artifact-id', }); - return { handler, providerAppCatalog, inspectFacts, bind }; + return { handler, listProviderApps, inspectFacts, bind }; } function appsRequest(leaseProvider: string): DaemonRequest { @@ -49,28 +52,37 @@ function appsRequest(leaseProvider: string): DaemonRequest { test.each(['bogus', 'proxy', 'browserstack'])( 'tenant apps rejects non-catalog provider %s before provider or device access', async (leaseProvider) => { - const { handler, providerAppCatalog, inspectFacts, bind } = - createAppsAdmissionHarness(undefined); + const { handler, listProviderApps, inspectFacts, bind } = createAppsAdmissionHarness(undefined); const response = await handler(appsRequest(leaseProvider)); expect(response.ok).toBe(false); expect(response.ok === false && response.error.message).toMatch( /tenant isolation requires lease id/, ); - expect(providerAppCatalog).not.toHaveBeenCalled(); + expect(listProviderApps).not.toHaveBeenCalled(); expect(inspectFacts).not.toHaveBeenCalled(); expect(bind).not.toHaveBeenCalled(); }, ); test('tenant apps admits the runtime-declared catalog provider without device access', async () => { - const { handler, providerAppCatalog, inspectFacts, bind } = createAppsAdmissionHarness([ + const { handler, listProviderApps, inspectFacts, bind } = createAppsAdmissionHarness([ 'Example.app.zip', ]); const response = await handler(appsRequest('limrun')); expect(response).toEqual({ ok: true, data: { apps: ['Example.app.zip'] } }); - expect(providerAppCatalog).toHaveBeenCalledTimes(1); + expect(listProviderApps).toHaveBeenCalledTimes(1); + expect(inspectFacts).not.toHaveBeenCalled(); + expect(bind).not.toHaveBeenCalled(); +}); + +test('tenant apps treats an empty provider catalog as authoritative', async () => { + const { handler, listProviderApps, inspectFacts, bind } = createAppsAdmissionHarness(); + const response = await handler(appsRequest('limrun')); + + expect(response).toEqual({ ok: true, data: { apps: [] } }); + expect(listProviderApps).toHaveBeenCalledTimes(1); expect(inspectFacts).not.toHaveBeenCalled(); expect(bind).not.toHaveBeenCalled(); }); diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index 5cc511468a..2b35ca049f 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -1,7 +1,7 @@ import { type DaemonCommandDescriptor, type DaemonCommandRoute, - type SessionlessLeaseAdmissionContext, + type SessionlessLeaseAdmissionExemption, type SessionCommandKind, } from '../core/command-descriptor/daemon-command-descriptor.ts'; import { deriveDaemonCommandDescriptors } from '../core/command-descriptor/derive.ts'; @@ -96,12 +96,10 @@ export function usesSessionlessDefaultProviderDevice(req: DaemonRequest): boolea return typeof allow === 'function' ? allow(req) : false; } -export function isSessionlessLeaseAdmissionExempt( +export function resolveSessionlessLeaseAdmissionExemption( req: DaemonRequest, - context: SessionlessLeaseAdmissionContext, -): boolean { - const exempt = getDaemonCommandDescriptor(req.command)?.sessionlessLeaseAdmissionExempt; - return typeof exempt === 'function' ? exempt(req, context) : false; +): SessionlessLeaseAdmissionExemption | undefined { + return getDaemonCommandDescriptor(req.command)?.sessionlessLeaseAdmissionExemption?.(req); } /** diff --git a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts index 0cd1d2081e..ee33f99d23 100644 --- a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts @@ -148,7 +148,10 @@ test('deferred provider apps returns uploaded assets without resolving a device' sessionStore, inspectFacts, bindDevice, - providerAppCatalog: listAvailableApps, + providerAppCatalog: { + supports: (provider) => provider === 'limrun', + list: listAvailableApps, + }, }); expect(response).toEqual({ @@ -170,7 +173,7 @@ test('deferred provider apps forwards request cancellation to the catalog', asyn const requestId = 'provider-app-catalog-abort'; const registration = registerRequestAbort(requestId); const reason = new Error('catalog canceled'); - const providerAppCatalog = vi.fn(async (_query, signal?: AbortSignal) => { + const listAvailableApps = vi.fn(async (_query, signal?: AbortSignal) => { expect(signal).toBe(registration?.controller.signal); return await new Promise((_resolve, reject) => { signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); @@ -190,7 +193,10 @@ test('deferred provider apps forwards request cancellation to the catalog', asyn req, sessionName: req.session, sessionStore: makeSessionStore(), - providerAppCatalog, + providerAppCatalog: { + supports: (provider) => provider === 'limrun', + list: listAvailableApps, + }, }); registration?.controller.abort(reason); await expect(response).rejects.toBe(reason); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 8c1cf7fa4f..643b841ba9 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -353,14 +353,15 @@ async function resolveProviderAppCatalogResponse( const provider = leaseScope.leaseProvider; const platform = req.flags?.platform; if (!provider || (platform !== 'android' && platform !== 'ios')) return undefined; - const apps = await providerAppCatalog( + if (!providerAppCatalog.supports(provider)) return undefined; + const apps = await providerAppCatalog.list( { provider, platform, }, getRequestSignal(req.meta?.requestId), ); - return apps ? { ok: true, data: { apps: [...apps] } } : undefined; + return { ok: true, data: { apps: [...apps] } }; } async function inspectCapabilityFacts( diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index 444b3ec5a4..8500da8232 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -2,7 +2,11 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { leaseScopeToReleaseRequest } from '../core/lease-scope.ts'; import { clearDeviceClaim } from './device-claims.ts'; import type { LeaseRegistry } from './lease-registry.ts'; -import type { DeviceLease, LeaseLifecycleProvider } from '@agent-device/contracts/device'; +import type { + DeviceLease, + LeaseLifecycleProvider, + ProviderAppCatalog, +} from '@agent-device/contracts/device'; import { buildSessionLeaseFromRequest, type SessionLease } from './lease-context.ts'; import { assertRequestLeaseAdmission, @@ -104,12 +108,12 @@ export function admitRequestLeaseForLockedScope(params: { sessionName: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; - providerAppCatalogIds?: readonly string[]; + providerAppCatalog?: ProviderAppCatalog; }): DaemonRequest { const { sessionName, sessionStore, leaseRegistry } = params; const existingSession = sessionStore.get(sessionName); const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession, { - providerAppCatalogIds: params.providerAppCatalogIds, + providerAppCatalog: params.providerAppCatalog, }); if (!activeLease) return params.req; diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index b7b3dd68f1..3a479fe389 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -5,8 +5,9 @@ import { isLeaseAdmissionExempt, isHumanControlMutation, isSessionlessPlainCloseAdmissionExempt, - isSessionlessLeaseAdmissionExempt, + resolveSessionlessLeaseAdmissionExemption, } from './daemon-command-registry.ts'; +import type { DeviceLease, ProviderAppCatalog } from '@agent-device/contracts/device'; import { DEFAULT_PROXY_LEASE_TTL_MS, findMissingProxyLeaseFields, @@ -16,7 +17,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 { @@ -67,7 +67,7 @@ export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, session?: SessionState, - options: Readonly<{ providerAppCatalogIds?: readonly string[] }> = {}, + options: Readonly<{ providerAppCatalog?: ProviderAppCatalog }> = {}, ): DeviceLease | undefined { if (isLeaseAdmissionExempt(req.command)) { return undefined; @@ -79,9 +79,7 @@ export function assertRequestLeaseAdmission( session === undefined && !requestLeaseScope.leaseId && (isSessionlessPlainCloseAdmissionExempt(req) || - isSessionlessLeaseAdmissionExempt(req, { - providerAppCatalogIds: options.providerAppCatalogIds ?? [], - })) + hasSessionlessLeaseAdmissionExemption(req, options.providerAppCatalog)) ) { return undefined; } @@ -109,6 +107,18 @@ export function assertRequestLeaseAdmission( return lease; } +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 62a2ec236b..9d2685d0a9 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, @@ -117,7 +118,7 @@ export async function createRequestExecutionScope(params: { deviceRuntimeGateway?: DeviceRuntimeGateway; platformRequestScope?: PlatformRequestScope; platformResourceCleanup?: PlatformResourceCleanup; - providerAppCatalogIds?: readonly string[]; + providerAppCatalog?: ProviderAppCatalog; }): Promise { const { sessionStore, leaseRegistry } = params; let scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); @@ -237,7 +238,7 @@ export async function createRequestExecutionScope(params: { sessionName, sessionStore, leaseRegistry, - providerAppCatalogIds: params.providerAppCatalogIds, + providerAppCatalog: params.providerAppCatalog, }); scope.req = scopedReq; return isHumanControlMutation(scopedReq) diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 137fb1b309..fc38f22ebb 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -91,7 +91,6 @@ export type RequestRouterDeps = { leaseLifecycleProvider?: LeaseLifecycleProvider; cloudArtifactProvider?: CloudArtifactProvider; providerAppCatalog?: ProviderAppCatalog; - providerAppCatalogIds?: readonly string[]; androidObservation?: AndroidObservationAdapter; platformResourceCleanup?: PlatformResourceCleanup; providerDeviceRuntimeScope?: (task: () => Promise) => Promise; @@ -153,7 +152,6 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { leaseLifecycleProvider, cloudArtifactProvider, providerAppCatalog, - providerAppCatalogIds, androidObservation = unavailableAndroidObservation, platformResourceCleanup = unavailablePlatformResourceCleanup, providerDeviceRuntimeScope, @@ -220,7 +218,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope, platformResourceCleanup, - providerAppCatalogIds, + providerAppCatalog, }); return await executeRequestScope(scope); }), @@ -347,7 +345,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { deviceRuntimeGateway, platformRequestScope: createPlatformRequestScope(scopedReq), platformResourceCleanup, - providerAppCatalogIds, + providerAppCatalog, }); // The outer replay keeps its stable session lock plus the device lock // from the first device binding through response projection and ref diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 8b790bd8ca..241a3cda53 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -334,7 +334,6 @@ export async function startDaemonRuntime( leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider, cloudArtifactProvider, providerAppCatalog, - providerAppCatalogIds: providerRuntimeProviders.providerAppCatalogIds, deviceInventoryGateways, deviceRuntimeGateway, appLogAdmissionLedger, diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 99485d9f91..2b26812de7 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -44,7 +44,6 @@ export type ProviderDeviceRuntimeRequestProviders = { providerRuntimeIds: readonly string[]; providerRuntimeRequiredIds: readonly string[]; recoverableProviderIds: readonly string[]; - providerAppCatalogIds: readonly string[]; leaseLifecycleProvider?: LeaseLifecycleProvider; recoverExpiredLease?: ProviderExpiredLeaseRecovery; cloudArtifactProvider?: CloudArtifactProvider; @@ -109,9 +108,6 @@ export function createProviderDeviceRuntimeRequestProviders( recoverableProviderIds: runtimes .filter((runtime) => runtime.recoverExpiredLease !== undefined) .map((runtime) => runtime.provider), - providerAppCatalogIds: runtimes - .filter((runtime) => runtime.appCatalog !== undefined) - .map((runtime) => runtime.provider), recoverExpiredLease: composeExpiredLeaseRecovery(runtimes), cloudArtifactProvider: composeCloudArtifactProvider(runtimes), providerAppCatalog: composeProviderAppCatalog(runtimes), @@ -220,14 +216,24 @@ function composeCloudArtifactProvider( function composeProviderAppCatalog( runtimes: ProviderDeviceRuntime[], ): ProviderAppCatalog | undefined { - if (!runtimes.some((runtime) => runtime.appCatalog !== undefined)) return undefined; - return async (query, signal) => { - for (const runtime of runtimes) { - if (!runtime.appCatalog || !runtimeMatchesProvider(runtime, query.provider)) continue; - const apps = await runtime.appCatalog(query, signal); - if (apps) return apps; - } - return undefined; + const catalogRuntimes = runtimes.filter((runtime) => runtime.appCatalog !== undefined); + if (catalogRuntimes.length === 0) return undefined; + return { + supports: (provider) => + catalogRuntimes.some((runtime) => runtimeMatchesProvider(runtime, provider)), + list: async (query, signal) => { + const runtime = catalogRuntimes.find((candidate) => + runtimeMatchesProvider(candidate, query.provider), + ); + if (!runtime?.appCatalog) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `Provider ${query.provider} does not expose an app catalog.`, + { provider: query.provider }, + ); + } + return await runtime.appCatalog(query, signal); + }, }; } From a0e3adf1b16be1f86b61688e5ef73ace7bb079ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 28 Aug 2026 22:10:30 +0200 Subject: [PATCH 11/14] refactor: simplify provider capability projection --- src/cli/connection/provider-policy.test.ts | 50 ++++++++----------- src/cli/connection/provider-policy.ts | 57 ++++++---------------- 2 files changed, 33 insertions(+), 74 deletions(-) diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index 738f30fc2d..508d5311a1 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -3,42 +3,30 @@ import { test } from 'vitest'; import { connectionProviderCapabilitiesForLease, connectionProviderCapabilitiesForVerification, - connectionProviderRequiresAppAttachment, - connectionProviderSupportsArtifacts, - connectionProviderSupportsDeferredAppSelection, - connectionProviderSupportsDirectPortReverse, - connectionProviderUsesCloudWebDriverLease, } from './provider-policy.ts'; -test('only providers declaring deferred app selection use app catalog before allocation', () => { - assert.equal(connectionProviderSupportsDeferredAppSelection('limrun'), true); - assert.equal(connectionProviderSupportsDeferredAppSelection('browserstack'), false); - assert.equal(connectionProviderSupportsDeferredAppSelection('aws-device-farm'), false); - assert.equal(connectionProviderSupportsDeferredAppSelection('proxy'), false); - assert.equal(connectionProviderSupportsDeferredAppSelection(undefined), false); -}); - -test('provider capabilities stay declared outside command implementations', () => { - assert.equal(connectionProviderRequiresAppAttachment('aws-device-farm'), true); - assert.equal(connectionProviderRequiresAppAttachment('browserstack'), false); - assert.equal(connectionProviderSupportsArtifacts('aws-device-farm'), true); - assert.equal(connectionProviderSupportsArtifacts('browserstack'), true); - assert.equal(connectionProviderSupportsArtifacts('limrun'), false); - assert.equal(connectionProviderSupportsDirectPortReverse('limrun'), true); - assert.equal(connectionProviderSupportsDirectPortReverse('aws-device-farm'), false); - assert.equal(connectionProviderUsesCloudWebDriverLease('browserstack'), true); - assert.equal(connectionProviderUsesCloudWebDriverLease('aws-device-farm'), true); - assert.equal(connectionProviderUsesCloudWebDriverLease('limrun'), false); -}); - -test('provider carriers resolve to semantic capabilities before commands inspect them', () => { +test('provider carriers project provider identity into semantic capabilities', () => { + assert.deepEqual(connectionProviderCapabilitiesForLease({ leaseProvider: 'limrun' }), { + leaseKind: 'direct-device-provider', + requiresAppAttachment: false, + requiresRemoteDaemon: false, + supportsArtifacts: false, + supportsDeferredAppSelection: true, + supportsDirectPortReverse: true, + usesCloudWebDriverLease: false, + }); + const browserStack = connectionProviderCapabilitiesForVerification({ + provider: 'browserstack', + }); + assert.equal(browserStack.supportsArtifacts, true); + assert.equal(browserStack.usesCloudWebDriverLease, true); assert.equal( - connectionProviderCapabilitiesForLease({ leaseProvider: 'limrun' }) - .supportsDeferredAppSelection, + connectionProviderCapabilitiesForLease({ leaseProvider: 'aws-device-farm' }) + .requiresAppAttachment, true, ); assert.equal( - connectionProviderCapabilitiesForVerification({ provider: 'browserstack' }).supportsArtifacts, - true, + connectionProviderCapabilitiesForLease({ leaseProvider: 'proxy' }).leaseKind, + 'proxy', ); }); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index bc57074c49..9ca1cdcd54 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -17,8 +17,6 @@ type ConnectionProviderCapabilities = { usesCloudWebDriverLease: boolean; }; -const DEFERRED_APP_SELECTION_PROVIDERS = new Set(['limrun']); - export function isConnectProviderName(value: string | undefined): value is ConnectProvider { return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); } @@ -39,40 +37,6 @@ export function connectProviderNamesForError(): string { ].join(', '); } -function connectionProviderRequiresRemoteDaemon(provider: string | undefined): boolean { - return !isDirectDeviceConnectProvider(provider); -} - -export function connectionProviderSupportsDeferredAppSelection( - provider: string | undefined, -): boolean { - return isDirectDeviceConnectProvider(provider) && DEFERRED_APP_SELECTION_PROVIDERS.has(provider); -} - -export function connectionProviderRequiresAppAttachment(provider: string | undefined): boolean { - return provider === CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm; -} - -export function connectionProviderSupportsArtifacts(provider: string | undefined): boolean { - return isCloudWebDriverProviderName(provider); -} - -export function connectionProviderSupportsDirectPortReverse(provider: string | undefined): boolean { - return provider === 'limrun'; -} - -export function connectionProviderUsesCloudWebDriverLease(provider: string | undefined): boolean { - return isCloudWebDriverProviderName(provider); -} - -function connectionProviderLeaseKind( - provider: string | undefined, -): 'proxy' | 'direct-device-provider' | 'remote-provider' { - if (provider === 'proxy') return 'proxy'; - if (isDirectDeviceConnectProvider(provider)) return 'direct-device-provider'; - return 'remote-provider'; -} - export function connectionProviderCapabilitiesForLease(source: { leaseProvider?: string; }): ConnectionProviderCapabilities { @@ -88,13 +52,20 @@ export function connectionProviderCapabilitiesForVerification( function connectionProviderCapabilities( provider: string | undefined, ): ConnectionProviderCapabilities { + const directDeviceProvider = isDirectDeviceConnectProvider(provider); + const cloudWebDriver = isCloudWebDriverProviderName(provider); return { - leaseKind: connectionProviderLeaseKind(provider), - requiresAppAttachment: connectionProviderRequiresAppAttachment(provider), - requiresRemoteDaemon: connectionProviderRequiresRemoteDaemon(provider), - supportsArtifacts: connectionProviderSupportsArtifacts(provider), - supportsDeferredAppSelection: connectionProviderSupportsDeferredAppSelection(provider), - supportsDirectPortReverse: connectionProviderSupportsDirectPortReverse(provider), - usesCloudWebDriverLease: connectionProviderUsesCloudWebDriverLease(provider), + leaseKind: + provider === 'proxy' + ? 'proxy' + : directDeviceProvider + ? 'direct-device-provider' + : 'remote-provider', + requiresAppAttachment: provider === CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + requiresRemoteDaemon: !directDeviceProvider, + supportsArtifacts: cloudWebDriver, + supportsDeferredAppSelection: provider === 'limrun', + supportsDirectPortReverse: provider === 'limrun', + usesCloudWebDriverLease: cloudWebDriver, }; } From 817b0bc0c6301903a2b82024fca56850c58a14f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 31 Aug 2026 10:55:10 +0200 Subject: [PATCH 12/14] fix: restrict Limrun uploaded apps on public daemon --- packages/contracts/src/device-provider.ts | 2 + .../provider-limrun/src/app-catalog.test.ts | 6 +++ packages/provider-limrun/src/app-catalog.ts | 8 +++ .../src/runtime-dependencies.test.ts | 50 ++++++++++++++++--- packages/provider-limrun/src/runtime.ts | 3 +- .../provider-limrun/src/session-allocation.ts | 2 + .../__tests__/request-handler-catalog.test.ts | 12 ++++- .../session-inventory-apps-runtime.test.ts | 27 ++++++++++ src/daemon/handlers/lease.ts | 1 + src/daemon/handlers/session-inventory.ts | 1 + src/daemon/request-admission.ts | 4 +- 11 files changed, 102 insertions(+), 14 deletions(-) diff --git a/packages/contracts/src/device-provider.ts b/packages/contracts/src/device-provider.ts index c0db10d243..fd33bb26b4 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -25,6 +25,7 @@ export type DeviceLease = { export type LeaseLifecycleContext = { flags?: Readonly>; cwd?: string; + publicNetworkOnly?: boolean; /** Request-bound cancellation (explicit cancel or client disconnect). */ signal?: AbortSignal; /** @@ -70,6 +71,7 @@ export type ProviderDeviceInventorySource = Readonly<{ export type ProviderAppCatalogQuery = Readonly<{ provider: string; platform: 'android' | 'ios'; + publicNetworkOnly?: boolean; }>; export type ProviderAppCatalogHandler = ( diff --git a/packages/provider-limrun/src/app-catalog.test.ts b/packages/provider-limrun/src/app-catalog.test.ts index 6685274847..27a636f3d1 100644 --- a/packages/provider-limrun/src/app-catalog.test.ts +++ b/packages/provider-limrun/src/app-catalog.test.ts @@ -1,11 +1,17 @@ import { describe, expect, test, vi } from 'vitest'; import { + assertLimrunUploadedAppAccess, listLimrunAppAssets, resolveInstalledAppIdForAsset, resolveLimrunAppAsset, } from './app-catalog.ts'; describe('Limrun uploaded app catalog', () => { + test('rejects uploaded app access on the public daemon HTTP surface', () => { + expect(() => assertLimrunUploadedAppAccess(true)).toThrow(/public daemon HTTP surface/); + expect(() => assertLimrunUploadedAppAccess(false)).not.toThrow(); + }); + 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' }, diff --git a/packages/provider-limrun/src/app-catalog.ts b/packages/provider-limrun/src/app-catalog.ts index 13028f8f1e..2e327e4da2 100644 --- a/packages/provider-limrun/src/app-catalog.ts +++ b/packages/provider-limrun/src/app-catalog.ts @@ -11,6 +11,14 @@ export type LimrunAppAsset = Readonly<{ type InstalledAppIdentity = Readonly<{ id: string; name?: string }>; +export function assertLimrunUploadedAppAccess(publicNetworkOnly: boolean | undefined): void { + if (!publicNetworkOnly) return; + throw new AppError( + 'UNAUTHORIZED', + 'Limrun uploaded apps are unavailable on the public daemon HTTP surface.', + ); +} + export async function listLimrunAppAssets( limrun: Limrun, platform: 'android' | 'ios', diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index 4a22879d38..c1f82fd935 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -15,6 +15,14 @@ import type { const state = vi.hoisted(() => ({ constructorOptions: [] as Array<{ defaultHeaders?: Record }>, androidCreateInputs: [] as unknown[], + assetList: vi.fn(async () => [ + { + id: 'asset-example', + name: 'Example.apk', + md5: 'uploaded', + os: 'android', + }, + ]), tunnelClose: vi.fn(), disconnect: vi.fn(), })); @@ -45,14 +53,7 @@ vi.mock('@limrun/api', () => ({ readonly assets = { getOrUpload: vi.fn(), - list: vi.fn(async () => [ - { - id: 'asset-example', - name: 'Example.apk', - md5: 'uploaded', - os: 'android', - }, - ]), + list: state.assetList, }; constructor(options: { defaultHeaders?: Record }) { @@ -158,6 +159,39 @@ test('allocation installs an exact uploaded asset before binding its application } }); +test('public daemon requests cannot list or allocate uploaded apps', async () => { + state.androidCreateInputs.length = 0; + state.assetList.mockClear(); + const fixture = createContractFixture(); + const runtime = createLimrunRuntime({ apiKey: 'lim_test_key' }, fixture.dependencies); + const appCatalog = runtime.appCatalog; + if (!appCatalog) throw new Error('Expected Limrun app catalog capability'); + + try { + await assert.rejects( + async () => + await appCatalog({ + provider: 'limrun', + platform: 'android', + publicNetworkOnly: true, + }), + (error) => error instanceof AppError && error.code === 'UNAUTHORIZED', + ); + await assert.rejects( + async () => + await runtime.leaseLifecycle.allocate?.(androidLease(), { + flags: { providerApp: 'Example.apk' }, + publicNetworkOnly: true, + }), + (error) => error instanceof AppError && error.code === 'UNAUTHORIZED', + ); + assert.equal(state.assetList.mock.calls.length, 0); + assert.equal(state.androidCreateInputs.length, 0); + } 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' }]); diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index ce3035348d..4a6b97a59c 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -101,7 +101,8 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { }; readonly appCatalog: ProviderAppCatalogHandler = async (query, signal) => { - const { listLimrunAppAssets } = await import('./app-catalog.ts'); + const { assertLimrunUploadedAppAccess, listLimrunAppAssets } = await import('./app-catalog.ts'); + assertLimrunUploadedAppAccess(query.publicNetworkOnly); return (await listLimrunAppAssets(this.limrun, query.platform, signal)).map( (asset) => asset.name, ); diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts index 2a77c83352..6028608d3a 100644 --- a/packages/provider-limrun/src/session-allocation.ts +++ b/packages/provider-limrun/src/session-allocation.ts @@ -5,6 +5,7 @@ import { createLimrunAndroidSession, type LimrunAndroidSession } from './android import { buildLimrunDevice } from './device.ts'; import { createLimrunIosSession, type LimrunIosSession } from './ios.ts'; import { + assertLimrunUploadedAppAccess, resolveInstalledAppIdForAsset, resolveLimrunAppAsset, type LimrunAppAsset, @@ -41,6 +42,7 @@ export async function resolveRequestedLimrunAppAsset( const value = context?.flags?.providerApp; const name = typeof value === 'string' ? value.trim() : ''; if (!name) return undefined; + assertLimrunUploadedAppAccess(context?.publicNetworkOnly); return await resolveLimrunAppAsset(limrun, platform, name, context?.signal); } diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 609f6aefb9..0f3e40c6ed 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -258,7 +258,9 @@ test('lease allocation hands the provider the request-bound signal and a deadlin const requestId = 'lease-alloc-cancel-req'; const registration = registerRequestAbort(requestId); const before = Date.now(); - let observed: { signal?: AbortSignal; deadline?: number } | undefined; + let observed: + | { signal?: AbortSignal; deadline?: number; publicNetworkOnly?: boolean } + | undefined; try { const response = await handleLeaseCommands({ @@ -273,6 +275,7 @@ test('lease allocation hands the provider the request-bound signal and a deadlin leaseBackend: 'android-instance', leaseProvider: 'fake-provider', }, + internal: { publicNetworkOnly: true }, positionals: [], }, sessionName: 'catalog-test', @@ -280,7 +283,11 @@ test('lease allocation hands the provider the request-bound signal and a deadlin leaseRegistry, leaseLifecycleProvider: { allocate: async (_lease, context) => { - observed = { signal: context?.signal, deadline: context?.deadline }; + observed = { + signal: context?.signal, + deadline: context?.deadline, + publicNetworkOnly: context?.publicNetworkOnly, + }; return { provider: 'fake-provider' }; }, }, @@ -288,6 +295,7 @@ test('lease allocation hands the provider the request-bound signal and a deadlin assert.equal(response?.ok, true); assert.equal(observed?.signal?.aborted, false); + assert.equal(observed?.publicNetworkOnly, true); markRequestCanceled(requestId); assert.equal(observed?.signal?.aborted, true, 'the provider signal must track this request'); assert.ok( 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 ee33f99d23..9f0db70e8b 100644 --- a/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-inventory-apps-runtime.test.ts @@ -204,3 +204,30 @@ test('deferred provider apps forwards request cancellation to the catalog', asyn clearRequestAbortRegistration(registration); } }); + +test('deferred provider apps forwards public daemon access to the catalog', async () => { + const listAvailableApps = vi.fn(async () => ['Example.apk']); + const req: DaemonRequest = { + token: 'test-token', + session: 'limrun-public-apps', + command: 'apps', + positionals: [], + flags: { platform: 'ios', leaseProvider: 'limrun' }, + internal: { publicNetworkOnly: true }, + }; + + await handleSessionInventoryCommands({ + req, + sessionName: req.session, + sessionStore: makeSessionStore(), + providerAppCatalog: { + supports: (provider) => provider === 'limrun', + list: listAvailableApps, + }, + }); + + expect(listAvailableApps).toHaveBeenCalledWith( + { provider: 'limrun', platform: 'ios', publicNetworkOnly: true }, + undefined, + ); +}); diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 6c14c8b11b..359e5514d5 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -231,6 +231,7 @@ function leaseLifecycleContext(req: DaemonRequest): LeaseLifecycleContext { return { flags: req.flags, cwd: typeof req.meta?.cwd === 'string' ? req.meta.cwd : undefined, + ...(req.internal?.publicNetworkOnly ? { publicNetworkOnly: true } : {}), }; } diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 643b841ba9..47de120f33 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -358,6 +358,7 @@ async function resolveProviderAppCatalogResponse( { provider, platform, + ...(req.internal?.publicNetworkOnly ? { publicNetworkOnly: true } : {}), }, getRequestSignal(req.meta?.requestId), ); diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 3a479fe389..c73ee17ed9 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -4,7 +4,6 @@ import { isTenantOwnedSessionName, tenantScopedSessionName } from './session-ten import { isLeaseAdmissionExempt, isHumanControlMutation, - isSessionlessPlainCloseAdmissionExempt, resolveSessionlessLeaseAdmissionExemption, } from './daemon-command-registry.ts'; import type { DeviceLease, ProviderAppCatalog } from '@agent-device/contracts/device'; @@ -78,8 +77,7 @@ export function assertRequestLeaseAdmission( if ( session === undefined && !requestLeaseScope.leaseId && - (isSessionlessPlainCloseAdmissionExempt(req) || - hasSessionlessLeaseAdmissionExemption(req, options.providerAppCatalog)) + hasSessionlessLeaseAdmissionExemption(req, options.providerAppCatalog) ) { return undefined; } From b1766b024336b94ddae3758239a286ce80c70b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 31 Aug 2026 10:58:11 +0200 Subject: [PATCH 13/14] refactor: separate provider app catalog lookup --- src/daemon/handlers/session-inventory.ts | 29 +++++++++++++----------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 47de120f33..20abc8d5e3 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -39,7 +39,7 @@ 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 type { ProviderAppCatalog, ProviderAppCatalogQuery } from '@agent-device/contracts/device'; import { resolveLeaseScope } from '../lease-context.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; @@ -347,22 +347,25 @@ async function resolveProviderAppCatalogResponse( req: DaemonRequest, providerAppCatalog: ProviderAppCatalog | undefined, ): Promise { - if (!providerAppCatalog) return undefined; + const query = resolveProviderAppCatalogQuery(req); + if (!providerAppCatalog || !query || !providerAppCatalog.supports(query.provider)) + return undefined; + const apps = await providerAppCatalog.list(query, getRequestSignal(req.meta?.requestId)); + return { ok: true, data: { apps: [...apps] } }; +} + +function resolveProviderAppCatalogQuery(req: DaemonRequest): ProviderAppCatalogQuery | undefined { const leaseScope = resolveLeaseScope(req); if (leaseScope.leaseId) return undefined; const provider = leaseScope.leaseProvider; + if (!provider) return undefined; 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, - ...(req.internal?.publicNetworkOnly ? { publicNetworkOnly: true } : {}), - }, - getRequestSignal(req.meta?.requestId), - ); - return { ok: true, data: { apps: [...apps] } }; + if (platform !== 'android' && platform !== 'ios') return undefined; + return { + provider, + platform, + ...(req.internal?.publicNetworkOnly ? { publicNetworkOnly: true } : {}), + }; } async function inspectCapabilityFacts( From 77d853c0a6dd21e818c39a93656a5aa2bc6cc7fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 31 Aug 2026 12:24:11 +0200 Subject: [PATCH 14/14] fix: simplify uploaded app provider boundaries --- oxlint.config.ts | 26 ---- .../application-lifecycle-interaction.test.ts | 33 +---- .../src/application-lifecycle-interaction.ts | 50 ++------ packages/contracts/src/device-provider.ts | 1 + .../provider-limrun/src/app-catalog.test.ts | 8 +- packages/provider-limrun/src/app-catalog.ts | 5 +- .../provider-limrun/src/lifecycle.test.ts | 17 ++- packages/provider-limrun/src/lifecycle.ts | 66 +++++++++- .../src/runtime-dependencies.test.ts | 6 +- .../provider-limrun/src/session-allocation.ts | 2 +- src/__tests__/cli-config-limrun.test.ts | 56 ++++++++- src/cli/commands/connection-presentation.ts | 23 ++-- src/cli/commands/connection-runtime.ts | 29 +++-- src/cli/commands/connection.ts | 13 +- src/cli/commands/react-devtools.ts | 4 +- src/cli/connection/provider-policy.test.ts | 24 +--- src/cli/connection/provider-policy.ts | 16 +-- src/cli/connection/provider-session-result.ts | 7 -- .../http-server-limrun-uploaded-apps.test.ts | 118 ++++++++++++++++++ .../__tests__/request-handler-catalog.test.ts | 10 +- src/daemon/client/daemon-client-rpc.test.ts | 32 ++++- src/daemon/client/daemon-client-rpc.ts | 7 +- src/daemon/handlers/lease.ts | 1 + src/daemon/server/http-server.ts | 4 + src/remote/remote-connection-state.ts | 6 - test/wire-compat/ledger.json | 14 ++- 26 files changed, 381 insertions(+), 197 deletions(-) delete mode 100644 src/cli/connection/provider-session-result.ts create mode 100644 src/daemon/__tests__/http-server-limrun-uploaded-apps.test.ts diff --git a/oxlint.config.ts b/oxlint.config.ts index 0f4a3f73a0..b1eb981ec0 100644 --- a/oxlint.config.ts +++ b/oxlint.config.ts @@ -93,32 +93,6 @@ export default defineConfig({ ], }, ], - 'no-restricted-properties': [ - 'error', - { - property: 'leaseProvider', - message: - 'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.', - }, - ], - }, - }, - { - files: ['src/cli/commands/**/*.ts'], - rules: { - 'no-restricted-properties': [ - 'error', - { - property: 'leaseProvider', - message: - 'Command implementations must ask src/cli/connection/provider-policy.ts for provider capabilities.', - }, - { - property: 'provider', - message: - 'Connection commands must ask src/cli/connection/provider-policy.ts for provider capabilities.', - }, - ], }, }, { diff --git a/packages/contracts/src/application-lifecycle-interaction.test.ts b/packages/contracts/src/application-lifecycle-interaction.test.ts index 4da6e55c65..2e42310f7c 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, vi } from 'vitest'; +import { expect, test } 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,37 +91,6 @@ 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 bdca0620ae..0db4ce162f 100644 --- a/packages/contracts/src/application-lifecycle-interaction.ts +++ b/packages/contracts/src/application-lifecycle-interaction.ts @@ -262,7 +262,6 @@ 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']; }>; @@ -283,8 +282,7 @@ export function bindDirectApplicationLifecycle( ); }; return Object.freeze({ - resolveOpenTarget: async (input) => - resolveDirectOpenTarget(params.openTargetIdentity, resolveOpenTargetReference(params, input)), + resolveOpenTarget: async (input) => resolveDirectOpenTarget(params.openTargetIdentity, input), prepareApplicationOpen: async () => undefined, openApplication: async (input) => await openDirectApplication(params, input), applyRuntimeHints: unavailable, @@ -306,64 +304,38 @@ async function openDirectApplication( params: DirectApplicationLifecycleParams, input: OpenApplicationInput, ): Promise { - const resolvedInput = resolveOpenApplicationReferences(params, input); const { binding } = params; - const interactor = await binding.resolveInteractor( - resolvedInput.execution, - resolvedInput.appBundleId, - ); - if (params.closeBeforeRelaunch && resolvedInput.relaunch && resolvedInput.target !== undefined) { + const interactor = await binding.resolveInteractor(input.execution, input.appBundleId); + if (params.closeBeforeRelaunch && input.relaunch && input.target !== undefined) { await invokeApplicationClose({ device: binding.device, interactor, - positionals: [resolvedInput.appBundleId ?? resolvedInput.target], + positionals: [input.appBundleId ?? input.target], }); } await invokeApplicationOpen({ device: binding.device, interactor, - positionals: resolvedInput.positionals, - appBundleId: resolvedInput.appBundleId, - execution: resolvedInput.execution, + positionals: input.positionals, + appBundleId: input.appBundleId, + execution: input.execution, }); - const followUpUrl = followUpRuntimeLaunchUrl(resolvedInput); + const followUpUrl = followUpRuntimeLaunchUrl(input); if (followUpUrl) { await invokeApplicationOpen({ device: binding.device, interactor, positionals: [followUpUrl], - appBundleId: resolvedInput.appBundleId, + appBundleId: input.appBundleId, execution: { - ...resolvedInput.execution, + ...input.execution, clearAppState: undefined, launchConsole: undefined, launchArgs: undefined, }, }); } - 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, - }; + return { appBundleId: input.appBundleId, timing: {} }; } export function followUpRuntimeLaunchUrl(input: OpenApplicationInput): string | undefined { diff --git a/packages/contracts/src/device-provider.ts b/packages/contracts/src/device-provider.ts index fd33bb26b4..ac49605c38 100644 --- a/packages/contracts/src/device-provider.ts +++ b/packages/contracts/src/device-provider.ts @@ -24,6 +24,7 @@ export type DeviceLease = { export type LeaseLifecycleContext = { flags?: Readonly>; + initialApp?: string; cwd?: string; publicNetworkOnly?: boolean; /** Request-bound cancellation (explicit cancel or client disconnect). */ diff --git a/packages/provider-limrun/src/app-catalog.test.ts b/packages/provider-limrun/src/app-catalog.test.ts index 27a636f3d1..0fe2ddfb69 100644 --- a/packages/provider-limrun/src/app-catalog.test.ts +++ b/packages/provider-limrun/src/app-catalog.test.ts @@ -35,7 +35,8 @@ describe('Limrun uploaded app catalog', () => { const list = vi .fn() .mockResolvedValueOnce([ - { id: 'similar', name: 'Example.app.zip.backup.zip', md5: 'z' }, + { id: 'similar-1', name: 'Example.app.zip.backup.zip', md5: 'z' }, + { id: 'similar-2', name: 'Example.app.zip.previous.zip', md5: 'y' }, { id: 'ios-app', name: 'Example.app.zip', md5: 'a' }, ]) .mockResolvedValueOnce([{ id: 'android-app', name: 'Example.apk', md5: 'b' }]); @@ -46,6 +47,11 @@ describe('Limrun uploaded app catalog', () => { name: 'Example.app.zip', }); await expect(resolveLimrunAppAsset(limrun, 'ios', 'Example.apk')).resolves.toBeUndefined(); + expect(list).toHaveBeenNthCalledWith( + 1, + { limit: 1_000, nameFilter: 'Example.app.zip' }, + { signal: undefined }, + ); }); test('matches an uploaded iOS asset when the instance also contains Expo Go', () => { diff --git a/packages/provider-limrun/src/app-catalog.ts b/packages/provider-limrun/src/app-catalog.ts index 2e327e4da2..4ed738bc07 100644 --- a/packages/provider-limrun/src/app-catalog.ts +++ b/packages/provider-limrun/src/app-catalog.ts @@ -42,7 +42,10 @@ export async function resolveLimrunAppAsset( signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); - const assets = await limrun.assets.list({ limit: 2, nameFilter: name }, { signal }); + const assets = await limrun.assets.list( + { limit: APP_CATALOG_LIMIT, nameFilter: name }, + { signal }, + ); signal?.throwIfAborted(); const matches = assets .filter((asset) => asset.name === name) diff --git a/packages/provider-limrun/src/lifecycle.test.ts b/packages/provider-limrun/src/lifecycle.test.ts index 23783d5c00..47bb093c79 100644 --- a/packages/provider-limrun/src/lifecycle.test.ts +++ b/packages/provider-limrun/src/lifecycle.test.ts @@ -257,6 +257,7 @@ test('a live Limrun lifecycle binding relaunches with its selected provider inte localInteractors: { resolve: localInteractor }, }, getInteractor: () => ({ close: providerClose, open: providerOpen }) as unknown as Interactor, + resolveAppReference: (_device, app) => (app === 'Example.app.zip' ? 'com.example.app' : app), }), ); @@ -270,10 +271,13 @@ test('a live Limrun lifecycle binding relaunches with its selected provider inte scope, }); + await expect( + binding.operations.resolveOpenTarget?.({ target: 'Example.app.zip', surface: 'app' }), + ).resolves.toEqual({ appBundleId: 'com.example.app', appName: 'com.example.app' }); await binding.operations.openApplication?.({ - target: 'com.example.app', - positionals: ['com.example.app'], - appBundleId: 'com.example.app', + target: 'Example.app.zip', + positionals: ['Example.app.zip'], + appBundleId: 'Example.app.zip', surface: 'app', hasExistingSession: true, relaunch: true, @@ -288,6 +292,13 @@ test('a live Limrun lifecycle binding relaunches with its selected provider inte 'com.example.app', expect.objectContaining({ appBundleId: 'com.example.app' }), ); + await binding.operations.closeApplication?.({ + positionals: ['Example.app.zip'], + appBundleId: 'Example.app.zip', + surface: 'app', + execution: {}, + }); + expect(providerClose).toHaveBeenLastCalledWith('com.example.app'); expect(localInteractor).not.toHaveBeenCalled(); }); diff --git a/packages/provider-limrun/src/lifecycle.ts b/packages/provider-limrun/src/lifecycle.ts index 5a4d0757ac..f90ed73a22 100644 --- a/packages/provider-limrun/src/lifecycle.ts +++ b/packages/provider-limrun/src/lifecycle.ts @@ -1,4 +1,9 @@ -import type { ApplicationLifecycleRuntimeOperations } from '@agent-device/contracts/application-lifecycle-runtime'; +import type { + ApplicationLifecycleRuntimeOperations, + CloseApplicationInput, + OpenApplicationInput, + OpenTargetResolutionInput, +} from '@agent-device/contracts/application-lifecycle-runtime'; import { bindDirectApplicationLifecycle, bindProviderApplicationLifecycleInteractor, @@ -21,11 +26,10 @@ type LimrunLifecycleParams = Readonly<{ export function bindLimrunApplicationLifecycle( params: LimrunLifecycleParams, ): ApplicationLifecycleRuntimeOperations { - return bindDirectApplicationLifecycle({ + const lifecycle = bindDirectApplicationLifecycle({ owner: 'Limrun', openTargetIdentity: 'bundle-id', closeBeforeRelaunch: true, - resolveAppReference: params.resolveAppReference, configureProviderPortReverse: async (input) => await params.configurePortReverse(input), binding: bindProviderApplicationLifecycleInteractor({ device: params.device, @@ -33,4 +37,60 @@ export function bindLimrunApplicationLifecycle( resolveInteractor: (runner) => params.getInteractor(params.device, runner), }), }); + const resolve = params.resolveAppReference; + if (!resolve) return lifecycle; + return Object.freeze({ + ...lifecycle, + resolveOpenTarget: async (input) => + await lifecycle.resolveOpenTarget!(resolveOpenTargetInput(input, resolve)), + openApplication: async (input) => + await lifecycle.openApplication!(resolveOpenApplicationInput(input, resolve)), + closeApplication: async (input) => + await lifecycle.closeApplication!(resolveCloseApplicationInput(input, resolve)), + }); +} + +function resolveOpenTargetInput( + input: OpenTargetResolutionInput, + resolve: (app: string) => string, +): OpenTargetResolutionInput { + return input.target === undefined ? input : { ...input, target: resolve(input.target) }; +} + +function resolveOpenApplicationInput( + input: OpenApplicationInput, + resolve: (app: string) => string, +): OpenApplicationInput { + return { + ...input, + target: resolveOptionalApp(input.target, resolve), + positionals: resolveFirstApp(input.positionals, resolve), + appBundleId: resolveOptionalApp(input.appBundleId, resolve), + }; +} + +function resolveCloseApplicationInput( + input: CloseApplicationInput, + resolve: (app: string) => string, +): CloseApplicationInput { + return { + ...input, + positionals: resolveFirstApp(input.positionals, resolve), + appBundleId: resolveOptionalApp(input.appBundleId, resolve), + }; +} + +function resolveFirstApp( + positionals: readonly string[], + resolve: (app: string) => string, +): readonly string[] { + const app = positionals[0]; + return app === undefined ? positionals : [resolve(app), ...positionals.slice(1)]; +} + +function resolveOptionalApp( + app: string | undefined, + resolve: (app: string) => string, +): string | undefined { + return app === undefined ? undefined : resolve(app); } diff --git a/packages/provider-limrun/src/runtime-dependencies.test.ts b/packages/provider-limrun/src/runtime-dependencies.test.ts index c1f82fd935..8b69a8615b 100644 --- a/packages/provider-limrun/src/runtime-dependencies.test.ts +++ b/packages/provider-limrun/src/runtime-dependencies.test.ts @@ -128,7 +128,7 @@ test('allocation installs an exact uploaded asset before binding its application try { await runtime.leaseLifecycle.allocate?.(androidLease(), { - flags: { providerApp: 'Example.apk' }, + initialApp: 'Example.apk', }); assert.deepEqual(state.androidCreateInputs[0], { @@ -180,7 +180,7 @@ test('public daemon requests cannot list or allocate uploaded apps', async () => await assert.rejects( async () => await runtime.leaseLifecycle.allocate?.(androidLease(), { - flags: { providerApp: 'Example.apk' }, + initialApp: 'Example.apk', publicNetworkOnly: true, }), (error) => error instanceof AppError && error.code === 'UNAUTHORIZED', @@ -204,7 +204,7 @@ test('allocation rejects an unrelated foreground app after preinstall', async () await assert.rejects( async () => await runtime.leaseLifecycle.allocate?.(androidLease(), { - flags: { providerApp: 'Example.apk' }, + initialApp: 'Example.apk', }), (error) => error instanceof AppError && error.code === 'COMMAND_FAILED', ); diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts index 6028608d3a..de83a71366 100644 --- a/packages/provider-limrun/src/session-allocation.ts +++ b/packages/provider-limrun/src/session-allocation.ts @@ -39,7 +39,7 @@ export async function resolveRequestedLimrunAppAsset( platform: 'android' | 'ios', context?: LeaseLifecycleContext, ): Promise { - const value = context?.flags?.providerApp; + const value = context?.initialApp; const name = typeof value === 'string' ? value.trim() : ''; if (!name) return undefined; assertLimrunUploadedAppAccess(context?.publicNetworkOnly); diff --git a/src/__tests__/cli-config-limrun.test.ts b/src/__tests__/cli-config-limrun.test.ts index 309a6c685d..3ba7488af0 100644 --- a/src/__tests__/cli-config-limrun.test.ts +++ b/src/__tests__/cli-config-limrun.test.ts @@ -49,11 +49,63 @@ test('Limrun apps lists uploaded assets without allocating an instance', async ( fs.rmSync(root, { recursive: true, force: true }); }); +test('Limrun state keeps explicit connection identity and excludes profile secrets', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'limrun.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + tenant: 'profile-tenant', + runId: 'profile-run', + session: 'profile-session', + leaseBackend: 'android-instance', + leaseProvider: 'limrun', + platform: 'android', + daemonAuthToken: 'daemon-secret', + metroBearerToken: 'metro-secret', + }), + 'utf8', + ); + + const result = await runCliCapture( + [ + 'apps', + '--remote-config', + remoteConfig, + '--state-dir', + stateDir, + '--tenant', + 'cli-tenant', + '--run-id', + 'cli-run', + '--session', + 'cli-session', + '--json', + ], + { + cwd: project, + env: { HOME: home }, + defaultResponse: { ok: true, data: { apps: [] } }, + }, + ); + + assert.equal(result.code, null); + const state = readActiveConnectionState({ stateDir }); + assert.equal(state?.tenant, 'cli-tenant'); + assert.equal(state?.runId, 'cli-run'); + assert.equal(state?.session, 'cli-session'); + assert.equal(Object.hasOwn(state ?? {}, 'daemonAuthToken'), false); + assert.equal(Object.hasOwn(state ?? {}, 'metroBearerToken'), false); + + 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'); + fs.writeFileSync(remoteConfig, JSON.stringify({ providerApp: 'Stale.app.zip' }), 'utf8'); const now = new Date().toISOString(); writeRemoteConnectionState({ stateDir, @@ -104,7 +156,7 @@ test('Limrun open allocates with the exact uploaded asset name', async () => { 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(result.calls[1]?.flags?.providerApp, undefined); assert.equal(readActiveConnectionState({ stateDir })?.leaseId, 'lease-limrun-open'); fs.rmSync(root, { recursive: true, force: true }); diff --git a/src/cli/commands/connection-presentation.ts b/src/cli/commands/connection-presentation.ts index ae983054de..f1a1c21a93 100644 --- a/src/cli/commands/connection-presentation.ts +++ b/src/cli/commands/connection-presentation.ts @@ -1,13 +1,6 @@ -import { - fingerprint, - remoteConnectionProviderOutput, - type RemoteConnectionState, -} from '../../remote/remote-connection-state.ts'; +import { fingerprint, type RemoteConnectionState } from '../../remote/remote-connection-state.ts'; import type { ConnectVerification } from '../connection/connect-provider-adapters.ts'; -import { - connectionProviderCapabilitiesForLease, - connectionProviderCapabilitiesForVerification, -} from '../connection/provider-policy.ts'; +import { connectionProviderCapabilities } from '../connection/provider-policy.ts'; import { shellQuoteIfNeeded } from '@agent-device/host-kit/command'; export type ConnectReadiness = ConnectVerification & { @@ -38,7 +31,7 @@ export function buildLeasePreparationNotice( verification?: ConnectVerification, ): LeasePreparationNotice | undefined { if (state.leaseId) return undefined; - const capabilities = connectionProviderCapabilitiesForLease(state); + const capabilities = connectionProviderCapabilities(state.leaseProvider); const leaseKind = capabilities.leaseKind; if (leaseKind === 'proxy') { return { @@ -148,7 +141,7 @@ export function serializeConnectionState(options: { leaseAllocated: Boolean(state.leaseId), leaseId: state.leaseId, leaseBackend: state.leaseBackend, - ...remoteConnectionProviderOutput(state), + leaseProvider: state.leaseProvider, platform: state.platform, target: state.target, remoteConfig: state.remoteConfigPath, @@ -237,7 +230,7 @@ function buildUnscopedConnectWorkflow( state: RemoteConnectionState, verification?: ConnectVerification, ): Pick { - const capabilities = connectionProviderCapabilitiesForLease(state); + const capabilities = connectionProviderCapabilities(state.leaseProvider); const leaseKind = capabilities.leaseKind; if (leaseKind === 'proxy') { return { @@ -250,7 +243,7 @@ function buildUnscopedConnectWorkflow( if (!verification && leaseKind === 'direct-device-provider') { return { nextSteps: defaultDirectProviderLifecycle() }; } - if (connectionProviderCapabilitiesForVerification(verification).supportsDeferredAppSelection) { + if (connectionProviderCapabilities(verification?.provider).supportsDeferredAppSelection) { return { nextSteps: ['agent-device apps', 'agent-device open '], }; @@ -271,7 +264,7 @@ function requiresInstall(verification?: ConnectVerification): boolean { } function supportsProviderArtifacts(verification?: ConnectVerification): boolean { - return connectionProviderCapabilitiesForVerification(verification).supportsArtifacts; + return connectionProviderCapabilities(verification?.provider).supportsArtifacts; } function missingAttachedAppRecovery(verification?: ConnectVerification): string[] { @@ -350,7 +343,7 @@ function appIdPlaceholder(platform: RemoteConnectionState['platform']): string { } function missingAppLabel(state: RemoteConnectionState): string { - const capabilities = connectionProviderCapabilitiesForLease(state); + const capabilities = connectionProviderCapabilities(state.leaseProvider); if (capabilities.requiresAppAttachment) return 'not attached'; if (capabilities.supportsDeferredAppSelection) { return 'not installed yet'; diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index c643edde45..eb6cc6e812 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -34,7 +34,7 @@ import type { AgentDeviceClient, Lease } from '../../agent-device-client.ts'; import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { readMetroPrepareKind } from '../../commands/metro/prepare-kind.ts'; -import { connectionProviderCapabilitiesForLease } from '../connection/provider-policy.ts'; +import { connectionProviderCapabilities } from '../connection/provider-policy.ts'; import { readCloudDeviceFeatureProfileFields } from '../connection/profile-fields.ts'; import type { PreviousLeaseReleaseNotice } from './connection-presentation.ts'; @@ -111,13 +111,12 @@ 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]; - } + const deferredAppSelection = connectionProviderCapabilities( + state.leaseProvider, + ).supportsDeferredAppSelection; + const initialApp = + deferredAppSelection && command === PUBLIC_COMMANDS.open ? options.positionals?.[0] : undefined; + if (deferredAppSelection) delete nextFlags.providerApp; let nextRuntime = selectCompatibleRuntime(state.runtime, nextFlags.platform) ?? options.runtime; let nextState = state; let changed = !existingState; @@ -131,6 +130,7 @@ export async function materializeRemoteConnectionForCommand(options: { state, nextState, nextFlags, + initialApp, policy: leasePolicy, }); nextState = materializedLease.state; @@ -289,6 +289,7 @@ async function materializeLeaseForCommand(options: { state: RemoteConnectionState; nextState: RemoteConnectionState; nextFlags: CliFlags; + initialApp?: string; policy: ConnectionLeasePolicy; }): Promise<{ state: RemoteConnectionState; @@ -320,6 +321,7 @@ async function materializeLeaseForCommand(options: { leaseBackend, policy, nextFlags, + options.initialApp, ); const lease = materializedLease.lease; nextFlags.leaseId = lease.leaseId; @@ -383,7 +385,7 @@ type ConnectionLeasePolicy = { }; function connectionLeasePolicyForState(state: RemoteConnectionState): ConnectionLeasePolicy { - const capabilities = connectionProviderCapabilitiesForLease(state); + const capabilities = connectionProviderCapabilities(state.leaseProvider); if (capabilities.leaseKind === 'proxy') { return PROXY_CONNECTION_LEASE_POLICY; } @@ -775,7 +777,7 @@ function createRemoteConnectionStateFromFlags( } if ( !flags.daemonBaseUrl && - connectionProviderCapabilitiesForLease(profile).requiresRemoteDaemon + connectionProviderCapabilities(profile.leaseProvider).requiresRemoteDaemon ) { throw new AppError( 'INVALID_ARGS', @@ -793,7 +795,9 @@ function createRemoteConnectionStateFromFlags( runId: flags.runId, leaseId: flags.leaseId, leaseBackend: flags.leaseBackend ?? resolveRequestedLeaseBackend(flags), - ...profile, + leaseProvider: profile.leaseProvider, + clientId: profile.clientId, + deviceKey: profile.deviceKey, platform: flags.platform, target: flags.target, connectedAt: now, @@ -807,6 +811,7 @@ async function allocateOrReuseLease( leaseBackend: LeaseBackend, policy: ConnectionLeasePolicy, flags: CliFlags, + initialApp?: string, ): Promise<{ lease: Lease; acquired: boolean }> { const connection = buildRemoteConnectionRequestMetadata(state); if (state.leaseId && state.leaseBackend === leaseBackend) { @@ -830,7 +835,7 @@ async function allocateOrReuseLease( device: flags.device, udid: flags.udid, serial: flags.serial, - providerApp: flags.providerApp, + providerApp: initialApp ?? flags.providerApp, providerOsVersion: flags.providerOsVersion, providerProject: flags.providerProject, providerBuild: flags.providerBuild, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 67c275043c..b6f9c55983 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -18,7 +18,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { connectProviderNamesForError, - connectionProviderCapabilitiesForLease, + connectionProviderCapabilities, isConnectProviderName, type ConnectProvider, } from '../connection/provider-policy.ts'; @@ -26,7 +26,6 @@ import { resolveConnectProviderProfile, verifyResolvedConnectProvider, } from '../connection/connect-provider-adapters.ts'; -import { providerSessionResult } from '../connection/provider-session-result.ts'; import { hasDeferredMetroConfig, releaseRemoteConnectionLease, @@ -126,7 +125,7 @@ function readRequiredConnectScope( } if ( !flags.daemonBaseUrl && - connectionProviderCapabilitiesForLease(connectionMetadata ?? {}).requiresRemoteDaemon + connectionProviderCapabilities(connectionMetadata?.leaseProvider).requiresRemoteDaemon ) { throw new AppError( 'INVALID_ARGS', @@ -255,9 +254,9 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) let providerData: CloudProviderSessionResult | undefined; if (state.leaseId || state.runtime || state.metro) { try { - providerData = providerSessionResult( - await client.sessions.close({ session: connectedSession, shutdown: flags.shutdown }), - ); + providerData = ( + await client.sessions.close({ session: connectedSession, shutdown: flags.shutdown }) + ).provider; } catch { // Disconnect is idempotent; the session may already be closed. } @@ -269,7 +268,7 @@ export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) try { const release = await releaseRemoteConnectionLease(client, state, flags.daemonAuthToken); released = release.released; - providerData ??= providerSessionResult(release); + providerData ??= release.provider; } catch { // Bridges may release on close or be unreachable; local state still needs cleanup. } diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 68c1dbcc48..149ebd1be3 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -6,7 +6,7 @@ import { import { AppError } from '@agent-device/kernel/errors'; import { isRemoteBridgeBackend } from './remote-bridge.ts'; import type { CliFlags } from '@agent-device/contracts/command'; -import { connectionProviderCapabilitiesForLease } from '../connection/provider-policy.ts'; +import { connectionProviderCapabilities } from '../connection/provider-policy.ts'; const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; @@ -169,7 +169,7 @@ function shouldConfigureDirectReverse( const { flags } = options; if (!flags) return false; return ( - connectionProviderCapabilitiesForLease(flags).supportsDirectPortReverse && + connectionProviderCapabilities(flags.leaseProvider).supportsDirectPortReverse && flags.leaseBackend === 'android-instance' && flags.metroProxyBaseUrl === undefined && options.configureDirectPortReverse !== undefined diff --git a/src/cli/connection/provider-policy.test.ts b/src/cli/connection/provider-policy.test.ts index 508d5311a1..d537e91c6a 100644 --- a/src/cli/connection/provider-policy.test.ts +++ b/src/cli/connection/provider-policy.test.ts @@ -1,12 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { - connectionProviderCapabilitiesForLease, - connectionProviderCapabilitiesForVerification, -} from './provider-policy.ts'; +import { connectionProviderCapabilities } from './provider-policy.ts'; -test('provider carriers project provider identity into semantic capabilities', () => { - assert.deepEqual(connectionProviderCapabilitiesForLease({ leaseProvider: 'limrun' }), { +test('provider policy projects provider identity into semantic capabilities', () => { + assert.deepEqual(connectionProviderCapabilities('limrun'), { leaseKind: 'direct-device-provider', requiresAppAttachment: false, requiresRemoteDaemon: false, @@ -15,18 +12,9 @@ test('provider carriers project provider identity into semantic capabilities', ( supportsDirectPortReverse: true, usesCloudWebDriverLease: false, }); - const browserStack = connectionProviderCapabilitiesForVerification({ - provider: 'browserstack', - }); + const browserStack = connectionProviderCapabilities('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', - ); + assert.equal(connectionProviderCapabilities('aws-device-farm').requiresAppAttachment, true); + assert.equal(connectionProviderCapabilities('proxy').leaseKind, 'proxy'); }); diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 9ca1cdcd54..f60077028f 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -7,7 +7,7 @@ import { export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; -type ConnectionProviderCapabilities = { +export type ConnectionProviderCapabilities = { leaseKind: 'proxy' | 'direct-device-provider' | 'remote-provider'; requiresAppAttachment: boolean; requiresRemoteDaemon: boolean; @@ -37,19 +37,7 @@ export function connectProviderNamesForError(): string { ].join(', '); } -export function connectionProviderCapabilitiesForLease(source: { - leaseProvider?: string; -}): ConnectionProviderCapabilities { - return connectionProviderCapabilities(source.leaseProvider); -} - -export function connectionProviderCapabilitiesForVerification( - verification: { provider?: string } | undefined, -): ConnectionProviderCapabilities { - return connectionProviderCapabilities(verification?.provider); -} - -function connectionProviderCapabilities( +export function connectionProviderCapabilities( provider: string | undefined, ): ConnectionProviderCapabilities { const directDeviceProvider = isDirectDeviceConnectProvider(provider); diff --git a/src/cli/connection/provider-session-result.ts b/src/cli/connection/provider-session-result.ts deleted file mode 100644 index a4df9ac954..0000000000 --- a/src/cli/connection/provider-session-result.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { CloudProviderSessionResult } from '@agent-device/contracts/observability'; - -export function providerSessionResult(response: { - provider?: CloudProviderSessionResult; -}): CloudProviderSessionResult | undefined { - return response.provider; -} diff --git a/src/daemon/__tests__/http-server-limrun-uploaded-apps.test.ts b/src/daemon/__tests__/http-server-limrun-uploaded-apps.test.ts new file mode 100644 index 0000000000..44586b3520 --- /dev/null +++ b/src/daemon/__tests__/http-server-limrun-uploaded-apps.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { test, vi } from 'vitest'; +import { createLimrunRuntime } from '@agent-device/provider-limrun'; +import { createProviderDeviceRuntimeRequestProviders } from '../../provider-device-runtime.ts'; +import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { limrunTestDependencies } from '../../platform-runtime-gateway.fixtures.ts'; +import { + DAEMON_HTTP_NETWORK_ACCESS_HEADER, + DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, +} from '../http-contract.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { createDaemonHttpServer } from '../server/http-server.ts'; +import { createRequestHandler } from './test-device-runtime-gateway.ts'; + +const limrunIo = vi.hoisted(() => ({ + listAssets: vi.fn(), + createAndroidInstance: vi.fn(), +})); + +type RpcResponse = { error?: { data?: { code?: string } } }; + +vi.mock('@limrun/api', () => ({ + default: class MockLimrun { + readonly assets = { list: limrunIo.listAssets }; + readonly androidInstances = { + create: limrunIo.createAndroidInstance, + delete: vi.fn(), + list: vi.fn(), + }; + readonly iosInstances = { create: vi.fn(), delete: vi.fn(), list: vi.fn() }; + }, +})); + +test('public HTTP rejects Limrun uploaded-app listing and allocation before provider I/O', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + limrunIo.listAssets.mockClear(); + limrunIo.createAndroidInstance.mockClear(); + const runtime = createLimrunRuntime( + { apiKey: 'lim_test_key', runtimeInstance: 'http-access-test' }, + limrunTestDependencies, + ); + const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + const token = 'limrun-http-test-token'; + const leaseRegistry = new LeaseRegistry(); + const handleRequest = createRequestHandler({ + logPath: path.join(os.tmpdir(), 'agent-device-limrun-http-access.log'), + token, + sessionStore: makeSessionStore('agent-device-limrun-http-access-'), + leaseRegistry, + deviceInventoryGateways: createTestDeviceInventoryGateways(), + providerRuntimeIds: providers.providerRuntimeIds, + providerRuntimeRequiredIds: providers.providerRuntimeRequiredIds, + leaseLifecycleProvider: providers.leaseLifecycleProvider, + providerAppCatalog: providers.providerAppCatalog, + providerDeviceRuntimeScope: providers.providerDeviceRuntimeScope, + trackDownloadableArtifact: () => 'unused-artifact', + }); + const server = await createDaemonHttpServer({ token, leaseRegistry, handleRequest }); + + try { + const port = await listenOnLoopback(server); + const apps = await callRpc(port, token, { + jsonrpc: '2.0', + id: 'limrun-public-apps', + method: 'agent_device.command', + params: { + command: 'apps', + positionals: [], + flags: { platform: 'android', leaseProvider: 'limrun' }, + }, + }); + const allocation = await callRpc(port, token, { + jsonrpc: '2.0', + id: 'limrun-public-allocation', + method: 'agent_device.lease.allocate', + params: { + tenantId: 'tenant-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + providerApp: 'Example.apk', + }, + }); + + assert.equal(apps.error?.data?.code, 'UNAUTHORIZED'); + assert.equal(allocation.error?.data?.code, 'UNAUTHORIZED'); + assert.equal(limrunIo.listAssets.mock.calls.length, 0); + assert.equal(limrunIo.createAndroidInstance.mock.calls.length, 0); + } finally { + await closeLoopbackServer(server); + await runtime.shutdown(); + } +}); + +async function callRpc( + port: number, + token: string, + payload: Record, +): Promise { + const response = await fetch(`http://127.0.0.1:${port}/rpc`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + [DAEMON_HTTP_NETWORK_ACCESS_HEADER]: DAEMON_HTTP_PUBLIC_NETWORK_ACCESS, + }, + body: JSON.stringify(payload), + }); + return (await response.json()) as RpcResponse; +} diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 0f3e40c6ed..98ef6af80b 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -259,7 +259,12 @@ test('lease allocation hands the provider the request-bound signal and a deadlin const registration = registerRequestAbort(requestId); const before = Date.now(); let observed: - | { signal?: AbortSignal; deadline?: number; publicNetworkOnly?: boolean } + | { + signal?: AbortSignal; + deadline?: number; + publicNetworkOnly?: boolean; + initialApp?: string; + } | undefined; try { @@ -275,6 +280,7 @@ test('lease allocation hands the provider the request-bound signal and a deadlin leaseBackend: 'android-instance', leaseProvider: 'fake-provider', }, + flags: { providerApp: 'Example.apk' }, internal: { publicNetworkOnly: true }, positionals: [], }, @@ -287,6 +293,7 @@ test('lease allocation hands the provider the request-bound signal and a deadlin signal: context?.signal, deadline: context?.deadline, publicNetworkOnly: context?.publicNetworkOnly, + initialApp: context?.initialApp, }; return { provider: 'fake-provider' }; }, @@ -296,6 +303,7 @@ test('lease allocation hands the provider the request-bound signal and a deadlin assert.equal(response?.ok, true); assert.equal(observed?.signal?.aborted, false); assert.equal(observed?.publicNetworkOnly, true); + assert.equal(observed?.initialApp, 'Example.apk'); markRequestCanceled(requestId); assert.equal(observed?.signal?.aborted, true, 'the provider signal must track this request'); assert.ok( diff --git a/src/daemon/client/daemon-client-rpc.test.ts b/src/daemon/client/daemon-client-rpc.test.ts index 9445546cfb..c4f6ed3f5f 100644 --- a/src/daemon/client/daemon-client-rpc.test.ts +++ b/src/daemon/client/daemon-client-rpc.test.ts @@ -1,7 +1,37 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { handleDaemonHttpResponseBody } from './daemon-client-rpc.ts'; +import { buildHttpRpcPayload, handleDaemonHttpResponseBody } from './daemon-client-rpc.ts'; + +test('lease allocation transports an optional initial provider app', () => { + const payload = buildHttpRpcPayload( + { + token: 'daemon-token', + session: 'qa-android', + command: 'lease_allocate', + positionals: [], + flags: { providerApp: 'Example.apk' }, + meta: { + requestId: 'lease-req', + tenantId: 'acme', + runId: 'run-123', + leaseBackend: 'android-instance', + leaseTtlMs: 30_000, + }, + }, + { includeTokenParam: false }, + ); + + assert.equal(payload.method, 'agent_device.lease.allocate'); + assert.deepEqual(payload.params, { + session: 'qa-android', + tenantId: 'acme', + runId: 'run-123', + ttlMs: 30_000, + backend: 'android-instance', + providerApp: 'Example.apk', + }); +}); test('HTTP RPC errors sanitize an untrusted cause before rehydration', () => { const secret = 'adc_agent_remote-secret'; diff --git a/src/daemon/client/daemon-client-rpc.ts b/src/daemon/client/daemon-client-rpc.ts index 3ccc05adbe..fe0a23297a 100644 --- a/src/daemon/client/daemon-client-rpc.ts +++ b/src/daemon/client/daemon-client-rpc.ts @@ -181,7 +181,12 @@ export function buildHttpRpcPayload( jsonrpc: '2.0', id, method: leaseRpcMethodForCommand(req.command), - params: buildLeaseRpcParams(req, req.command, options), + params: { + ...buildLeaseRpcParams(req, req.command, options), + ...(req.command === 'lease_allocate' && typeof req.flags?.providerApp === 'string' + ? { providerApp: req.flags.providerApp } + : {}), + }, }; } diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 359e5514d5..d1ebaca284 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -230,6 +230,7 @@ function canceledAllocationHint( function leaseLifecycleContext(req: DaemonRequest): LeaseLifecycleContext { return { flags: req.flags, + ...(typeof req.flags?.providerApp === 'string' ? { initialApp: req.flags.providerApp } : {}), cwd: typeof req.meta?.cwd === 'string' ? req.meta.cwd : undefined, ...(req.internal?.publicNetworkOnly ? { publicNetworkOnly: true } : {}), }; diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index f023417c6e..c49e84ebbf 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -313,6 +313,10 @@ function toLeaseDaemonRequest( session: readStringParam(params, 'session') ?? 'default', command, positionals: [], + flags: + command === 'lease_allocate' + ? { providerApp: readStringParam(params, 'providerApp') } + : undefined, meta: { tenantId: readStringParam(params, 'tenantId') ?? readStringParam(params, 'tenant'), runId: readStringParam(params, 'runId'), diff --git a/src/remote/remote-connection-state.ts b/src/remote/remote-connection-state.ts index f39e4a78ef..7ed28766dc 100644 --- a/src/remote/remote-connection-state.ts +++ b/src/remote/remote-connection-state.ts @@ -186,12 +186,6 @@ export function mergeRemoteConnectionRequestMetadata( }; } -export function remoteConnectionProviderOutput( - state: RemoteConnectionState, -): Pick { - return { leaseProvider: state.leaseProvider }; -} - export function remoteConnectionLeaseIdentityMatches( state: RemoteConnectionState, metadata: RemoteConnectionRequestMetadata | undefined, diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 3ae289cba8..7667d5a130 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -44,7 +44,7 @@ "src/daemon/client/daemon-client-progress.ts#createInvalidDaemonResponseError": "sha256:0af812346b667a23fdfef641b242cd86d07edaa05eb928c4c531628c09a4031b", "src/daemon/client/daemon-client-progress.ts#shouldReadDaemonProgressStream": "sha256:6811404f41d8db3fa6fc751332c7186a2399ec7ecbc4e9c547d9cb9f14dbb350", "src/daemon/client/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", - "src/daemon/client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:c12b5945e89defe3ede7d699a5caa9f006e1bb2d47ce9adbc7e7dac5670ab5c0", + "src/daemon/client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:efa9a5da4c7288cceae5656b468946ad3a2af0f0f0fb940f8a37987f922aa4b8", "src/daemon/client/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:1755f46be8c62e7a8eb409e9a94d61781c96ccbef3306702121aa94264d63267", "src/daemon/client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:8f1c4bba1918545db6296ad29b95f0baa9f949e7c23bc04d59e261130f14611e", "src/daemon/client/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", @@ -115,7 +115,7 @@ "src/daemon/server/http-server.ts#sendJson": "sha256:a1de00da3f1db8c98e69a85dfe2e11b626a4d2ce472840e3b8f519aa8a207a8f", "src/daemon/server/http-server.ts#toDaemonRequest": "sha256:f3861d2d4f67f66e83800616926f6bbb0595de4ecbb0072b5b78971ebeb0705b", "src/daemon/server/http-server.ts#toInstallFromSourceDaemonRequest": "sha256:dff093758b433043816dfc1e2e9658078875e46b887fdc530fb9380158d629f5", - "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:48d632821a0721e408b4444f41c892b624e7659711780729edd525ffb3e4ab1b", + "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:719b5b8c29a95e9d2be01850bb97c8120f5a04aecc09c265bebfc6631e41ffa3", "src/daemon/server/http-server.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:708736ca03d6a3fc449bb408382dbd509e490de1f84ecc8771fd77d4adda0154", "src/daemon/server/http-server.ts#writeProgressEnvelope": "sha256:ca661f6dd2de6e517c520dbd1e20b59b24b58d925157ed06123149ed2bc0fa3b", "src/daemon/server/http-server.ts#writeRpcResponseEnvelope": "sha256:7a8155e9fcbb53485489728250a85109b5dab0d96cdd4aa8b7e7eb87a4898ddb", @@ -173,6 +173,16 @@ "src/remote/upload-stream.ts#streamFileToHttpRequestAttempt": "sha256:da39a79fa7c1f81e55caf613eedc347c0f3a9a9711b265a4db185677532d9552" }, "compatibleChanges": [ + { + "declaration": "src/daemon/client/daemon-client-rpc.ts#buildHttpRpcPayload", + "digest": "sha256:efa9a5da4c7288cceae5656b468946ad3a2af0f0f0fb940f8a37987f922aa4b8", + "rationale": "#2110 adds the optional providerApp parameter to lease allocation. A released protocol-2 daemon ignores unknown JSON-RPC parameters, while requests without an initial app retain the previous payload exactly." + }, + { + "declaration": "src/daemon/server/http-server.ts#toLeaseDaemonRequest", + "digest": "sha256:719b5b8c29a95e9d2be01850bb97c8120f5a04aecc09c265bebfc6631e41ffa3", + "rationale": "#2110 reads the optional providerApp lease-allocation parameter into the existing flags bag. Released protocol-2 clients omit it and follow the unchanged allocation path; no existing field is required or reinterpreted." + }, { "declaration": "src/daemon/server/http-server.ts#authorizeAuxiliaryHttpRequest", "digest": "sha256:f74f2fc70313a4cc048bcd531f9831970cab75c2b098688e846c44b3c37a7185",