diff --git a/packages/provider-limrun/src/android.ts b/packages/provider-limrun/src/android.ts index f9f8bac8a8..14c785e58d 100644 --- a/packages/provider-limrun/src/android.ts +++ b/packages/provider-limrun/src/android.ts @@ -21,6 +21,7 @@ import type { LimrunRuntimeDependencies, } from './runtime-dependencies.ts'; import type { AndroidAdbInvocation } from '@agent-device/platform-android/mechanics'; +import type { LimrunInstanceOwnership } from './instance-access.ts'; import { normalizeOptionalString } from './strings.ts'; import { awaitLimrunDeploymentOperation, @@ -33,6 +34,7 @@ type LimrunAndroidAdbSession = { platform: 'android'; lease: DeviceLease; instanceId: string; + readonly ownership: LimrunInstanceOwnership; device: DeviceInfo; client: LimrunAndroidClient; /** Instance bearer token; the recording download the SDK would run inline is done by the host instead. */ @@ -51,6 +53,7 @@ export async function createLimrunAndroidSession( options: { lease: DeviceLease; instanceId: string; + ownership: LimrunInstanceOwnership; device: DeviceInfo; apiUrl: string; adbUrl: string; @@ -68,6 +71,7 @@ export async function createLimrunAndroidSession( platform: 'android', lease: options.lease, instanceId: options.instanceId, + ownership: options.ownership, device: options.device, client, token: options.token, @@ -165,9 +169,10 @@ async function cleanupAndroidPortReverse(session: LimrunAndroidSession): Promise const mappings = await reverse.list().catch(() => []); const owners = new Set(); const unownedLocals: LimrunPortReverseEndpoint[] = []; + const sharedDevice = session.ownership === 'attached'; for (const mapping of mappings) { if (mapping.ownerId) owners.add(mapping.ownerId); - else unownedLocals.push(mapping.local); + else if (!sharedDevice) unownedLocals.push(mapping.local); } await Promise.allSettled([ ...[...owners].map(async (ownerId) => await reverse.removeAllOwned(ownerId)), diff --git a/packages/provider-limrun/src/app-log-descriptor.test.ts b/packages/provider-limrun/src/app-log-descriptor.test.ts new file mode 100644 index 0000000000..007deea7a5 --- /dev/null +++ b/packages/provider-limrun/src/app-log-descriptor.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest'; +import { limrunAppLogDescriptorCodec } from './app-log-descriptor.ts'; + +const body = { + transport: 'limrun-log-poller', + platform: 'ios', + leaseId: 'lease-a', + instanceId: 'ios_instance', + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', +}; + +test('decodes descriptors written before ownership as created instances', () => { + expect(limrunAppLogDescriptorCodec.decode(body)).toMatchObject({ + status: 'decoded', + descriptor: { ownership: 'created' }, + }); + expect(limrunAppLogDescriptorCodec.decode({ ...body, ownership: 'attached' })).toMatchObject({ + status: 'decoded', + descriptor: { ownership: 'attached' }, + }); + expect(limrunAppLogDescriptorCodec.decode({ ...body, ownership: 'borrowed' }).status).toBe( + 'invalid', + ); +}); diff --git a/packages/provider-limrun/src/app-log-descriptor.ts b/packages/provider-limrun/src/app-log-descriptor.ts index de867769ba..1bba2b07a5 100644 --- a/packages/provider-limrun/src/app-log-descriptor.ts +++ b/packages/provider-limrun/src/app-log-descriptor.ts @@ -6,12 +6,14 @@ import type { import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; import { APP_LOG_RESOURCE_KIND } from '@agent-device/contracts/app-log-runtime'; import { createDurableResourceEnvelope, encodeDurableDescriptor } from '@agent-device/capture-kit'; +import type { LimrunInstanceOwnership } from './instance-access.ts'; export type LimrunAppLogDescriptor = Readonly<{ transport: 'limrun-log-poller'; platform: 'ios' | 'android'; leaseId: string; instanceId: string; + ownership: LimrunInstanceOwnership; appBundleId: string; outputPath: string; }>; @@ -24,7 +26,9 @@ export const limrunAppLogDescriptorCodec: DurableDescriptorCodec< version: 1, encode: (descriptor) => ({ ...descriptor }), decode: (body) => { + const ownership = decodeOwnership(body.ownership); if ( + !ownership || body.transport !== 'limrun-log-poller' || (body.platform !== 'ios' && body.platform !== 'android') || !isNonEmptyString(body.leaseId) || @@ -41,6 +45,7 @@ export const limrunAppLogDescriptorCodec: DurableDescriptorCodec< platform: body.platform, leaseId: body.leaseId, instanceId: body.instanceId, + ownership, appBundleId: body.appBundleId, outputPath: body.outputPath, }), @@ -78,6 +83,12 @@ export function createLimrunAppLogEnvelope(input: { }); } +/** Descriptors written before attach support carry no ownership; those named created instances. */ +function decodeOwnership(value: unknown): LimrunInstanceOwnership | undefined { + if (value === undefined) return 'created'; + return value === 'created' || value === 'attached' ? value : undefined; +} + function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } diff --git a/packages/provider-limrun/src/app-log-poller.test.ts b/packages/provider-limrun/src/app-log-poller.test.ts index 5e87015eff..b2840d6f22 100644 --- a/packages/provider-limrun/src/app-log-poller.test.ts +++ b/packages/provider-limrun/src/app-log-poller.test.ts @@ -12,6 +12,7 @@ describe('Limrun app-log poller', () => { platform: 'ios', leaseId: 'lease-1', instanceId: 'instance-1', + ownership: 'created', readLogs: vi.fn(async () => 'old line\nshared\nnew line\n'), [Symbol.asyncDispose]: async () => { readerDisposed = true; @@ -50,6 +51,7 @@ describe('Limrun app-log poller', () => { platform: 'ios', leaseId: 'lease-1', instanceId: 'instance-1', + ownership: 'created', readLogs: async () => '', [Symbol.asyncDispose]: dispose, }; @@ -78,6 +80,7 @@ describe('Limrun app-log poller', () => { platform: 'ios', leaseId: 'lease-1', instanceId: 'instance-1', + ownership: 'created', readLogs: async () => `${'b'.repeat(240_000)}\n`, [Symbol.asyncDispose]: async () => {}, }; @@ -100,6 +103,7 @@ describe('Limrun app-log poller', () => { platform: 'ios', leaseId: 'lease-1', instanceId: 'instance-1', + ownership: 'created', readLogs: async () => '', [Symbol.asyncDispose]: async () => { throw new Error('reader cleanup failed'); @@ -132,6 +136,7 @@ describe('Limrun app-log poller', () => { platform: 'android', leaseId: 'lease-1', instanceId: 'instance-1', + ownership: 'created', readLogs: vi.fn(async () => await new Promise(() => {})), [Symbol.asyncDispose]: async () => { readerDisposed = true; diff --git a/packages/provider-limrun/src/app-log-poller.ts b/packages/provider-limrun/src/app-log-poller.ts index 5a4f9a9257..a1186848a1 100644 --- a/packages/provider-limrun/src/app-log-poller.ts +++ b/packages/provider-limrun/src/app-log-poller.ts @@ -9,12 +9,14 @@ import { AsyncCleanupStack } from '@agent-device/contracts/async-lifecycle'; import { createAppLogLiveHandleFromFinish } from '@agent-device/capture-kit'; import type { LogBackend } from '@agent-device/contracts/observability'; import { awaitLimrunOperation } from './request-cancellation.ts'; +import type { LimrunInstanceOwnership } from './instance-access.ts'; export type LimrunAppLogReader = AsyncDisposable & Readonly<{ platform: 'ios' | 'android'; leaseId: string; instanceId: string; + ownership: LimrunInstanceOwnership; readLogs(appBundleId: string, lineLimit: number): Promise; }>; diff --git a/packages/provider-limrun/src/app-log-reconnect.test.ts b/packages/provider-limrun/src/app-log-reconnect.test.ts index 1c263e5119..70df0b5353 100644 --- a/packages/provider-limrun/src/app-log-reconnect.test.ts +++ b/packages/provider-limrun/src/app-log-reconnect.test.ts @@ -31,6 +31,8 @@ import { type AndroidAdbInvocation, } from '@agent-device/platform-android/mechanics'; import { reconnectLimrunAppLogReader } from './app-log-reconnect.ts'; +import { attachedLimrunInstanceId } from './device.ts'; +import { createInstanceClient as createIosInstanceClient } from '@limrun/api/ios-client'; import type { LimrunAdbCommandOptions, LimrunAdbExecutor, @@ -54,6 +56,7 @@ test('reattaches an owned Limrun instance without persisting credentials', async platform: 'ios', leaseId: 'lease-a', instanceId: 'instance-a', + ownership: 'created', appBundleId: 'com.example.app', outputPath: '/sessions/one/app.log', }, @@ -83,6 +86,7 @@ test('fails closed when the instance labels do not match the descriptor lease', platform: 'ios', leaseId: 'lease-a', instanceId: 'instance-a', + ownership: 'created', appBundleId: 'com.example.app', outputPath: '/sessions/one/app.log', }, @@ -113,6 +117,7 @@ test('disconnects the Android instance client when tunnel acquisition fails', as platform: 'android', leaseId: 'lease-a', instanceId: 'instance-a', + ownership: 'created', appBundleId: 'com.example.app', outputPath: '/sessions/one/app.log', }, @@ -154,6 +159,7 @@ test('addresses app-log adb traffic at the tunnel serial and hands cleanup a com platform: 'android', leaseId: 'lease-a', instanceId: 'instance-a', + ownership: 'created', appBundleId: 'com.example.app', outputPath: '/sessions/one/app.log', }, @@ -209,3 +215,70 @@ test('addresses app-log adb traffic at the tunnel serial and hands cleanup a com expect(closeTunnel).toHaveBeenCalledOnce(); expect(androidClient.disconnect).toHaveBeenCalledOnce(); }); + +const ATTACHED_ACCESS = { apiUrl: 'https://attached.example/api', token: 'instance-token' }; + +function attachedDescriptor( + instanceId = attachedLimrunInstanceId(ATTACHED_ACCESS.apiUrl), + ownership: 'created' | 'attached' = 'attached', +) { + return { + transport: 'limrun-log-poller', + platform: 'ios', + leaseId: 'lease-a', + instanceId, + ownership, + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + } as const; +} + +test('reattaches app logs to the attached instance the descriptor names', async () => { + const outcome = await reconnectLimrunAppLogReader({ + instances: { ios: ATTACHED_ACCESS }, + descriptor: attachedDescriptor(), + dependencies: {} as LimrunRuntimeDependencies, + }); + expect(vi.mocked(createIosInstanceClient)).toHaveBeenLastCalledWith({ + ...ATTACHED_ACCESS, + logLevel: 'warn', + }); + expect(outcome.status).toBe('opened'); + + const replaced = await reconnectLimrunAppLogReader({ + instances: { ios: { ...ATTACHED_ACCESS, apiUrl: 'https://another-instance.example/api' } }, + descriptor: attachedDescriptor(), + dependencies: {} as LimrunRuntimeDependencies, + }); + expect(replaced).toEqual({ status: 'ownership-lost' }); +}); + +test('reattaches a created instance through the organization API while another is attached', async () => { + const get = vi.fn(async () => ({ + metadata: { labels: { provider: 'limrun', leaseId: 'lease-a' } }, + status: { state: 'ready', apiUrl: 'https://created.example/api', token: 'created-token' }, + })); + const outcome = await reconnectLimrunAppLogReader({ + limrun: { iosInstances: { get } } as never, + instances: { ios: ATTACHED_ACCESS }, + descriptor: attachedDescriptor('ios_created_instance', 'created'), + dependencies: {} as LimrunRuntimeDependencies, + }); + expect(get).toHaveBeenCalledOnce(); + expect(outcome.status).toBe('opened'); +}); + +test('does not connect once the reconnect request is aborted', async () => { + const controller = new AbortController(); + controller.abort(); + vi.mocked(createIosInstanceClient).mockClear(); + await expect( + reconnectLimrunAppLogReader({ + instances: { ios: ATTACHED_ACCESS }, + descriptor: attachedDescriptor(), + dependencies: {} as LimrunRuntimeDependencies, + signal: controller.signal, + }), + ).rejects.toThrow(); + expect(vi.mocked(createIosInstanceClient)).not.toHaveBeenCalled(); +}); diff --git a/packages/provider-limrun/src/app-log-reconnect.ts b/packages/provider-limrun/src/app-log-reconnect.ts index e37e4d3a66..f01a86855a 100644 --- a/packages/provider-limrun/src/app-log-reconnect.ts +++ b/packages/provider-limrun/src/app-log-reconnect.ts @@ -6,24 +6,63 @@ import { AsyncCleanupStack } from '@agent-device/contracts/async-lifecycle'; import type { LimrunAppLogDescriptor } from './app-log-descriptor.ts'; import type { LimrunAppLogReader } from './app-log-poller.ts'; import type { LimrunAppLogReconnectOutcome } from './app-log-runtime.ts'; +import { requireLimrunOrgClient } from './client-options.ts'; +import { attachedLimrunInstanceId } from './device.ts'; +import type { + LimrunAndroidInstanceAccess, + LimrunInstanceAccess, + LimrunIosInstanceAccess, +} from './instance-access.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; export async function reconnectLimrunAppLogReader(options: { - limrun: Limrun; + limrun?: Limrun; + instances?: LimrunInstanceAccess; descriptor: LimrunAppLogDescriptor; dependencies: LimrunRuntimeDependencies; signal?: AbortSignal; }): Promise { + options.signal?.throwIfAborted(); + if (options.descriptor.ownership === 'attached') { + return await reconnectAttached( + options.instances ?? {}, + options.descriptor, + options.dependencies, + ); + } + const limrun = requireLimrunOrgClient(options.limrun, 'Reconnecting app logs'); try { return options.descriptor.platform === 'ios' - ? await reconnectIos(options) - : await reconnectAndroid(options); + ? await reconnectIos({ ...options, limrun }) + : await reconnectAndroid({ ...options, limrun }); } catch (error) { if (error instanceof NotFoundError) return { status: 'missing' }; throw error; } } +async function reconnectAttached( + instances: LimrunInstanceAccess, + descriptor: LimrunAppLogDescriptor, + dependencies: LimrunRuntimeDependencies, +): Promise { + if (descriptor.platform === 'ios') { + return isDescribedInstance(instances.ios, descriptor) + ? await openIosReader(instances.ios, descriptor) + : { status: 'ownership-lost' }; + } + return isDescribedInstance(instances.android, descriptor) + ? await openAndroidReader(instances.android, descriptor, dependencies) + : { status: 'ownership-lost' }; +} + +function isDescribedInstance( + access: Access | undefined, + descriptor: LimrunAppLogDescriptor, +): access is Access { + return access !== undefined && attachedLimrunInstanceId(access.apiUrl) === descriptor.instanceId; +} + async function reconnectIos(options: { limrun: Limrun; descriptor: LimrunAppLogDescriptor; @@ -39,16 +78,26 @@ async function reconnectIos(options: { options.descriptor, ); if (selected.status !== 'active') return selected; - const { instance } = selected; + return await openIosReader( + { apiUrl: selected.apiUrl, token: selected.instance.status.token }, + options.descriptor, + ); +} + +async function openIosReader( + access: LimrunIosInstanceAccess, + descriptor: LimrunAppLogDescriptor, +): Promise { const client = await createIosInstanceClient({ - apiUrl: selected.apiUrl, - token: instance.status.token, + apiUrl: access.apiUrl, + token: access.token, logLevel: 'warn', }); const reader: LimrunAppLogReader = { platform: 'ios', - leaseId: options.descriptor.leaseId, - instanceId: options.descriptor.instanceId, + leaseId: descriptor.leaseId, + instanceId: descriptor.instanceId, + ownership: descriptor.ownership, readLogs: async (appBundleId, lineLimit) => await client.appLogTail(appBundleId, lineLimit), [Symbol.asyncDispose]: async () => client.disconnect(), }; @@ -73,10 +122,26 @@ async function reconnectAndroid(options: { if (selected.status !== 'active') return selected; const { instance } = selected; if (!instance.status.adbWebSocketUrl) return { status: 'missing' }; + return await openAndroidReader( + { + apiUrl: selected.apiUrl, + adbUrl: instance.status.adbWebSocketUrl, + token: instance.status.token, + }, + options.descriptor, + options.dependencies, + ); +} + +async function openAndroidReader( + access: LimrunAndroidInstanceAccess, + descriptor: LimrunAppLogDescriptor, + dependencies: LimrunRuntimeDependencies, +): Promise { const client = await createAndroidInstanceClient({ - apiUrl: selected.apiUrl, - adbUrl: instance.status.adbWebSocketUrl, - token: instance.status.token, + apiUrl: access.apiUrl, + adbUrl: access.adbUrl, + token: access.token, logLevel: 'warn', }); const rollback = new AsyncCleanupStack(); @@ -94,19 +159,20 @@ async function reconnectAndroid(options: { args: readonly string[], commandOptions?: Parameters[1], ) => - await options.dependencies.host.runAdb( - options.dependencies.android.deviceAdbInvocation(serial, args), + await dependencies.host.runAdb( + dependencies.android.deviceAdbInvocation(serial, args), commandOptions, ); const reader: LimrunAppLogReader = { platform: 'android', - leaseId: options.descriptor.leaseId, - instanceId: options.descriptor.instanceId, + leaseId: descriptor.leaseId, + instanceId: descriptor.instanceId, + ownership: descriptor.ownership, readLogs: async (_appBundleId, lineLimit) => - await options.dependencies.android.readLogs(adb, lineLimit), + await dependencies.android.readLogs(adb, lineLimit), [Symbol.asyncDispose]: async () => { - await options.dependencies.host - .runAdb(options.dependencies.android.hostAdbInvocation(['disconnect', serial]), { + await dependencies.host + .runAdb(dependencies.android.hostAdbInvocation(['disconnect', serial]), { allowFailure: true, timeoutMs: 10_000, }) diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index a717238cab..54de2f926c 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -39,6 +39,7 @@ test('rejects a cross-platform durable descriptor before provider reconnection', platform: 'android', leaseId: 'lease-a', instanceId: 'instance-a', + ownership: 'created', appBundleId: 'com.example.app', outputPath: '/sessions/one/app.log', }, @@ -74,6 +75,7 @@ test('rejects cross-session paths before reconnecting or opening a provider read platform: 'ios', leaseId: 'lease-a', instanceId: 'instance-a', + ownership: 'created', appBundleId: 'com.example.app', outputPath: '/sessions/two/app.log', }, @@ -124,6 +126,7 @@ test('keeps exact-owner app-log recovery available without a process-local sessi platform: 'ios', leaseId: 'lease-a', instanceId: 'instance-a', + ownership: 'created', appBundleId: 'com.example.app', outputPath: '/sessions/session/app.log', }, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index c924032f1f..c5f0cb7f75 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -230,6 +230,7 @@ function bindLimrunAppLogs( platform: reader.platform, leaseId: reader.leaseId, instanceId: reader.instanceId, + ownership: reader.ownership, appBundleId: input.appBundleId, outputPath: input.outputPath, }; diff --git a/packages/provider-limrun/src/client-options.ts b/packages/provider-limrun/src/client-options.ts index 15626555ca..3832757f57 100644 --- a/packages/provider-limrun/src/client-options.ts +++ b/packages/provider-limrun/src/client-options.ts @@ -1,3 +1,6 @@ +import type Limrun from '@limrun/api'; +import { AppError } from '@agent-device/kernel/errors'; + export const LIMRUN_CLIENT_HEADER = 'agent-device-cli'; export function buildLimrunClientOptions(options: { apiKey: string; clientVersion: string }): { @@ -12,3 +15,13 @@ export function buildLimrunClientOptions(options: { apiKey: string; clientVersio }, }; } + +/** The organization client, or a typed refusal for an operation that needs the API key. */ +export function requireLimrunOrgClient( + limrun: Limrun | undefined, + operation: string, + hint = 'This daemon drives an existing Limrun instance with its own token. Set LIMRUN_API_KEY, or ask the instance owner to do this.', +): Limrun { + if (limrun) return limrun; + throw new AppError('UNSUPPORTED_OPERATION', `${operation} requires a Limrun API key.`, { hint }); +} diff --git a/packages/provider-limrun/src/connection-verification.test.ts b/packages/provider-limrun/src/connection-verification.test.ts index 24950f2a78..e07c556991 100644 --- a/packages/provider-limrun/src/connection-verification.test.ts +++ b/packages/provider-limrun/src/connection-verification.test.ts @@ -8,6 +8,21 @@ const mockState = vi.hoisted(() => ({ iosList: vi.fn(async () => ({ getPaginatedItems: () => [] })), })); +const instanceClients = vi.hoisted(() => ({ + disconnect: vi.fn(), + createIos: vi.fn(async (_options: Record) => ({ + disconnect: instanceClients.disconnect, + })), + createAndroid: vi.fn(async (_options: Record) => ({ + disconnect: instanceClients.disconnect, + })), +})); + +vi.mock('@limrun/api/ios-client', () => ({ createInstanceClient: instanceClients.createIos })); +vi.mock('@limrun/api/instance-client', () => ({ + createInstanceClient: instanceClients.createAndroid, +})); + vi.mock('@limrun/api', () => ({ default: class MockLimrun { readonly androidInstances = { list: mockState.androidList }; @@ -70,3 +85,52 @@ test('Limrun verification classifies authentication failures', async () => { }, ); }); + +test('Limrun verification connects to an attached instance instead of the control plane', async () => { + const android = { apiUrl: 'https://attached.example/api', token: 'tok', adbUrl: 'wss://adb' }; + const result = await verifyLimrunConnection({ + apiKey: 'lim_test_key', + instances: { android }, + clientVersion: '1.2.3', + platform: 'android', + }); + + assert.equal(result.device.status, 'verified'); + assert.deepEqual(instanceClients.createAndroid.mock.calls, [ + [{ ...android, logLevel: 'none', maxReconnectAttempts: 0 }], + ]); + assert.equal(instanceClients.disconnect.mock.calls.length, 1); + assert.equal(mockState.constructorOptions.length, 0); + assert.equal(mockState.androidList.mock.calls.length, 0); +}); + +test('Limrun verification names the variables to check when an instance rejects access', async () => { + const cases = [ + { + platform: 'ios', + instances: { ios: { apiUrl: 'https://attached/api', token: 'lim_st_secret' } }, + create: instanceClients.createIos, + variables: /LIM_IOS_INSTANCE_URL and LIM_IOS_INSTANCE_TOKEN,/, + }, + { + platform: 'android', + instances: { + android: { apiUrl: 'https://attached/api', token: 'lim_st_secret', adbUrl: 'wss://adb' }, + }, + create: instanceClients.createAndroid, + variables: /LIM_ANDROID_INSTANCE_TOKEN, and LIM_ANDROID_INSTANCE_ADB_URL/, + }, + ] as const; + for (const { platform, instances, create, variables } of cases) { + create.mockRejectedValueOnce(new Error('Unexpected server response: 401')); + await assert.rejects( + verifyLimrunConnection({ instances, clientVersion: '1.2.3', platform }), + (error: unknown) => { + assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); + assert.match(JSON.stringify(error), variables); + assert.doesNotMatch(JSON.stringify(error), /lim_st_secret/); + return true; + }, + ); + } +}); diff --git a/packages/provider-limrun/src/connection-verification.ts b/packages/provider-limrun/src/connection-verification.ts index b277d1cddd..9e126b0b9f 100644 --- a/packages/provider-limrun/src/connection-verification.ts +++ b/packages/provider-limrun/src/connection-verification.ts @@ -2,12 +2,13 @@ import Limrun from '@limrun/api'; import { AppError } from '@agent-device/kernel/errors'; import { buildLimrunClientOptions } from './client-options.ts'; import type { ProviderConnectionVerification } from '@agent-device/contracts/remote'; +import type { LimrunInstanceAccess } from './instance-access.ts'; export type LimrunConnectionVerification = ProviderConnectionVerification & { provider: 'limrun'; service: 'Limrun'; device: { - status: 'deferred'; + status: 'deferred' | 'verified'; name: string; platform: 'android' | 'ios'; }; @@ -18,17 +19,27 @@ export type LimrunConnectionVerification = ProviderConnectionVerification & { }; export type LimrunConnectionVerificationOptions = { - apiKey: string; + apiKey?: string; + instances?: LimrunInstanceAccess; clientVersion: string; platform: 'android' | 'ios'; region?: string; }; +/** Verifies the instance access for the platform when given, otherwise the organization API key. */ export async function verifyLimrunConnection( options: LimrunConnectionVerificationOptions, ): Promise { + const attached = await verifyAttachedInstance(options); + if (attached) return attached; + if (!options.apiKey) { + throw new AppError( + 'INVALID_ARGS', + 'Limrun verification requires an API key or instance access.', + ); + } const client = new Limrun({ - ...buildLimrunClientOptions(options), + ...buildLimrunClientOptions({ apiKey: options.apiKey, clientVersion: options.clientVersion }), timeout: 15_000, maxRetries: 0, }); @@ -77,3 +88,61 @@ function readStatus(error: unknown): number | undefined { const status = (error as { status?: unknown }).status; return typeof status === 'number' ? status : undefined; } + +function connectAttachedInstance( + options: LimrunConnectionVerificationOptions, +): Promise<{ disconnect(): void }> | undefined { + const clientOptions = { logLevel: 'none', maxReconnectAttempts: 0 } as const; + const { ios, android } = options.instances ?? {}; + if (options.platform === 'android') { + return ( + android && + import('@limrun/api/instance-client').then(({ createInstanceClient }) => + createInstanceClient({ ...clientOptions, ...android }), + ) + ); + } + return ( + ios && + import('@limrun/api/ios-client').then(({ createInstanceClient }) => + createInstanceClient({ ...clientOptions, ...ios }), + ) + ); +} + +async function verifyAttachedInstance( + options: LimrunConnectionVerificationOptions, +): Promise { + const connecting = connectAttachedInstance(options); + if (!connecting) return undefined; + const android = options.platform === 'android'; + const platformName = android ? 'Android' : 'iOS'; + try { + (await connecting).disconnect(); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Limrun ${platformName} instance access failed.`, + { + hint: android + ? 'Check LIM_ANDROID_INSTANCE_URL, LIM_ANDROID_INSTANCE_TOKEN, and LIM_ANDROID_INSTANCE_ADB_URL, and that the instance is still running.' + : 'Check LIM_IOS_INSTANCE_URL and LIM_IOS_INSTANCE_TOKEN, and that the instance is still running.', + }, + error, + ); + } + return { + provider: 'limrun', + service: 'Limrun', + verificationMessage: `Existing ${platformName} instance access verified. A daemon started with these variables drives it without creating or deleting it.`, + device: { + status: 'verified', + name: `Existing ${platformName} ${android ? 'emulator' : 'simulator'}`, + platform: options.platform, + }, + app: { + status: 'missing', + message: 'Run open to use an app installed on the instance.', + }, + }; +} diff --git a/packages/provider-limrun/src/device.test.ts b/packages/provider-limrun/src/device.test.ts new file mode 100644 index 0000000000..6b9e3c4129 --- /dev/null +++ b/packages/provider-limrun/src/device.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from 'vitest'; +import { attachedLimrunInstanceId } from './device.ts'; + +test('names an attached instance stably by its API URL', () => { + const apiUrl = 'https://region.limrun.example/v1/ios_attached/api'; + expect(attachedLimrunInstanceId(apiUrl)).toBe(attachedLimrunInstanceId(apiUrl)); + expect(attachedLimrunInstanceId(`${apiUrl}/other`)).not.toBe(attachedLimrunInstanceId(apiUrl)); +}); diff --git a/packages/provider-limrun/src/device.ts b/packages/provider-limrun/src/device.ts index a61e41b3e2..0650af5b1a 100644 --- a/packages/provider-limrun/src/device.ts +++ b/packages/provider-limrun/src/device.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import type { DeviceLease } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -29,6 +30,14 @@ export function buildLimrunDevice( }; } +/** + * A stable local id for an attached instance, derived from the API URL that identifies it, so the + * same instance keeps the same id across daemon restarts. + */ +export function attachedLimrunInstanceId(apiUrl: string): string { + return `attached-${createHash('sha256').update(apiUrl).digest('hex').slice(0, 12)}`; +} + export function parseLimrunDeviceId( value: string, ): { platform: LimrunPlatform; leaseId: string } | undefined { diff --git a/packages/provider-limrun/src/index.ts b/packages/provider-limrun/src/index.ts index d29e531c0c..d22a8ab6a4 100644 --- a/packages/provider-limrun/src/index.ts +++ b/packages/provider-limrun/src/index.ts @@ -1,6 +1,7 @@ export { LIMRUN_PROVIDER } from './device.ts'; export { createLimrunRuntime, type LimrunRuntime, type LimrunRuntimeOptions } from './runtime.ts'; export { verifyLimrunConnection } from './connection-verification.ts'; +export type { LimrunInstanceAccess } from './instance-access.ts'; export type { LimrunFileDownload, LimrunRuntimeDependencies } from './runtime-dependencies.ts'; diff --git a/packages/provider-limrun/src/instance-access.ts b/packages/provider-limrun/src/instance-access.ts new file mode 100644 index 0000000000..fc77c13dfc --- /dev/null +++ b/packages/provider-limrun/src/instance-access.ts @@ -0,0 +1,24 @@ +/** An existing iOS instance's own API URL and token (`status.apiUrl`, `status.token`). */ +export type LimrunIosInstanceAccess = Readonly<{ apiUrl: string; token: string }>; + +/** An existing Android instance's own API URL, ADB WebSocket URL, and token. */ +export type LimrunAndroidInstanceAccess = Readonly<{ + apiUrl: string; + token: string; + adbUrl: string; +}>; + +/** + * Existing instances to drive with their own credentials instead of an organization API key. A + * platform listed here never creates or deletes instances: whoever created the instance owns it. + */ +export type LimrunInstanceAccess = Readonly<{ + ios?: LimrunIosInstanceAccess; + android?: LimrunAndroidInstanceAccess; +}>; + +/** + * Who owns an instance: `created` instances were created by this runtime, which deletes them; + * `attached` instances belong to someone else and are never deleted. + */ +export type LimrunInstanceOwnership = 'created' | 'attached'; diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index 1edbd873c3..09adfb2530 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -23,12 +23,14 @@ import { awaitLimrunDeploymentOperation, type LimrunRequestOperationDrain, } from './request-cancellation.ts'; +import type { LimrunInstanceOwnership } from './instance-access.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; export type LimrunIosSession = { platform: 'ios'; lease: DeviceLease; instanceId: string; + readonly ownership: LimrunInstanceOwnership; device: DeviceInfo; client: LimrunIosClient; /** Instance bearer token; the recording download the SDK would run inline is done by the host instead. */ @@ -52,6 +54,7 @@ export async function createLimrunIosSession( options: { lease: DeviceLease; instanceId: string; + ownership: LimrunInstanceOwnership; device: DeviceInfo; apiUrl: string; token: string; @@ -67,6 +70,7 @@ export async function createLimrunIosSession( platform: 'ios', lease: options.lease, instanceId: options.instanceId, + ownership: options.ownership, device: options.device, client, token: options.token, diff --git a/packages/provider-limrun/src/runtime-instance.test.ts b/packages/provider-limrun/src/runtime-instance.test.ts index dbc7164087..d30ef3ebb1 100644 --- a/packages/provider-limrun/src/runtime-instance.test.ts +++ b/packages/provider-limrun/src/runtime-instance.test.ts @@ -18,3 +18,14 @@ test('uses and validates an explicit composition identity', () => { 'non-empty', ); }); + +test('derives an identity from attached instances without an API key', () => { + const ios = { apiUrl: 'https://attached.example/api', token: 'instance-token' }; + const attached = resolveLimrunRuntimeInstance({ instances: { ios } }); + expect(attached).not.toBe( + resolveLimrunRuntimeInstance({ instances: { ios: { ...ios, apiUrl: 'x' } } }), + ); + expect(resolveLimrunRuntimeInstance({ apiKey: 'k', instances: {} })).toBe( + resolveLimrunRuntimeInstance({ apiKey: 'k' }), + ); +}); diff --git a/packages/provider-limrun/src/runtime-instance.ts b/packages/provider-limrun/src/runtime-instance.ts index 6c728d7844..52d10f0224 100644 --- a/packages/provider-limrun/src/runtime-instance.ts +++ b/packages/provider-limrun/src/runtime-instance.ts @@ -1,4 +1,5 @@ import { scryptSync } from 'node:crypto'; +import type { LimrunInstanceAccess } from './instance-access.ts'; const RUNTIME_INSTANCE_KEY_LENGTH = 32; const RUNTIME_INSTANCE_SCRYPT_COST = 16_384; @@ -6,9 +7,10 @@ const RUNTIME_INSTANCE_SCRYPT_MAX_MEMORY = 64 * 1024 * 1024; const RUNTIME_INSTANCE_SALT = 'agent-device:limrun-runtime-owner:v1'; export function resolveLimrunRuntimeInstance(options: { - apiKey: string; + apiKey?: string; region?: string; runtimeInstance?: string; + instances?: LimrunInstanceAccess; }): string { if (options.runtimeInstance !== undefined) { const explicit = options.runtimeInstance.trim(); @@ -19,6 +21,8 @@ export function resolveLimrunRuntimeInstance(options: { provider: 'limrun', region: options.region?.trim().toLowerCase() || 'default', apiKey: options.apiKey, + attachedIos: options.instances?.ios?.apiUrl, + attachedAndroid: options.instances?.android?.apiUrl, }); const fingerprint = scryptSync(principal, RUNTIME_INSTANCE_SALT, RUNTIME_INSTANCE_KEY_LENGTH, { N: RUNTIME_INSTANCE_SCRYPT_COST, diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index d3528ea1b1..d57bf353c5 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -18,11 +18,24 @@ import { cleanupLimrunAndroidAdbTunnel, configureLimrunAndroidPortReverse, createLimrunAndroidInteractor, + createLimrunAndroidSession, installLimrunAndroidApp, type LimrunAndroidSession, } from './android.ts'; -import { LIMRUN_PROVIDER, parseLimrunDeviceId, platformForLimrunLeaseBackend } from './device.ts'; -import { createLimrunIosInteractor, installLimrunIosApp, type LimrunIosSession } from './ios.ts'; +import { + attachedLimrunInstanceId, + buildLimrunDevice, + LIMRUN_PROVIDER, + parseLimrunDeviceId, + platformForLimrunLeaseBackend, + type LimrunPlatform, +} from './device.ts'; +import { + createLimrunIosInteractor, + createLimrunIosSession, + installLimrunIosApp, + type LimrunIosSession, +} from './ios.ts'; import { createLimrunDeviceSession, type LimrunDeviceSession } from './device-session.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; import type { @@ -33,17 +46,28 @@ import type { import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { LimrunAppLogDescriptor } from './app-log-descriptor.ts'; import type { LimrunAppLogReader } from './app-log-poller.ts'; -import { buildLimrunClientOptions, LIMRUN_CLIENT_HEADER } from './client-options.ts'; +import { + buildLimrunClientOptions, + LIMRUN_CLIENT_HEADER, + requireLimrunOrgClient, +} from './client-options.ts'; import { resolveLimrunRuntimeInstance } from './runtime-instance.ts'; import type { LimrunRequestOperationDrain } from './request-cancellation.ts'; import type { LimrunAppAsset } from './app-catalog.ts'; +import type { LimrunInstanceAccess } from './instance-access.ts'; type LimrunRuntimeSession = LimrunIosSession | LimrunAndroidSession; +const KEEP_ALIVE_INTERVAL_MS = 30_000; + export type LimrunRuntimeOptions = { - apiKey: string; + /** Organization API key. It creates and deletes instances for platforms without `instances`. */ + apiKey?: string; region?: string; + /** Pings each leased instance every 30 s so Limrun's inactivity timeout does not end idle sessions. */ + keepAlive?: boolean; runtimeInstance?: string; + instances?: LimrunInstanceAccess; }; export type LimrunRuntime = ProviderDeviceRuntime & { @@ -85,8 +109,9 @@ export function createLimrunRuntime( } class LimrunRuntimeImplementation implements ProviderDeviceRuntime { - private readonly limrun: Limrun; + private readonly limrun: Limrun | undefined; private readonly sessions = new Map(); + private readonly keepAliveTimers = new Map(); private readonly appAliases = new Map< string, Readonly<{ assetName: string; installedAppId: string }> @@ -103,9 +128,11 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { readonly appCatalog: ProviderAppCatalogHandler = async (query, signal) => { const { assertLimrunUploadedAppAccess, listLimrunAppAssets } = await import('./app-catalog.ts'); assertLimrunUploadedAppAccess(query.publicNetworkOnly); - return (await listLimrunAppAssets(this.limrun, query.platform, signal)).map( - (asset) => asset.name, + const limrun = this.orgClient( + 'Listing uploaded apps', + 'Run open first; apps then lists the apps installed on the instance.', ); + return (await listLimrunAppAssets(limrun, query.platform, signal)).map((asset) => asset.name); }; readonly recoverExpiredLease: ProviderExpiredLeaseRecovery = async (lease) => { @@ -128,14 +155,19 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { }; constructor(options: LimrunRuntimeOptions, dependencies: LimrunRuntimeDependencies) { + if (!options.apiKey && !options.instances?.ios && !options.instances?.android) { + throw new TypeError('Limrun runtime requires an apiKey or instance access'); + } this.options = options; this.dependencies = dependencies; - this.limrun = new Limrun( - buildLimrunClientOptions({ - apiKey: options.apiKey, - clientVersion: dependencies.clientVersion, - }), - ); + this.limrun = options.apiKey + ? new Limrun( + buildLimrunClientOptions({ + apiKey: options.apiKey, + clientVersion: dependencies.clientVersion, + }), + ) + : undefined; } ownsDevice(device: DeviceInfo): boolean { @@ -189,17 +221,11 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { ): Promise { const session = this.getSessionForDevice(device); if (!session) return undefined; + const limrun = this.orgClient('Uploading an app'); return session.platform === 'ios' - ? await installLimrunIosApp( - this.limrun, - session, - installablePath, - options, - signal, - operationDrain, - ) + ? await installLimrunIosApp(limrun, session, installablePath, options, signal, operationDrain) : await installLimrunAndroidApp( - this.limrun, + limrun, session, installablePath, options, @@ -234,17 +260,56 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const existing = this.sessions.get(lease.leaseId); if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; + const session = + (await this.attachSession(platform, lease)) ?? + (await this.createSession(platform, lease, context)); + this.sessions.set(lease.leaseId, session); + if (this.options.keepAlive) this.startKeepAlive(lease.leaseId, session); + return { limrunInstanceId: session.instanceId, device: session.device }; + } + + private async attachSession( + platform: LimrunPlatform, + lease: DeviceLease, + ): Promise { + const { ios, android } = this.options.instances ?? {}; + const target = (apiUrl: string) => { + const instanceId = attachedLimrunInstanceId(apiUrl); + const device = buildLimrunDevice(platform, lease, instanceId); + return { lease, instanceId, ownership: 'attached' as const, device }; + }; + if (platform === 'android') { + return ( + android && + (await createLimrunAndroidSession( + { ...target(android.apiUrl), ...android }, + this.dependencies, + )) + ); + } + return ( + ios && (await createLimrunIosSession({ ...target(ios.apiUrl), ...ios }, this.dependencies)) + ); + } + + private async createSession( + platform: LimrunPlatform, + lease: DeviceLease, + context?: LeaseLifecycleContext, + ): Promise { const { allocateLimrunAndroidSession, allocateLimrunIosSession, resolvePreinstalledAppId, resolveRequestedLimrunAppAsset, } = await import('./session-allocation.ts'); - const requestedAsset = await resolveRequestedLimrunAppAsset(this.limrun, platform, context); + const limrun = this.orgClient('Creating an instance'); + const requestedAsset = await resolveRequestedLimrunAppAsset(limrun, platform, context); + const params = this.sessionAllocationParams(limrun, lease, requestedAsset); const session = platform === 'ios' - ? await allocateLimrunIosSession(this.sessionAllocationParams(lease, requestedAsset)) - : await allocateLimrunAndroidSession(this.sessionAllocationParams(lease, requestedAsset)); + ? await allocateLimrunIosSession(params) + : await allocateLimrunAndroidSession(params); if (requestedAsset) { try { const installedAppId = await resolvePreinstalledAppId(session, requestedAsset); @@ -257,13 +322,12 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { throw error; } } - this.sessions.set(lease.leaseId, session); - return { limrunInstanceId: session.instanceId, device: session.device }; + return session; } - private sessionAllocationParams(lease: DeviceLease, app?: LimrunAppAsset) { + private sessionAllocationParams(limrun: Limrun, lease: DeviceLease, app?: LimrunAppAsset) { return { - limrun: this.limrun, + limrun, lease, metadata: this.buildInstanceMetadata(lease), region: this.options.region, @@ -298,32 +362,54 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { lease: DeviceLease, ): Promise | undefined> { const platform = platformForLimrunLeaseBackend(lease.backend); - if (!platform) return undefined; + const limrun = this.limrun; + if (!platform || !limrun) return undefined; const labelSelector = `provider=${LIMRUN_PROVIDER},leaseId=${lease.leaseId}`; const instances = platform === 'ios' - ? await this.limrun.iosInstances.list({ labelSelector }) - : await this.limrun.androidInstances.list({ labelSelector }); + ? await limrun.iosInstances.list({ labelSelector }) + : await limrun.androidInstances.list({ labelSelector }); const instanceIds = instances.getPaginatedItems().map((instance) => instance.metadata.id); for (const instanceId of instanceIds) { if (platform === 'ios') { - await this.limrun.iosInstances.delete(instanceId); + await limrun.iosInstances.delete(instanceId); } else { - await this.limrun.androidInstances.delete(instanceId); + await limrun.androidInstances.delete(instanceId); } } if (instanceIds.length === 0) return undefined; return { limrunInstanceId: instanceIds[0], limrunInstanceCount: instanceIds.length }; } + private startKeepAlive(leaseId: string, session: LimrunRuntimeSession): void { + const timer = setInterval(() => { + try { + session.client.keepAlive(); + } catch { + // A dead connection surfaces on the next real command. + } + }, KEEP_ALIVE_INTERVAL_MS); + timer.unref(); + this.keepAliveTimers.set(leaseId, timer); + } + private async terminateSession(session: LimrunRuntimeSession): Promise { + const leaseId = session.lease.leaseId; + clearInterval(this.keepAliveTimers.get(leaseId)); + this.keepAliveTimers.delete(leaseId); session.client.disconnect(); + if (session.platform === 'android') await cleanupLimrunAndroidAdbTunnel(session); + if (session.ownership === 'attached') return; + const limrun = this.orgClient('Deleting an instance'); if (session.platform === 'ios') { - await this.limrun.iosInstances.delete(session.instanceId); - return; + await limrun.iosInstances.delete(session.instanceId); + } else { + await limrun.androidInstances.delete(session.instanceId); } - await cleanupLimrunAndroidAdbTunnel(session); - await this.limrun.androidInstances.delete(session.instanceId); + } + + private orgClient(operation: string, hint?: string): Limrun { + return requireLimrunOrgClient(this.limrun, operation, hint); } private getSessionForDevice(device: DeviceInfo): LimrunRuntimeSession | undefined { @@ -348,6 +434,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { platform: session.platform, leaseId: session.lease.leaseId, instanceId: session.instanceId, + ownership: session.ownership, readLogs: async (appBundleId, lineLimit) => publicSession.platform === 'ios' ? await publicSession.readLogs(appBundleId, lineLimit) @@ -360,6 +447,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { const { reconnectLimrunAppLogReader } = await import('./app-log-reconnect.ts'); return await reconnectLimrunAppLogReader({ limrun: this.limrun, + instances: this.options.instances, descriptor, dependencies: this.dependencies, signal, diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts index de83a71366..e04c8b1055 100644 --- a/packages/provider-limrun/src/session-allocation.ts +++ b/packages/provider-limrun/src/session-allocation.ts @@ -90,6 +90,7 @@ export async function allocateLimrunIosSession( { lease: params.lease, instanceId: instance.metadata.id, + ownership: 'created', device: buildLimrunDevice('ios', params.lease, instance.metadata.id), apiUrl: instance.status.apiUrl, token: instance.status.token, @@ -134,6 +135,7 @@ export async function allocateLimrunAndroidSession( { lease: params.lease, instanceId: instance.metadata.id, + ownership: 'created', device: buildLimrunDevice('android', params.lease, instance.metadata.id), apiUrl: instance.status.apiUrl, token: instance.status.token, diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 45436b420c..9b27be59a9 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -35,6 +35,7 @@ vi.mock('../provider-webdriver.ts', () => ({ afterEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); const mockedResolveCloudAccessForConnect = vi.mocked(resolveCloudAccessForConnect); @@ -198,6 +199,27 @@ test('connect limrun generates a local daemon remote profile', async () => { } }); +test('connect limrun attaches to an existing instance without storing its token', async () => { + const stateDir = path.join(mkdtempForTestSync('agent-device-connect-limrun-attach-'), '.state'); + const ios = { apiUrl: 'https://region.limrun.example/v1/ios_x/api', token: 'ios-instance-token' }; + vi.stubEnv('LIMRUN_API_KEY', ''); + vi.stubEnv('LIM_IOS_INSTANCE_URL', ios.apiUrl); + vi.stubEnv('LIM_IOS_INSTANCE_TOKEN', ios.token); + + await captureConnectStdout(async () => { + await connectCommand({ + positionals: ['limrun'], + flags: { json: true, help: false, version: false, stateDir, platform: 'ios' }, + client: {} as AgentDeviceClient, + }); + }); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.leaseBackend, 'ios-instance'); + assert.doesNotMatch(fs.readFileSync(state.remoteConfigPath, 'utf8'), /ios-instance-token/); + assert.deepEqual(mockedVerifyLimrunConnection.mock.calls[0]?.[0].instances?.ios, ios); +}); + test('connect limrun persists deferred Metro bridge settings', async () => { const tempRoot = mkdtempForTestSync('agent-device-connect-limrun-metro-'); const stateDir = path.join(tempRoot, '.state'); @@ -253,11 +275,13 @@ test('connect limrun persists deferred Metro bridge settings', async () => { } }); -test('connect limrun requires LIMRUN_API_KEY', async () => { +test('connect limrun requires LIMRUN_API_KEY or access to an existing instance', async () => { const tempRoot = mkdtempForTestSync('agent-device-connect-limrun-env-'); const stateDir = path.join(tempRoot, '.state'); vi.stubEnv('LIMRUN_API_KEY', ''); vi.stubEnv('LIM_API_KEY', 'lim_test_key'); + vi.stubEnv('LIM_IOS_INSTANCE_URL', 'https://region.limrun.example/v1/ios_x/api'); + vi.stubEnv('LIM_IOS_INSTANCE_TOKEN', 'ios-instance-token'); try { await assert.rejects( @@ -268,10 +292,11 @@ test('connect limrun requires LIMRUN_API_KEY', async () => { help: false, version: false, stateDir, + platform: 'android', }, client: {} as AgentDeviceClient, }), - /connect limrun requires LIMRUN_API_KEY/, + /connect limrun requires LIMRUN_API_KEY, or LIM_ANDROID_INSTANCE_URL, LIM_ANDROID_INSTANCE_TOKEN, LIM_ANDROID_INSTANCE_ADB_URL/, ); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index dd3446fbeb..e06499d417 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; +import { createInstanceClient as createAndroidInstanceClient } from '@limrun/api/instance-client'; +import { createInstanceClient as createIosInstanceClient } from '@limrun/api/ios-client'; import { LimrunRuntime } from '../sdk/limrun.ts'; import { createExpiredProviderLeaseReleaser } from '../daemon/provider-lease-expiry.ts'; import type { SimulatorLease } from '../daemon/lease-registry.ts'; @@ -35,6 +37,7 @@ const limrunMockState = vi.hoisted(() => { ]), androidOpenUrl: vi.fn(async () => undefined), androidDisconnect: vi.fn(), + androidKeepAlive: vi.fn(), androidSendAsset: vi.fn(async () => undefined), androidTunnelClose, androidStartAdbTunnel: vi.fn(async () => ({ @@ -108,6 +111,7 @@ vi.mock('@limrun/api/ios-client', () => ({ vi.mock('@limrun/api/instance-client', () => ({ createInstanceClient: vi.fn(async () => ({ disconnect: limrunMockState.androidDisconnect, + keepAlive: limrunMockState.androidKeepAlive, openUrl: limrunMockState.androidOpenUrl, sendAsset: limrunMockState.androidSendAsset, startAdbTunnel: limrunMockState.androidStartAdbTunnel, @@ -125,6 +129,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => { afterEach(() => { limrunMockState.constructorOptions.length = 0; vi.clearAllMocks(); + vi.mocked(runCmd).mockReset(); }); test('Limrun runtime identifies direct CLI usage to the Limrun API', async () => { @@ -304,6 +309,52 @@ test('Limrun Android reverses localhost URL ports through the persistent ADB tun } }); +test('Limrun keepAlive pings the session client every 30 s until release', async () => { + vi.useFakeTimers(); + try { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', keepAlive: true }); + const lease = androidLease(); + await allocateLimrunDevice(runtime, lease); + await vi.advanceTimersByTimeAsync(60_000); + assert.equal(limrunMockState.androidKeepAlive.mock.calls.length, 2); + + await runtime.leaseLifecycle.release?.(lease); + await vi.advanceTimersByTimeAsync(60_000); + assert.equal(limrunMockState.androidKeepAlive.mock.calls.length, 2); + } finally { + vi.useRealTimers(); + } +}); + +test('Limrun keepAlive is off by default', async () => { + vi.useFakeTimers(); + try { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' }); + await allocateLimrunDevice(runtime, androidLease()); + await vi.advanceTimersByTimeAsync(60_000); + assert.equal(limrunMockState.androidKeepAlive.mock.calls.length, 0); + await runtime.shutdown(); + } finally { + vi.useRealTimers(); + } +}); + +test('Limrun keepAlive keeps pinging after the client throws', async () => { + vi.useFakeTimers(); + try { + limrunMockState.androidKeepAlive.mockImplementationOnce(() => { + throw new Error('socket closed'); + }); + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', keepAlive: true }); + await allocateLimrunDevice(runtime, androidLease()); + await vi.advanceTimersByTimeAsync(60_000); + assert.equal(limrunMockState.androidKeepAlive.mock.calls.length, 2); + await runtime.shutdown(); + } finally { + vi.useRealTimers(); + } +}); + function androidLease(): SimulatorLease { return { leaseId: 'lease-android', @@ -733,3 +784,115 @@ test('Limrun keeps session tracked when release fails so release can be retried' await runtime.shutdown(); } }); + +const ATTACHED_IOS = { + apiUrl: 'https://region.limrun.example/v1/ios_attached/api', + token: 'ios-instance-token', +}; +const ATTACHED_ANDROID = { + apiUrl: 'https://region.limrun.example/v1/android_attached/api', + token: 'android-instance-token', + adbUrl: 'wss://region.limrun.example/v1/android_attached/adb', +}; + +function iosLease(leaseId: string): SimulatorLease { + return { ...androidLease(), leaseId, backend: 'ios-instance' }; +} + +test('Limrun drives existing instances without an API key and never deletes them', async () => { + const runtime = new LimrunRuntime({ + instances: { ios: ATTACHED_IOS, android: ATTACHED_ANDROID }, + }); + const releaseLease = runtime.leaseLifecycle.release; + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease || !releaseLease) throw new Error('Limrun runtime must provide lease hooks'); + + try { + const ios = await allocateLease(iosLease('lease-attached-ios'), { initialApp: 'Example.ipa' }); + await allocateLease({ ...androidLease(), leaseId: 'lease-attached-android' }); + + assert.match(String(ios?.limrunInstanceId), /^attached-[a-f0-9]{12}$/); + assert.deepEqual(vi.mocked(createIosInstanceClient).mock.calls, [ + [{ ...ATTACHED_IOS, logLevel: 'warn' }], + ]); + assert.deepEqual(vi.mocked(createAndroidInstanceClient).mock.calls, [ + [{ ...ATTACHED_ANDROID, logLevel: 'warn' }], + ]); + + await releaseLease(iosLease('lease-attached-ios')); + await releaseLease({ ...androidLease(), leaseId: 'lease-attached-android' }); + assert.equal(await releaseLease(iosLease('lease-before-daemon-restart')), undefined); + assert.equal(limrunMockState.androidDisconnect.mock.calls.length, 1); + } finally { + await runtime.shutdown(); + } + assert.equal(limrunMockState.constructorOptions.length, 0); + for (const controlPlaneCall of [ + limrunMockState.iosCreate, + limrunMockState.iosList, + limrunMockState.iosDelete, + limrunMockState.androidCreate, + limrunMockState.androidList, + limrunMockState.androidDelete, + ]) { + assert.equal(controlPlaneCall.mock.calls.length, 0); + } +}); + +test('Limrun removes only its own port reverse mappings from an attached Android instance', async () => { + const runtime = new LimrunRuntime({ instances: { android: ATTACHED_ANDROID } }); + const lease = { ...androidLease(), leaseId: 'lease-attached-android' }; + vi.mocked(runCmd).mockImplementation(async (_command, args) => ({ + stdout: args.includes('--list') ? 'host-7 tcp:8081 tcp:8081\nhost-9 tcp:8097 tcp:8097\n' : '', + stderr: '', + exitCode: 0, + })); + + await allocateLimrunDevice(runtime, lease); + await runtime.configurePortReverse({ + leaseId: lease.leaseId, + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }); + await runtime.shutdown(); + + const removals = vi + .mocked(runCmd) + .mock.calls.map(([, args]) => args) + .filter((args) => args.includes('--remove')); + assert.deepEqual(removals, [['-s', '127.0.0.1:62001', 'reverse', '--remove', 'tcp:8097']]); +}); + +test('Limrun instance access wins over the API key for its platform only', async () => { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', instances: { ios: ATTACHED_IOS } }); + + await allocateLimrunDevice(runtime, iosLease('lease-attached-ios')); + await allocateLimrunDevice(runtime, androidLease()); + await runtime.shutdown(); + + assert.equal(limrunMockState.iosCreate.mock.calls.length, 0); + assert.equal(limrunMockState.iosDelete.mock.calls.length, 0); + assert.equal(limrunMockState.androidCreate.mock.calls.length, 1); + assert.deepEqual(limrunMockState.androidDelete.mock.calls, [['android-instance-1']]); +}); + +test('Limrun without an API key refuses operations that need one', async () => { + const runtime = new LimrunRuntime({ instances: { ios: ATTACHED_IOS } }); + + try { + const device = await allocateLimrunDevice(runtime, iosLease('lease-attached-ios')); + await assert.rejects(runtime.installInstallablePath(device, '/tmp/Example.app'), { + code: 'UNSUPPORTED_OPERATION', + message: 'Uploading an app requires a Limrun API key.', + }); + await assert.rejects(allocateLimrunDevice(runtime, androidLease()), { + code: 'UNSUPPORTED_OPERATION', + message: 'Creating an instance requires a Limrun API key.', + }); + assert.equal(limrunMockState.assetsGetOrUpload.mock.calls.length, 0); + } finally { + await runtime.shutdown(); + } + assert.throws(() => new LimrunRuntime({}), /requires an apiKey or instance access/); +}); diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index 81243d21f6..9971c34546 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -27,6 +27,24 @@ test('bundled composition stays independent of configured plugins and reserves a await Promise.all(composition.runtimes.map((runtime) => runtime.shutdown())); }); +test('daemon composition skips Limrun for a partial instance set and still loads every other provider', async () => { + const { home, env } = pluginHome(); + selectPlugin(home, 'example', 'example', registrationSource('example')); + const composition = await createDaemonProviderRuntimeComposition({ + ...env, + LIMRUN_API_KEY: 'lim_test_key', + LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', + LIM_ANDROID_INSTANCE_TOKEN: 'android-instance-token', + }); + + const providers = composition.runtimes.map((runtime) => runtime.provider); + assert.deepEqual(providers, ['browserstack', 'aws-device-farm', 'example']); + assert.equal(composition.skipped?.[0]?.provider, 'limrun'); + assert.equal(composition.skipped?.[0]?.error.code, 'INVALID_ARGS'); + assert.match(composition.skipped?.[0]?.error.message ?? '', /LIM_ANDROID_INSTANCE_ADB_URL/); + await Promise.all(composition.runtimes.map((runtime) => runtime.shutdown())); +}); + test('daemon composition rejects builtin provider IDs before evaluating plugins', async () => { for (const provider of ['limrun', 'browserstack', 'aws-device-farm']) { const { home, env } = pluginHome(); @@ -67,6 +85,18 @@ test('default provider runtimes load Limrun when a Limrun API key is configured' await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); }); +test('default provider runtimes load Limrun for an existing instance without an API key', async () => { + const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ + LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', + LIM_IOS_INSTANCE_TOKEN: 'ios-instance-token', + }); + + const limrun = runtimes.find((runtime) => runtime.provider === 'limrun'); + assert.ok(limrun); + assertPlatformModuleCoverage(runtimes, platformModules, [limrun]); + await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); +}); + function assertPlatformModuleCoverage( runtimes: readonly object[], platformModules: ReadonlyArray< diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 27d9c5c564..fe41e812c0 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -10,6 +10,7 @@ import { type EnvMap } from '@agent-device/kernel/source-value'; import { resolveCloudConnectProfile } from './cloud-profile.ts'; import { resolveCloudWebDriverConnectProfile } from './cloud-webdriver-profile.ts'; import { resolveLimrunConnectProfile } from './limrun-profile.ts'; +import { readLimrunCredentials } from '../../provider-limrun-credentials.ts'; import { resolveProxyConnectProfile } from './proxy-profile.ts'; import { profileToCliFlags } from '../remote-config-flags.ts'; import { isConnectProviderName, type ConnectProvider } from './provider-policy.ts'; @@ -177,13 +178,9 @@ async function verifyLimrun( context: Pick, ): Promise { return await verifyLimrunConnection({ - apiKey: requiredResolvedValue( - context.env.LIMRUN_API_KEY, - 'Limrun profile missed LIMRUN_API_KEY.', - ), + ...readLimrunCredentials(context.env), clientVersion: readVersion(), platform: requiredResolvedPlatform(context.flags.platform, 'Limrun'), - region: context.env.LIMRUN_REGION?.trim() || undefined, }); } diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index b3025a78da..bcffed85f4 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -6,6 +6,10 @@ import { type EnvMap } from '@agent-device/kernel/source-value'; import { readMetroProfileFields } from './profile-fields.ts'; import { persistAndResolveGeneratedProfile } from './generated-config.ts'; import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts'; +import { + limrunInstanceVariables, + readLimrunCredentials, +} from '../../provider-limrun-credentials.ts'; const DEFAULT_LIMRUN_TENANT = 'limrun'; export function resolveLimrunConnectProfile(options: { @@ -15,14 +19,18 @@ export function resolveLimrunConnectProfile(options: { env?: EnvMap; }): { flags: CliFlags; remoteConfigPath: string } { const env = options.env ?? process.env; - const apiKey = env.LIMRUN_API_KEY?.trim(); - if (!apiKey) { - throw new AppError('INVALID_ARGS', 'connect limrun requires LIMRUN_API_KEY.', { - hint: 'Set LIMRUN_API_KEY in the environment before running agent-device connect limrun.', - }); - } - const profile = buildLimrunRemoteProfile({ flags: options.flags }); + const credentials = readLimrunCredentials(env); + const platform = profile.leaseBackend === 'ios-instance' ? 'ios' : 'android'; + if (!credentials?.apiKey && !credentials?.instances?.[platform]) { + throw new AppError( + 'INVALID_ARGS', + `connect limrun requires LIMRUN_API_KEY, or ${limrunInstanceVariables(platform).join(', ')} for an existing instance.`, + { + hint: 'Set the credentials in the environment before running agent-device connect limrun.', + }, + ); + } return persistAndResolveGeneratedProfile({ stateDir: options.stateDir, provider: 'limrun', diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 1c24a8d164..0ac4b4829f 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -641,6 +641,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. + To drive an existing instance without the API key, set LIM_IOS_INSTANCE_URL and LIM_IOS_INSTANCE_TOKEN, or LIM_ANDROID_INSTANCE_URL, LIM_ANDROID_INSTANCE_TOKEN, and LIM_ANDROID_INSTANCE_ADB_URL, from the instance status before connect. agent-device then never creates or deletes that instance; install, and apps before the first open, still need LIMRUN_API_KEY. + Set LIMRUN_KEEP_ALIVE=1 to ping the instance every 30 seconds while a session is open, so an idle session does not hit the Limrun inactivity timeout. It is off by default. agent-device apps agent-device open Example.apk agent-device snapshot -i @@ -664,11 +666,11 @@ Rules: Prefer connect --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. Use agent-device proxy for direct tunnel access to a Mac you control. Expose the printed proxy URL through cloudflared/ngrok, then run agent-device connect proxy with the tunnel URL and printed token before normal commands. Use Limrun, BrowserStack, and AWS Device Farm through local provider profiles; they do not accept a remote agent-device daemon URL. - Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. + Device cloud credentials must be available before the command starts. Limrun uses LIMRUN_API_KEY, or the LIM_*_INSTANCE_* variables for an existing instance. BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY. AWS Device Farm uses the AWS CLI credential chain, including CI-provided AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKEN, AWS profiles, or web identity role variables. Direct-provider connect performs read-only provider calls and saves active connection state only after verification succeeds. It never creates a device, instance, App Automate session, or AWS remote access session. connect without --session always creates a fresh remote session and prints that session in its next-step commands. Concurrent callers must pass the returned --session on every command; the ambient active connection is only a single-workflow convenience. To replace an existing connection, pass its returned session explicitly with --session --force. --force without --session creates another fresh session and does not release or overwrite an unrelated active connection. - Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not Limrun API keys, BrowserStack access keys, or AWS credentials. + Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not Limrun API keys or instance tokens, BrowserStack access keys, or AWS credentials. Limrun Android supports direct ADB port reverse for local Metro. Limrun iOS requires a public Metro/React DevTools URL because it cannot reach local host ports directly. After closing a device cloud session, run agent-device artifacts --json to retrieve provider video/log/dashboard URLs when the provider has made them available. connect proxy stores the connection profile and client identity. Proxy device leases are acquired on open and expire after five minutes without commands; devices may inspect proxy inventory without allocating. diff --git a/src/daemon/server/daemon-runtime-metadata-ownership.test.ts b/src/daemon/server/daemon-runtime-metadata-ownership.test.ts index fb428af30f..82da792bed 100644 --- a/src/daemon/server/daemon-runtime-metadata-ownership.test.ts +++ b/src/daemon/server/daemon-runtime-metadata-ownership.test.ts @@ -6,6 +6,9 @@ import { publishDaemonRegistration } from '../../__tests__/test-utils/device-cla const lifecycleEvents = vi.hoisted(() => [] as string[]); const startupFailure = vi.hoisted(() => ({ active: false })); +const providerComposition = vi.hoisted(() => ({ + skipped: [] as Array<{ provider: string; error: Error & { code: string } }>, +})); vi.mock('../../platform-runtime.ts', () => ({ androidObservation: {}, @@ -33,7 +36,11 @@ vi.mock('../../platform-runtime.ts', () => ({ vi.mock('../../provider-device-runtimes.ts', () => ({ DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS: [], - createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), + createDaemonProviderRuntimeComposition: async () => ({ + runtimes: [], + platformModules: [], + skipped: providerComposition.skipped, + }), })); // The post-lock, pre-publication step the runtime awaits first. Making it throw lands the runtime in @@ -92,6 +99,7 @@ function publishSuccessor(paths: DaemonPaths): void { afterEach(() => { startupFailure.active = false; lifecycleEvents.length = 0; + providerComposition.skipped = []; vi.restoreAllMocks(); }); @@ -163,6 +171,21 @@ test('a shutdown removes its own metadata and reports an unverified release', as } }); +test('a skipped provider runtime is logged after this daemon publishes its record', async () => { + const stateDir = mkdtempForTestSync('agent-device-daemon-provider-skip-'); + const error = Object.assign(new Error('Limrun instance access is missing X.'), { + code: 'INVALID_ARGS', + }); + providerComposition.skipped = [{ provider: 'limrun', error }]; + const runtime = await startRuntime(stateDir, () => {}); + try { + const skipped = logEvents(stateDir).find(({ phase }) => phase === 'provider_runtime_skipped'); + expect(skipped?.data).toMatchObject({ provider: 'limrun', code: 'INVALID_ARGS' }); + } finally { + await runtime?.shutdown(); + } +}); + test('a startup that fails before publication removes no record and writes no log', async () => { // The other removal site. Client cleanup deletes daemon.json routinely, so this path must stay // silent: reporting an "absent" record here would create a daemon.log on every such startup. diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index e561d56290..ce6f247d2d 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -240,6 +240,20 @@ export async function flushDaemonStartupDiagnostics( ); } +async function noteSkippedProviderRuntimes( + logPath: string, + skipped: readonly Readonly<{ provider: string; error: AppError }>[] | undefined, +): Promise { + for (const { provider, error } of skipped ?? []) { + await emitDaemonDiagnostic(logPath, 'provider_runtime_skipped', { + provider, + code: error.code, + message: error.message, + hint: error.details?.hint, + }); + } +} + /** * Records one daemon-level event. These run outside any request, so there is no request scope and no * resolved debug level to inherit; debug is forced on for the same reason the #2681 handoff forces it — @@ -717,6 +731,7 @@ export async function startDaemonRuntime( publishDaemonInfo(socketPort, httpPort); stopMetadataLossWatch = armDaemonMetadataLossWatch(baseDir, infoPath, logPath, daemonIdentity); await flushDaemonStartupDiagnostics(logPath, startupDiagnostics); + await noteSkippedProviderRuntimes(logPath, providerComposition.skipped); // After publication: publishDaemonInfo truncates daemon.log, so anything // written before it is lost — including reconciliation diagnostics. await reconcileDeviceClaimsForDaemonStartup( diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 9626e395cc..3d2e0f8a3b 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -7,7 +7,9 @@ import type { PlatformRuntimeProviderModule, } from '@agent-device/contracts/platform-runtime-operations'; import type { PlatformRuntimeProviderRegistration } from './platform-runtime-gateway.ts'; +import { asAppError, type AppError } from '@agent-device/kernel/errors'; import { providerWebDriver } from './provider-webdriver.ts'; +import { readLimrunCredentials, type LimrunCredentials } from './provider-limrun-credentials.ts'; export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & NodeJS.ProcessEnv; @@ -20,6 +22,8 @@ export const DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS = [ export type DefaultProviderRuntimeComposition = Readonly<{ runtimes: readonly ProviderDeviceRuntime[]; platformModules: readonly PlatformRuntimeProviderRegistration[]; + /** Providers left out because their environment configuration is invalid; the rest still load. */ + skipped?: readonly Readonly<{ provider: string; error: AppError }>[]; }>; /** @@ -48,6 +52,7 @@ export async function createDaemonProviderRuntimeComposition( const { loadProviderPlugins } = await import('./plugins/load.ts'); const plugins = await loadProviderPlugins(env, DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS); return Object.freeze({ + ...bundled, runtimes: Object.freeze([...bundled.runtimes, ...plugins.map(({ runtime }) => runtime)]), platformModules: Object.freeze([ ...bundled.platformModules, @@ -65,18 +70,26 @@ export async function createDefaultProviderRuntimeComposition( ): Promise { const runtimes = providerWebDriver.createDefaultRuntimes(env); const platformModules = [...createProviderPlatformRuntimeRegistrations(runtimes)]; - const apiKey = env.LIMRUN_API_KEY?.trim(); - if (!apiKey) return Object.freeze({ runtimes, platformModules: Object.freeze(platformModules) }); + let limrunCredentials: LimrunCredentials | undefined; + try { + limrunCredentials = readLimrunCredentials(env); + } catch (error) { + return Object.freeze({ + runtimes, + platformModules: Object.freeze(platformModules), + skipped: [{ provider: 'limrun', error: asAppError(error) }], + }); + } + if (!limrunCredentials) { + return Object.freeze({ runtimes, platformModules: Object.freeze(platformModules) }); + } const [limrunRuntime, dependencies] = await Promise.all([ import('@agent-device/provider-limrun'), import('./sdk/limrun-runtime-dependencies.ts'), ]); const registration = limrunRuntime.createLimrunRuntime( - { - apiKey, - region: env.LIMRUN_REGION?.trim() || undefined, - }, + limrunCredentials, dependencies.createLimrunRuntimeDependencies(), { includePlatformModule: true }, ); diff --git a/src/provider-limrun-credentials.test.ts b/src/provider-limrun-credentials.test.ts new file mode 100644 index 0000000000..087ae58827 --- /dev/null +++ b/src/provider-limrun-credentials.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'vitest'; +import { readLimrunCredentials } from './provider-limrun-credentials.ts'; + +const IOS_ENV = { + LIM_IOS_INSTANCE_URL: ' https://region.limrun.example/v1/ios_x/api ', + LIM_IOS_INSTANCE_TOKEN: 'ios-token', +}; +const ANDROID_ENV = { + LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', + LIM_ANDROID_INSTANCE_TOKEN: 'android-token', + LIM_ANDROID_INSTANCE_ADB_URL: 'wss://region.limrun.example/v1/android_x/adb', +}; + +test('reads instance access with the lim CLI variable names', () => { + expect(readLimrunCredentials({ ...IOS_ENV, ...ANDROID_ENV, LIMRUN_REGION: 'eu' })).toEqual({ + apiKey: undefined, + region: 'eu', + keepAlive: false, + instances: { + ios: { apiUrl: 'https://region.limrun.example/v1/ios_x/api', token: 'ios-token' }, + android: { + apiUrl: 'https://region.limrun.example/v1/android_x/api', + token: 'android-token', + adbUrl: 'wss://region.limrun.example/v1/android_x/adb', + }, + }, + }); +}); + +test('reads the API key alone and ignores the removed key alias', () => { + expect(readLimrunCredentials({ LIMRUN_API_KEY: ' lim_key ' })).toEqual({ + apiKey: 'lim_key', + region: undefined, + keepAlive: false, + instances: undefined, + }); + expect(readLimrunCredentials({ LIM_API_KEY: 'lim_key', LIMRUN_REGION: 'eu' })).toBeUndefined(); +}); + +test('rejects partial instance access instead of creating a new instance', () => { + expect(() => + readLimrunCredentials({ LIMRUN_API_KEY: 'lim_key', LIM_IOS_INSTANCE_URL: 'https://x/api' }), + ).toThrow( + expect.objectContaining({ + code: 'INVALID_ARGS', + message: 'Limrun instance access is missing LIM_IOS_INSTANCE_TOKEN.', + }), + ); + const { LIM_ANDROID_INSTANCE_ADB_URL: _adbUrl, ...withoutAdbUrl } = ANDROID_ENV; + expect(() => readLimrunCredentials(withoutAdbUrl)).toThrow(/LIM_ANDROID_INSTANCE_ADB_URL/); +}); + +test('reads LIMRUN_KEEP_ALIVE as an opt-in flag that never registers a runtime alone', () => { + const keepAlive = (env: Record) => + readLimrunCredentials({ ...IOS_ENV, ...env })?.keepAlive; + expect(keepAlive({ LIMRUN_KEEP_ALIVE: '1' })).toBe(true); + expect(keepAlive({ LIMRUN_KEEP_ALIVE: ' TRUE ' })).toBe(true); + expect(keepAlive({ LIMRUN_KEEP_ALIVE: '0' })).toBe(false); + expect(keepAlive({})).toBe(false); + expect(readLimrunCredentials({ LIMRUN_KEEP_ALIVE: '1' })).toBeUndefined(); +}); diff --git a/src/provider-limrun-credentials.ts b/src/provider-limrun-credentials.ts new file mode 100644 index 0000000000..4fb205de0b --- /dev/null +++ b/src/provider-limrun-credentials.ts @@ -0,0 +1,61 @@ +import type { LimrunInstanceAccess } from '@agent-device/provider-limrun'; +import { AppError } from '@agent-device/kernel/errors'; +import type { EnvMap } from '@agent-device/kernel/source-value'; + +export type LimrunCredentials = Readonly<{ + apiKey?: string; + region?: string; + keepAlive?: boolean; + instances?: LimrunInstanceAccess; +}>; + +const INSTANCE_VARS = { + ios: ['LIM_IOS_INSTANCE_URL', 'LIM_IOS_INSTANCE_TOKEN'], + android: [ + 'LIM_ANDROID_INSTANCE_URL', + 'LIM_ANDROID_INSTANCE_TOKEN', + 'LIM_ANDROID_INSTANCE_ADB_URL', + ], +} as const; + +/** The variables that give access to an existing instance of a platform. */ +export function limrunInstanceVariables(platform: 'ios' | 'android'): readonly string[] { + return INSTANCE_VARS[platform]; +} + +/** + * The one reader of Limrun credentials in the environment. Instance variables use the `lim` CLI + * names, so an orchestrator hands a sandbox one set of variables for both tools. + */ +export function readLimrunCredentials(env: EnvMap): LimrunCredentials | undefined { + const apiKey = env.LIMRUN_API_KEY?.trim() || undefined; + const region = env.LIMRUN_REGION?.trim() || undefined; + const keepAlive = ['1', 'true'].includes(env.LIMRUN_KEEP_ALIVE?.trim().toLowerCase() ?? ''); + const ios = readInstanceVars(env, INSTANCE_VARS.ios); + const android = readInstanceVars(env, INSTANCE_VARS.android); + if (!apiKey && !ios && !android) return undefined; + const instances: LimrunInstanceAccess = { + ios: ios && { apiUrl: ios.LIM_IOS_INSTANCE_URL, token: ios.LIM_IOS_INSTANCE_TOKEN }, + android: android && { + apiUrl: android.LIM_ANDROID_INSTANCE_URL, + token: android.LIM_ANDROID_INSTANCE_TOKEN, + adbUrl: android.LIM_ANDROID_INSTANCE_ADB_URL, + }, + }; + return { apiKey, region, keepAlive, instances: ios || android ? instances : undefined }; +} + +function readInstanceVars( + env: EnvMap, + names: readonly Name[], +): Readonly> | undefined { + const entries = names.map((name) => [name, env[name]?.trim() || undefined] as const); + if (entries.every(([, value]) => value === undefined)) return undefined; + const missing = entries.filter(([, value]) => value === undefined).map(([name]) => name); + if (missing.length > 0) { + throw new AppError('INVALID_ARGS', `Limrun instance access is missing ${missing.join(', ')}.`, { + hint: `Set ${names.join(', ')} together from the instance status, or unset them all.`, + }); + } + return Object.fromEntries(entries) as Record; +} diff --git a/src/sdk/limrun.ts b/src/sdk/limrun.ts index b5a33f35a4..0c0ac71ed9 100644 --- a/src/sdk/limrun.ts +++ b/src/sdk/limrun.ts @@ -1,6 +1,7 @@ export { LimrunRuntime, type LimrunRuntimeOptions } from '../provider-limrun-runtime.ts'; export type { LimrunAndroidDeviceSession, LimrunDeviceSession } from './limrun-runtime-types.ts'; export type { + LimrunInstanceAccess, LimrunIosCommandExecution, LimrunIosDeviceSession, } from '@agent-device/provider-limrun'; diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index a09535570d..65bcf9a235 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -18,6 +18,35 @@ agent-device connect limrun --platform android Pass `android` or `ios` to select the instance type. `connect` verifies the selected service without creating an instance. +## Drive an existing instance + +An agent in a sandbox can drive an instance that someone else created, such as the instance behind a live preview, without your organization API key. Create the instance where the API key lives, then give the sandbox that instance's own URL and token from its `status`: + +| Platform | Environment variables | +| --- | --- | +| iOS | `LIM_IOS_INSTANCE_URL` (`status.apiUrl`), `LIM_IOS_INSTANCE_TOKEN` (`status.token`) | +| Android | `LIM_ANDROID_INSTANCE_URL` (`status.apiUrl`), `LIM_ANDROID_INSTANCE_TOKEN` (`status.token`), `LIM_ANDROID_INSTANCE_ADB_URL` (`status.adbWebSocketUrl`) | + +These are the same variables the `lim` CLI reads, so one set works for both tools. + +```bash +export LIM_IOS_INSTANCE_URL=... +export LIM_IOS_INSTANCE_TOKEN=... + +agent-device connect limrun --platform ios +agent-device open com.example.app +agent-device snapshot -i +agent-device disconnect +``` + +`connect` checks the instance credentials. agent-device never creates or deletes that instance: `disconnect` leaves it running, and its owner deletes it. When the variables for a platform are set, they take precedence over `LIMRUN_API_KEY` for that platform. + +`install`, and `apps` before the first `open`, still need `LIMRUN_API_KEY`, because they use Limrun asset storage. After `open`, `apps` lists the apps installed on the instance without the key. Install the app before you hand over the instance. From the Node.js runtime, `getDeviceSession(device).installRemoteApp(url)` installs from a signed asset URL without the API key. + +## Keeping idle sessions alive + +Limrun ends an instance after its inactivity timeout, which an idle session reaches while a model thinks between steps. Set `LIMRUN_KEEP_ALIVE=1` (or `true`) to ping the instance every 30 seconds while a session is open. It is off by default, so instance lifetime is unchanged unless you ask for it. In the Node.js runtime, pass `keepAlive: true`. + ## CLI workflow A new Limrun instance does not contain your app. Run `install ` before `open`. The install command allocates the instance when needed, so you do not need to run `devices` first. @@ -58,8 +87,10 @@ const runtime = new LimrunRuntime({ }); ``` +To drive existing instances, pass `instances: { ios: { apiUrl, token } }` (Android also takes `adbUrl`), with or without `apiKey`. A platform listed there never creates or deletes instances. + After allocating a lease, an embedding bridge can call `runtime.getDeviceSession(device)` for the allocated device's reusable semantic capabilities. The facade includes app inventory, foreground state where Limrun exposes it, key input, bounded log reads, recording, remote asset installation, and the existing interactor. Android also exposes agent-device's `AndroidAdbProvider` abstraction for helpers and reversible port forwarding. iOS exposes a typed `simctl` execution handle for bridge-owned runner lifecycle and launch policy. Raw Limrun clients remain private to the provider runtime. ## Artifacts and troubleshooting -Limrun does not currently expose provider artifacts through `agent-device artifacts`. If connect fails, check `LIMRUN_API_KEY` and the optional `LIMRUN_REGION`. +Limrun does not currently expose provider artifacts through `agent-device artifacts`. If connect fails, check `LIMRUN_API_KEY` and the optional `LIMRUN_REGION`. For an existing instance, check its URL and token variables, and that the instance is still running.