From 6937e2da7fb890f92cd10d88d76e019618c579b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 27 Jun 2026 14:15:23 +0200 Subject: [PATCH 01/13] feat: add direct Limrun cloud runtime --- package.json | 1 + pnpm-lock.yaml | 82 ++ src/__tests__/cli-react-devtools.test.ts | 50 ++ src/__tests__/cloud-connect-profile.test.ts | 142 ++- src/__tests__/limrun-runtime.test.ts | 480 ++++++++++ src/cli/commands/connection.ts | 13 +- src/cli/commands/react-devtools.ts | 18 + src/cli/connection/limrun-profile.ts | 58 ++ src/cli/connection/profile-fields.ts | 1 + src/cli/connection/provider-policy.ts | 14 +- src/daemon/lease-registry.ts | 11 +- src/daemon/request-router.ts | 2 +- src/daemon/server/daemon-runtime.ts | 7 +- src/daemon/types.ts | 1 + src/providers/limrun/device.ts | 50 ++ src/providers/limrun/runtime.ts | 948 ++++++++++++++++++++ src/providers/limrun/snapshot.ts | 147 +++ src/remote/remote-config-schema.ts | 2 + 18 files changed, 2016 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/limrun-runtime.test.ts create mode 100644 src/cli/connection/limrun-profile.ts create mode 100644 src/providers/limrun/device.ts create mode 100644 src/providers/limrun/runtime.ts create mode 100644 src/providers/limrun/snapshot.ts diff --git a/package.json b/package.json index 422fa7cc0..b8ff62c18 100644 --- a/package.json +++ b/package.json @@ -225,6 +225,7 @@ "detox" ], "dependencies": { + "@limrun/api": "^0.24.5", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f378c51ac..d16213c19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@limrun/api': + specifier: ^0.24.5 + version: 0.24.5 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -158,6 +161,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@limrun/api@0.24.5': + resolution: {integrity: sha512-bgIs+KcP9xITj1v2gMe8wJrvqPakl1PlXtvxlO6Akci8ijU7V27RWQAq7JBaA89AzwUb5EvLc+nmAMMiKl/g1A==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -1278,6 +1284,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} @@ -1440,6 +1450,14 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventsource-client@1.2.0: + resolution: {integrity: sha512-kDI75RSzO3TwyG/K9w1ap8XwqSPcwi6jaMkNulfVeZmSeUM49U8kUzk1s+vKNt0tGrXgK47i+620Yasn1ccFiw==} + engines: {node: '>=18.0.0'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1529,6 +1547,14 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + import-without-cache@0.4.0: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} @@ -1935,6 +1961,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -2246,6 +2276,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} + engines: {node: '>=20.18.1'} + unhead@2.1.15: resolution: {integrity: sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA==} @@ -2377,6 +2411,18 @@ packages: engines: {node: '>=8'} hasBin: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -2505,6 +2551,19 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@limrun/api@0.24.5': + dependencies: + eventsource-client: 1.2.0 + https-proxy-agent: 7.0.6 + ignore: 7.0.6 + proxy-from-env: 2.1.0 + undici: 7.24.7 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 @@ -3291,6 +3350,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ansi-regex@6.2.2: {} ansis@4.3.1: {} @@ -3422,6 +3483,12 @@ snapshots: dependencies: '@types/estree': 1.0.8 + eventsource-client@1.2.0: + dependencies: + eventsource-parser: 3.1.0 + + eventsource-parser@3.1.0: {} + expect-type@1.3.0: {} extend@3.0.2: {} @@ -3584,6 +3651,15 @@ snapshots: html-void-elements@3.0.0: {} + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ignore@7.0.6: {} + import-without-cache@0.4.0: {} inline-style-parser@0.2.7: {} @@ -4275,6 +4351,8 @@ snapshots: property-information@7.1.0: {} + proxy-from-env@2.1.0: {} + quansync@1.0.0: {} react-dom@19.2.7(react@19.2.7): @@ -4654,6 +4732,8 @@ snapshots: undici-types@6.21.0: {} + undici@7.24.7: {} + unhead@2.1.15: dependencies: hookable: 6.1.0 @@ -4767,6 +4847,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.21.0: {} + yaml@2.9.0: {} yuku-ast@0.1.7: diff --git a/src/__tests__/cli-react-devtools.test.ts b/src/__tests__/cli-react-devtools.test.ts index d3ac0549b..c11476d7d 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -235,6 +235,56 @@ test('react-devtools stop cleans up remote companion', async () => { }); }); +test('react-devtools start configures direct remote port reverse', async () => { + const configureDirectPortReverse = vi.fn(async () => undefined); + vi.mocked(runCmdStreaming).mockResolvedValueOnce({ + exitCode: 0, + stdout: '', + stderr: '', + }); + + const exitCode = await runReactDevtoolsCommand(['start'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + flags: { + ...remoteBridgeScope, + metroProxyBaseUrl: undefined, + leaseBackend: 'android-instance', + remoteConfig: '/tmp/remote.json', + session: 'default', + }, + configureDirectPortReverse, + }); + + assert.equal(exitCode, 0); + assert.equal(configureDirectPortReverse.mock.calls.length, 1); + assertNoRemoteCompanion(); +}); + +test('react-devtools start keeps bridge-backed Android on companion tunnel', async () => { + const env = { ...process.env }; + const configureDirectPortReverse = vi.fn(async () => undefined); + mockRemoteCompanionSuccess(); + + const exitCode = await runReactDevtoolsCommand(['start'], { + stateDir: '/tmp/agent-device-state', + session: 'default', + cwd: '/tmp/project', + env, + flags: { + ...remoteBridgeScope, + leaseBackend: 'android-instance', + remoteConfig: '/tmp/remote.json', + session: 'default', + }, + configureDirectPortReverse, + }); + + assert.equal(exitCode, 0); + assert.equal(configureDirectPortReverse.mock.calls.length, 0); + assertRemoteCompanionStarted(env); +}); + test('react-devtools skips companion for non-bridge remote sessions', async () => { await runStatusWithoutCompanion({ ...remoteBridgeScope, diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index d7ca31d17..3e347fb68 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -55,6 +55,138 @@ test('connect without remote config generates one from cloud connection profile' } }); +test('connect limrun generates a local daemon remote profile', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-limrun-')); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('LIMRUN_API_KEY', 'lim_test_key'); + + try { + await captureConnectStdout(async () => { + await connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + platform: 'android', + tenant: 'team-a', + runId: 'run-a', + session: 'limrun-android', + }, + client: {} as AgentDeviceClient, + }); + }); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.session, 'limrun-android'); + assert.equal(state.tenant, 'team-a'); + assert.equal(state.runId, 'run-a'); + assert.equal(state.leaseBackend, 'android-instance'); + assert.equal(state.leaseProvider, 'limrun'); + assert.equal(state.platform, 'android'); + assert.equal(state.daemon?.baseUrl, undefined); + assert.match( + state.remoteConfigPath, + /remote-connections\/generated\/limrun-[a-f0-9]{16}\.json$/, + ); + assert.deepEqual(readGeneratedConfigKeys(state.remoteConfigPath), [ + 'daemonTransport', + 'leaseBackend', + 'leaseProvider', + 'platform', + 'runId', + 'session', + 'sessionIsolation', + 'stateDir', + 'target', + 'tenant', + ]); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect limrun persists deferred Metro bridge settings', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-limrun-metro-')); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('LIMRUN_API_KEY', 'lim_test_key'); + + try { + await captureConnectStdout(async () => { + await connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + platform: 'ios', + tenant: 'team-a', + runId: 'run-a', + session: 'limrun-ios', + metroProjectRoot: '/tmp/app', + metroKind: 'expo', + metroProxyBaseUrl: 'https://metro.agent-device.dev', + metroBearerToken: 'adc_live_test', + metroPreparePort: 8082, + launchUrl: 'exp://127.0.0.1:8082', + }, + client: {} as AgentDeviceClient, + }); + }); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.leaseBackend, 'ios-instance'); + assert.equal(state.leaseProvider, 'limrun'); + assert.equal(state.platform, 'ios'); + assert.deepEqual(readGeneratedConfig(state.remoteConfigPath), { + daemonTransport: 'auto', + launchUrl: 'exp://127.0.0.1:8082', + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + metroKind: 'expo', + metroPreparePort: 8082, + metroProjectRoot: '/tmp/app', + metroProxyBaseUrl: 'https://metro.agent-device.dev', + platform: 'ios', + runId: 'run-a', + session: 'limrun-ios', + sessionIsolation: 'tenant', + stateDir, + target: 'mobile', + tenant: 'team-a', + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect limrun requires LIMRUN_API_KEY', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-limrun-env-')); + const stateDir = path.join(tempRoot, '.state'); + vi.stubEnv('LIMRUN_API_KEY', ''); + vi.stubEnv('LIM_API_KEY', ''); + + try { + await assert.rejects( + connectCommand({ + positionals: ['limrun'], + flags: { + json: true, + help: false, + version: false, + stateDir, + }, + client: {} as AgentDeviceClient, + }), + /connect limrun requires LIMRUN_API_KEY/, + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('connect without remote config rejects legacy remoteConfig string profile response', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-legacy-')); const stateDir = path.join(tempRoot, '.state'); @@ -251,8 +383,7 @@ function fetchProfileUrl(fetchMock: ReturnType): string | undefine } async function connectWithGeneratedCloudProfile(stateDir: string): Promise { - const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - try { + await captureConnectStdout(async () => { await connectCommand({ positionals: [], flags: { @@ -263,6 +394,13 @@ async function connectWithGeneratedCloudProfile(stateDir: string): Promise }, client: {} as AgentDeviceClient, }); + }); +} + +async function captureConnectStdout(task: () => Promise): Promise { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await task(); } finally { stdoutWrite.mockRestore(); } diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts new file mode 100644 index 000000000..e5de84590 --- /dev/null +++ b/src/__tests__/limrun-runtime.test.ts @@ -0,0 +1,480 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { LimrunRuntime, readLocalhostUrlPort } from '../providers/limrun/runtime.ts'; +import type { SimulatorLease } from '../daemon/lease-registry.ts'; +import type { DeviceInfo } from '../kernel/device.ts'; +import { runCmd } from '../utils/exec.ts'; + +const limrunMockState = vi.hoisted(() => { + const androidTunnelClose = vi.fn(); + return { + constructorOptions: [] as Array<{ defaultHeaders?: Record }>, + assetsGetOrUpload: vi.fn(async () => ({ + signedDownloadUrl: 'https://assets.example/app', + md5: 'asset-md5', + })), + iosInstallApp: vi.fn(async () => ({ + bundleId: 'com.example.ios', + url: 'https://assets.example/app', + })), + androidOpenUrl: vi.fn(async () => undefined), + androidDisconnect: vi.fn(), + androidSendAsset: vi.fn(async () => undefined), + androidTunnelClose, + androidStartAdbTunnel: vi.fn(async () => ({ + address: { address: '127.0.0.1', port: 62_001 }, + close: androidTunnelClose, + })), + iosCreate: vi.fn(async () => ({ + metadata: { id: 'ios-instance-1' }, + status: { token: 'instance-token', apiUrl: 'https://ios.example' }, + })), + iosDelete: vi.fn(async () => undefined), + androidCreate: vi.fn(async () => ({ + metadata: { id: 'android-instance-1' }, + status: { + token: 'instance-token', + apiUrl: 'https://android.example', + adbWebSocketUrl: 'wss://adb.example', + }, + })), + androidDelete: vi.fn(async () => undefined), + }; +}); + +vi.mock('@limrun/api', () => ({ + default: class MockLimrun { + readonly iosInstances = { + create: limrunMockState.iosCreate, + delete: limrunMockState.iosDelete, + }; + + readonly androidInstances = { + create: limrunMockState.androidCreate, + delete: limrunMockState.androidDelete, + }; + + readonly assets = { + getOrUpload: limrunMockState.assetsGetOrUpload, + }; + + constructor(options: { defaultHeaders?: Record }) { + limrunMockState.constructorOptions.push(options); + } + }, +})); + +vi.mock('@limrun/api/ios-client', () => ({ + createInstanceClient: vi.fn(async () => ({ + disconnect: vi.fn(), + installApp: limrunMockState.iosInstallApp, + })), +})); + +vi.mock('@limrun/api/instance-client', () => ({ + createInstanceClient: vi.fn(async () => ({ + disconnect: limrunMockState.androidDisconnect, + openUrl: limrunMockState.androidOpenUrl, + sendAsset: limrunMockState.androidSendAsset, + startAdbTunnel: limrunMockState.androidStartAdbTunnel, + })), +})); + +vi.mock('../utils/exec.ts', () => ({ + runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), +})); + +afterEach(() => { + limrunMockState.constructorOptions.length = 0; + vi.clearAllMocks(); +}); + +test('Limrun runtime identifies direct CLI usage to the Limrun API', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + + const lease: SimulatorLease = { + leaseId: 'lease-a', + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await allocateLease(lease); + + assert.deepEqual(limrunMockState.constructorOptions[0]?.defaultHeaders, { + 'x-agent-device-client': 'agent-device-cli', + 'x-agent-device-version': '9.9.9-test', + }); + const iosCreateCalls = limrunMockState.iosCreate.mock.calls as unknown as Array< + [{ metadata?: { labels?: Record } }] + >; + assert.deepEqual(iosCreateCalls[0]?.[0].metadata?.labels, { + tenantId: 'team-a', + runId: 'run-a', + leaseId: 'lease-a', + provider: 'limrun', + source: 'agent-device-cli', + }); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun Android reverses localhost URL ports through the persistent ADB tunnel', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + + try { + const device = await allocateLimrunDevice(runtime, androidLease()); + const interactor = runtime.getInteractor(device); + if (!interactor) throw new Error('Limrun runtime must return an interactor'); + + await interactor.open('exp://127.0.0.1:8081'); + await runtime.shutdown(); + + assertAndroidTunnelLifecycle('exp://127.0.0.1:8081'); + } finally { + await runtime.shutdown(); + } +}); + +function androidLease(): SimulatorLease { + return { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; +} + +async function allocateLimrunDevice( + runtime: LimrunRuntime, + lease: SimulatorLease, +): Promise { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + const allocated = await allocateLease(lease); + const device = allocated?.device; + if (!device || typeof device !== 'object') { + throw new Error('Limrun runtime must return allocated device metadata'); + } + return device as DeviceInfo; +} + +function assertAndroidTunnelLifecycle(openUrl: string): void { + assert.equal(limrunMockState.androidStartAdbTunnel.mock.calls.length, 1); + const androidOpenUrlCalls = limrunMockState.androidOpenUrl.mock.calls as unknown as Array< + [string] + >; + assert.equal(androidOpenUrlCalls[0]?.[0], openUrl); + assert.deepEqual(vi.mocked(runCmd).mock.calls[0]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + 'tcp:8081', + 'tcp:8081', + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[1]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + '--remove', + 'tcp:8081', + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[2]?.[1], ['disconnect', '127.0.0.1:62001']); + assert.equal(limrunMockState.androidTunnelClose.mock.calls.length, 1); +} + +test('Limrun Android installs direct local artifacts through Limrun assets', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + const allocated = await allocateLease(lease); + const device = allocated?.device; + if (!device || typeof device !== 'object') { + throw new Error('Limrun runtime must return allocated device metadata'); + } + const allocatedDevice = device as Parameters[0]; + assert.equal(allocatedDevice.platform, 'android'); + assert.equal(allocatedDevice.id, 'limrun:android:lease-android'); + + const result = await runtime.installApp( + allocatedDevice, + 'com.example.android', + '/tmp/app-debug.apk', + ); + + const assetCalls = limrunMockState.assetsGetOrUpload.mock.calls as unknown as Array< + [Record] + >; + assert.deepEqual(assetCalls[0]?.[0], { + path: '/tmp/app-debug.apk', + name: 'com.example.android.apk', + }); + assert.deepEqual(limrunMockState.androidSendAsset.mock.calls[0], [ + 'https://assets.example/app', + ]); + assert.deepEqual(result, { + packageName: 'com.example.android', + launchTarget: 'com.example.android', + appName: 'android', + }); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun iOS installs direct local artifacts through Limrun assets', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-limrun-install-')); + const ipaPath = path.join(tempRoot, 'Demo.ipa'); + fs.writeFileSync(ipaPath, 'demo'); + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-ios', + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + const allocated = await allocateLease(lease); + const device = allocated?.device; + if (!device || typeof device !== 'object') { + throw new Error('Limrun runtime must return allocated device metadata'); + } + + const result = await runtime.installApp( + device as Parameters[0], + 'com.example.ios', + ipaPath, + { relaunch: true }, + ); + + const assetCalls = limrunMockState.assetsGetOrUpload.mock.calls as unknown as Array< + [Record] + >; + assert.deepEqual(assetCalls[0]?.[0], { + path: ipaPath, + name: 'Demo.ipa', + }); + assert.deepEqual(limrunMockState.iosInstallApp.mock.calls[0], [ + 'https://assets.example/app', + { md5: 'asset-md5', launchMode: 'RelaunchIfRunning' }, + ]); + assert.deepEqual(result, { + bundleId: 'com.example.ios', + launchTarget: 'com.example.ios', + appName: 'Demo', + }); + } finally { + await runtime.shutdown(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('Limrun Android configures and removes an explicit port reverse', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await allocateLease(lease); + + assert.deepEqual( + await runtime.configurePortReverse({ + leaseId: lease.leaseId, + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }), + { + leaseId: lease.leaseId, + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }, + ); + await runtime.removePortReverse({ + leaseId: lease.leaseId, + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }); + + assert.deepEqual(vi.mocked(runCmd).mock.calls[0]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + 'tcp:8097', + 'tcp:8097', + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[1]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + '--remove', + 'tcp:8097', + ]); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun deletes iOS instance when post-create validation fails', async () => { + limrunMockState.iosCreate.mockResolvedValueOnce({ + metadata: { id: 'ios-instance-missing-api' }, + status: { token: 'instance-token' }, + } as never); + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-ios', + tenantId: 'team-a', + runId: 'run-a', + backend: 'ios-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await assert.rejects(() => allocateLease(lease), /did not expose apiUrl/); + assert.deepEqual(limrunMockState.iosDelete.mock.calls[0], ['ios-instance-missing-api']); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun deletes Android instance when post-create validation fails', async () => { + limrunMockState.androidCreate.mockResolvedValueOnce({ + metadata: { id: 'android-instance-missing-adb' }, + status: { + token: 'instance-token', + apiUrl: 'https://android.example', + }, + } as never); + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + if (!allocateLease) throw new Error('Limrun runtime must provide lease allocation'); + await assert.rejects(() => allocateLease(lease), /did not expose API and ADB/); + assert.deepEqual(limrunMockState.androidDelete.mock.calls[0], ['android-instance-missing-adb']); + } finally { + await runtime.shutdown(); + } +}); + +test('Limrun keeps session tracked when release fails so release can be retried', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + const lease: SimulatorLease = { + leaseId: 'lease-android', + tenantId: 'team-a', + runId: 'run-a', + backend: 'android-instance', + leaseProvider: 'limrun', + createdAt: 1, + heartbeatAt: 1, + expiresAt: 60_001, + }; + + try { + const allocateLease = runtime.leaseLifecycle.allocate; + const releaseLease = runtime.leaseLifecycle.release; + if (!allocateLease || !releaseLease) { + throw new Error('Limrun runtime must provide lease lifecycle hooks'); + } + await allocateLease(lease); + limrunMockState.androidDelete.mockRejectedValueOnce(new Error('temporary delete failure')); + + await assert.rejects(() => releaseLease(lease), /temporary delete failure/); + assert.deepEqual(await releaseLease(lease), { limrunInstanceId: 'android-instance-1' }); + } finally { + await runtime.shutdown(); + } +}); + +test('readLocalhostUrlPort recognizes loopback URLs only', () => { + assert.equal(readLocalhostUrlPort('exp://127.0.0.1:8081'), 8081); + assert.equal(readLocalhostUrlPort('http://localhost:19000/status'), 19000); + assert.equal(readLocalhostUrlPort('http://[::1]:8097'), 8097); + assert.equal(readLocalhostUrlPort('https://metro.agent-device.dev/status'), undefined); + assert.equal(readLocalhostUrlPort('not a url'), undefined); +}); diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 9de6cc035..54f97caa7 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -19,11 +19,12 @@ import { connectProviderNamesForError, connectionProviderLeaseKind, connectionProviderRequiresRemoteDaemon, + isCloudWebDriverConnectProvider, isConnectProviderName, - isDirectDeviceConnectProvider, type ConnectProvider, } from '../connection/provider-policy.ts'; import { resolveCloudWebDriverConnectProfile } from '../connection/cloud-webdriver-profile.ts'; +import { resolveLimrunConnectProfile } from '../connection/limrun-profile.ts'; import { resolveProxyConnectProfile } from '../connection/proxy-profile.ts'; import { hasDeferredMetroConfig, @@ -93,7 +94,7 @@ async function resolveConnectProfile(options: { }): Promise<{ flags: CliFlags; remoteConfigPath: string }> { const { provider, flags, stateDir } = options; if (flags.remoteConfig) return resolveRemoteConnectFlags(flags); - if (isDirectDeviceConnectProvider(provider)) { + if (isCloudWebDriverConnectProvider(provider)) { return resolveCloudWebDriverConnectProfile({ provider, flags, @@ -110,6 +111,14 @@ async function resolveConnectProfile(options: { env: process.env, }); } + if (provider === 'limrun') { + return resolveLimrunConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); + } return await resolveCloudConnectProfile({ flags, stateDir, diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 2a95f89a4..9cee03516 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -27,6 +27,7 @@ type ReactDevtoolsCommandOptions = { session?: string; cwd?: string; env?: NodeJS.ProcessEnv; + configureDirectPortReverse?: () => Promise; }; type RemoteBridgeConfig = { @@ -155,6 +156,20 @@ async function withRemoteDevtoolsCompanion( return await action(); } +function shouldConfigureDirectReverse( + args: string[], + options: ReactDevtoolsCommandOptions, +): boolean { + if (args[0] !== 'start') return false; + const { flags } = options; + if (!flags) return false; + return ( + isRemoteBridgeBackend(flags.leaseBackend) && + flags.metroProxyBaseUrl === undefined && + options.configureDirectPortReverse !== undefined + ); +} + export async function runReactDevtoolsCommand( args: string[], options: ReactDevtoolsCommandOptions = {}, @@ -162,6 +177,9 @@ export async function runReactDevtoolsCommand( const cwd = options.cwd ?? process.cwd(); const env = options.env ?? process.env; const exitCode = await withRemoteDevtoolsCompanion(args, options, async () => { + if (shouldConfigureDirectReverse(args, options)) { + await options.configureDirectPortReverse?.(); + } const result = await runCmdStreaming('npm', buildReactDevtoolsNpmExecArgs(args), { cwd, env, diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts new file mode 100644 index 000000000..29a4586c3 --- /dev/null +++ b/src/cli/connection/limrun-profile.ts @@ -0,0 +1,58 @@ +import { resolveDaemonPaths } from '../../daemon/config.ts'; +import type { RemoteConfigProfile } from '../../remote/remote-config-schema.ts'; +import { AppError } from '../../kernel/errors.ts'; +import type { CliFlags } from '../parser/cli-flags.ts'; +import type { EnvMap } from '../../utils/env-map.ts'; +import { readMetroProfileFields } from './profile-fields.ts'; +import { persistAndResolveGeneratedProfile } from './generated-config.ts'; +import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts'; + +const DEFAULT_LIMRUN_TENANT = 'limrun'; + +export function resolveLimrunConnectProfile(options: { + flags: CliFlags; + stateDir: string; + cwd: string; + env?: EnvMap; +}): { flags: CliFlags; remoteConfigPath: string } { + const env = options.env ?? process.env; + const apiKey = env.LIMRUN_API_KEY?.trim() || env.LIM_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 }); + return persistAndResolveGeneratedProfile({ + stateDir: options.stateDir, + provider: 'limrun', + profile, + cwd: options.cwd, + env, + flags: options.flags, + }); +} + +function buildLimrunRemoteProfile(options: { flags: CliFlags }): RemoteConfigProfile { + const flags = options.flags; + const daemonPaths = resolveDaemonPaths(flags.stateDir); + return { + stateDir: daemonPaths.baseDir, + daemonTransport: 'auto', + tenant: flags.tenant ?? DEFAULT_LIMRUN_TENANT, + runId: flags.runId ?? `cli-${Date.now().toString(36)}`, + sessionIsolation: 'tenant', + leaseBackend: resolveRequestedLeaseBackend(flags), + leaseProvider: 'limrun', + platform: flags.platform, + target: flags.target ?? 'mobile', + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + session: flags.session, + ...readMetroProfileFields(flags), + }; +} diff --git a/src/cli/connection/profile-fields.ts b/src/cli/connection/profile-fields.ts index b323a322c..adf3ab89a 100644 --- a/src/cli/connection/profile-fields.ts +++ b/src/cli/connection/profile-fields.ts @@ -15,5 +15,6 @@ export function readMetroProfileFields(flags: CliFlags): RemoteConfigMetroOption metroRuntimeFile: flags.metroRuntimeFile, metroNoReuseExisting: flags.metroNoReuseExisting, metroNoInstallDeps: flags.metroNoInstallDeps, + launchUrl: flags.launchUrl, }; } diff --git a/src/cli/connection/provider-policy.ts b/src/cli/connection/provider-policy.ts index 6941f5e6e..9c14812b3 100644 --- a/src/cli/connection/provider-policy.ts +++ b/src/cli/connection/provider-policy.ts @@ -4,13 +4,20 @@ import { type CloudWebDriverKnownProviderName, } from '../../cloud-webdriver/providers.ts'; -export type ConnectProvider = 'cloud' | 'proxy' | CloudWebDriverKnownProviderName; +export type DirectDeviceConnectProvider = CloudWebDriverKnownProviderName | 'limrun'; +export type ConnectProvider = 'cloud' | 'proxy' | DirectDeviceConnectProvider; export function isConnectProviderName(value: string | undefined): value is ConnectProvider { - return value === 'cloud' || value === 'proxy' || isCloudWebDriverProviderName(value); + return value === 'cloud' || value === 'proxy' || isDirectDeviceConnectProvider(value); } -export function isDirectDeviceConnectProvider( +function isDirectDeviceConnectProvider( + provider: string | undefined, +): provider is DirectDeviceConnectProvider { + return provider === 'limrun' || isCloudWebDriverProviderName(provider); +} + +export function isCloudWebDriverConnectProvider( provider: string | undefined, ): provider is CloudWebDriverKnownProviderName { return isCloudWebDriverProviderName(provider); @@ -22,6 +29,7 @@ export function connectProviderNamesForError(): string { 'proxy', CLOUD_WEBDRIVER_PROVIDERS.browserStack, CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, + 'limrun', ].join(', '); } diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index b41afd603..cda11fb7c 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -24,6 +24,7 @@ export type LeaseRegistryOptions = { minLeaseTtlMs?: number; maxLeaseTtlMs?: number; now?: () => number; + onLeaseExpired?: (lease: DeviceLease) => void; }; export type AllocateLeaseRequest = { @@ -214,6 +215,7 @@ export class LeaseRegistry { private readonly minLeaseTtlMs: number; private readonly maxLeaseTtlMs: number; private readonly now: () => number; + private readonly onLeaseExpired?: (lease: DeviceLease) => void; constructor(options: LeaseRegistryOptions = {}) { this.maxActiveSimulatorLeases = Number.isInteger(options.maxActiveSimulatorLeases) @@ -229,6 +231,7 @@ export class LeaseRegistry { ? Math.max(this.minLeaseTtlMs, Number(options.maxLeaseTtlMs)) : MAX_LEASE_TTL_MS; this.now = options.now ?? (() => Date.now()); + this.onLeaseExpired = options.onLeaseExpired; } allocateLease(request: AllocateLeaseRequest): DeviceLease { @@ -345,7 +348,9 @@ export class LeaseRegistry { if (lease.expiresAt > now) continue; this.leases.delete(lease.leaseId); this.unbindLease(lease); - expired.push({ ...lease }); + const expiredLease = { ...lease }; + expired.push(expiredLease); + this.onLeaseExpired?.(expiredLease); } return expired; } @@ -357,7 +362,9 @@ export class LeaseRegistry { if (!lease || lease.expiresAt > this.now()) return undefined; this.leases.delete(lease.leaseId); this.unbindLease(lease); - return { ...lease }; + const expiredLease = { ...lease }; + this.onLeaseExpired?.(expiredLease); + return expiredLease; } private cleanupExpiredLeases(): void { diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index ceaa165fb..b8bee203d 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -34,6 +34,7 @@ import { loadGenericRequestHandlerModule, runRequestHandlerChain, } from './request-handler-chain.ts'; +import type { LeaseLifecycleProvider } from './handlers/lease.ts'; import { createRequestExecutionScope, type LockedRequestScope, @@ -43,7 +44,6 @@ import { import { buildRequestFinishedEvent, shouldRecordEventForRequest } from './session-event-log.ts'; import { canRunReplayScopedAction } from './daemon-command-registry.ts'; import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts'; -import type { LeaseLifecycleProvider } from './handlers/lease.ts'; import { openWebSessionNames } from './web-session-names.ts'; // --------------------------------------------------------------------------- diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 7b69f38ee..62259f1c4 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -7,9 +7,11 @@ import { createDaemonHttpServer } from './http-server.ts'; import { trackDownloadableArtifact } from '../artifact-tracking.ts'; import { createDefaultCloudArtifactProvider } from '../../default-cloud-artifact-provider.ts'; import { createDefaultCloudWebDriverProviderRuntimes } from '../../cloud-webdriver/provider-runtimes.ts'; +import { createLimrunRuntimeFromEnv } from '../../providers/limrun/runtime.ts'; import { composeCloudArtifactProviders, createProviderDeviceRuntimeRequestProviders, + type ProviderDeviceRuntime, } from '../../provider-device-runtime.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createRequestHandler } from '../request-router.ts'; @@ -98,7 +100,10 @@ export async function startDaemonRuntime( const token = crypto.randomBytes(24).toString('hex'); const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; const daemonCodeSignature = resolveDaemonCodeSignature(); - const providerDeviceRuntimes = createDefaultCloudWebDriverProviderRuntimes(env); + const providerDeviceRuntimes = [ + ...createDefaultCloudWebDriverProviderRuntimes(env), + createLimrunRuntimeFromEnv(env), + ].filter((runtime): runtime is ProviderDeviceRuntime => Boolean(runtime)); const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders(providerDeviceRuntimes); const cloudArtifactProvider = composeCloudArtifactProviders( diff --git a/src/daemon/types.ts b/src/daemon/types.ts index d832e81c1..ef3cbcb08 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -51,6 +51,7 @@ type DaemonRequestMeta = Omit>; + +type LimrunInstance = { + metadata: { id: string }; + status: { + token: string; + apiUrl?: string; + adbWebSocketUrl?: string; + state?: string; + }; +}; + +type LimrunRuntimeSession = + | { + platform: 'ios'; + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + client: LimrunIosClient; + } + | { + platform: 'android'; + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + client: LimrunAndroidClient; + adbTunnel?: LimrunAdbTunnel; + adbSerial?: string; + reversedPorts: Map; + }; + +type LimrunRuntimeOptions = { + apiKey: string; + region?: string; + version?: string; +}; + +const LIMRUN_CLIENT_HEADER = 'agent-device-cli'; +const SETTINGS_INTENT = 'android.settings.SETTINGS'; + +export function createLimrunRuntimeFromEnv(env: NodeJS.ProcessEnv): LimrunRuntime | undefined { + const apiKey = env.LIMRUN_API_KEY?.trim() || env.LIM_API_KEY?.trim(); + if (!apiKey) return undefined; + return new LimrunRuntime({ + apiKey, + region: env.LIMRUN_REGION?.trim() || env.LIM_REGION?.trim() || undefined, + version: readVersion(), + }); +} + +export class LimrunRuntime implements ProviderDeviceRuntime { + private readonly limrun: Limrun; + private readonly sessions = new Map(); + private readonly options: LimrunRuntimeOptions; + readonly provider = LIMRUN_PROVIDER; + + readonly leaseLifecycle: LeaseLifecycleProvider = { + allocate: async (lease) => await this.allocate(lease), + release: async (lease) => await this.release(lease.leaseId), + }; + + readonly deviceInventoryProvider: DeviceInventoryProvider = async (request) => { + if (request.leaseProvider !== this.provider) return null; + const leaseId = readLimrunLeaseIdFromInventoryRequest(request); + if (!leaseId) return null; + const session = this.sessions.get(leaseId); + if (!session) return null; + if (request.platform && request.platform !== session.platform) return []; + return [session.device]; + }; + + constructor(options: LimrunRuntimeOptions) { + this.options = options; + this.limrun = new Limrun({ + apiKey: options.apiKey, + defaultHeaders: { + 'x-agent-device-client': LIMRUN_CLIENT_HEADER, + 'x-agent-device-version': options.version ?? readVersion(), + }, + }); + } + + ownsDevice(device: DeviceInfo): boolean { + return parseLimrunDeviceId(device.id) !== undefined; + } + + getInteractor(device: DeviceInfo): Interactor | undefined { + const session = this.getSessionForDevice(device); + if (!session) return undefined; + return session.platform === 'ios' + ? new LimrunIosInteractor(session) + : new LimrunAndroidInteractor(session); + } + + async installApp( + device: DeviceInfo, + app: string, + appPath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + return await this.installInstallablePath(device, appPath, { + ...options, + appIdentifierHint: options?.appIdentifierHint ?? app, + packageNameHint: options?.packageNameHint ?? app, + }); + } + + async installInstallablePath( + device: DeviceInfo, + installablePath: string, + options?: ProviderDeviceInstallOptions, + ): Promise { + const session = this.getSessionForDevice(device); + if (!session) return undefined; + return session.platform === 'ios' + ? await installLimrunIosApp(this.limrun, session, installablePath, options) + : await installLimrunAndroidApp(this.limrun, session, installablePath, options); + } + + async configurePortReverse(options: { + leaseId: string; + devicePort: number; + hostPort: number; + name: string; + }): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId, { throwOnIos: true }); + if (!session) return undefined; + await ensureAndroidPortReverse(session, options); + return portReverseResult(options); + } + + async removePortReverse(options: { + leaseId: string; + devicePort: number; + hostPort: number; + name: string; + }): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId); + if (!session) return undefined; + await removeAndroidPortReverse(session, options.devicePort); + return portReverseResult(options); + } + + async shutdown(): Promise { + const sessions = Array.from(this.sessions.values()); + await Promise.allSettled(sessions.map((session) => this.terminateSession(session))); + this.sessions.clear(); + } + + private async allocate(lease: DeviceLease): 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 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'); + } + const client = await createIosInstanceClient({ + apiUrl: instance.status.apiUrl, + token: instance.status.token, + logLevel: 'warn', + }); + return { + platform: 'ios', + lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('ios', lease, instance.metadata.id), + client, + }; + } catch (error) { + await this.limrun.iosInstances.delete(instance.metadata.id).catch(() => {}); + throw error; + } + } + + private async createAndroidSession(lease: DeviceLease): Promise { + const instance = (await this.limrun.androidInstances.create({ + wait: true, + 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', + ); + } + const client = await createAndroidInstanceClient({ + apiUrl: instance.status.apiUrl, + adbUrl: instance.status.adbWebSocketUrl, + token: instance.status.token, + logLevel: 'warn', + }); + return { + platform: 'android', + lease, + instanceId: instance.metadata.id, + device: buildLimrunDevice('android', lease, instance.metadata.id), + client, + reversedPorts: new Map(), + }; + } catch (error) { + await this.limrun.androidInstances.delete(instance.metadata.id).catch(() => {}); + throw error; + } + } + + private buildInstanceMetadata(lease: DeviceLease) { + return { + displayName: `agent-device-${lease.tenantId}-${lease.runId}`, + labels: { + tenantId: lease.tenantId, + runId: lease.runId, + leaseId: lease.leaseId, + provider: lease.leaseProvider ?? LIMRUN_PROVIDER, + source: LIMRUN_CLIENT_HEADER, + }, + }; + } + + private async release(leaseId: string): Promise | undefined> { + const session = this.sessions.get(leaseId); + if (!session) return undefined; + await this.terminateSession(session); + this.sessions.delete(leaseId); + return { limrunInstanceId: session.instanceId }; + } + + private async terminateSession(session: LimrunRuntimeSession): Promise { + session.client.disconnect(); + if (session.platform === 'ios') { + await this.limrun.iosInstances.delete(session.instanceId); + return; + } + await cleanupAndroidAdbTunnel(session); + await this.limrun.androidInstances.delete(session.instanceId); + } + + private getSessionForDevice(device: DeviceInfo): LimrunRuntimeSession | undefined { + const parsed = parseLimrunDeviceId(device.id); + if (!parsed) return undefined; + const session = this.sessions.get(parsed.leaseId); + if (!session || session.platform !== parsed.platform) return undefined; + return session; + } + + private getAndroidPortReverseSession( + leaseId: string, + options: { throwOnIos?: boolean } = {}, + ): Extract | undefined { + const session = this.sessions.get(leaseId); + if (!session) return undefined; + if (session.platform === 'android') return session; + if (options.throwOnIos) { + throw unsupported( + 'port reverse', + 'Direct Limrun iOS sessions cannot reach local host ports; use a bridge public URL.', + ); + } + return undefined; + } +} + +function portReverseResult(options: { + leaseId: string; + devicePort: number; + hostPort: number; + name: string; +}): Record { + return { + leaseId: options.leaseId, + devicePort: options.devicePort, + hostPort: options.hostPort, + name: options.name, + }; +} + +async function installLimrunIosApp( + limrun: Limrun, + session: Extract, + installablePath: string, + options?: ProviderDeviceInstallOptions, +): Promise { + const prepared = await prepareLimrunIosAsset(installablePath); + try { + const asset = await limrun.assets.getOrUpload({ + path: prepared.uploadPath, + name: prepared.assetName, + }); + const result = await session.client.installApp(asset.signedDownloadUrl, { + md5: asset.md5, + launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'ForegroundIfRunning', + }); + const bundleId = normalizeOptionalString(result.bundleId) ?? options?.appIdentifierHint; + return { + ...(bundleId ? { bundleId, launchTarget: bundleId } : {}), + appName: inferAppNameFromPath(installablePath), + }; + } finally { + await prepared.cleanup(); + } +} + +async function installLimrunAndroidApp( + limrun: Limrun, + session: Extract, + installablePath: string, + options?: ProviderDeviceInstallOptions, +): Promise { + const packageName = normalizeOptionalString(options?.packageNameHint); + if (options?.relaunch && packageName) { + await runLimrunAndroidAdb(session, ['shell', 'am', 'force-stop', packageName], { + allowFailure: true, + }); + } + const asset = await limrun.assets.getOrUpload({ + path: installablePath, + name: buildAndroidAssetName(packageName, installablePath), + }); + await session.client.sendAsset(asset.signedDownloadUrl); + return { + ...(packageName ? { packageName, launchTarget: packageName } : {}), + ...(packageName ? { appName: inferAndroidAppName(packageName) } : {}), + }; +} + +async function prepareLimrunIosAsset(artifactPath: string): Promise<{ + uploadPath: string; + assetName: string; + cleanup: () => Promise; +}> { + const stat = await fs.promises.stat(artifactPath); + if (!stat.isDirectory()) { + return { + uploadPath: artifactPath, + assetName: path.basename(artifactPath), + cleanup: async () => {}, + }; + } + + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-limrun-ios-app-')); + const zipPath = path.join(tempDir, `${path.basename(artifactPath)}.zip`); + const result = await runCmd('zip', ['-qr', zipPath, path.basename(artifactPath)], { + cwd: path.dirname(artifactPath), + timeoutMs: 120_000, + }); + if (result.exitCode !== 0) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { + command: ['zip', '-qr', zipPath, path.basename(artifactPath)].join(' '), + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }); + } + return { + uploadPath: zipPath, + assetName: path.basename(zipPath), + cleanup: async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +async function runLimrunAndroidAdb( + session: Extract, + args: string[], + options?: AndroidAdbExecutorOptions, +): Promise { + const serial = await ensurePersistentAndroidAdbSerial(session); + const result = await runCmd('adb', ['-s', serial, ...args], { + allowFailure: options?.allowFailure, + timeoutMs: options?.timeoutMs ?? 30_000, + signal: options?.signal, + }); + if (result.exitCode !== 0 && options?.allowFailure !== true) { + throw new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { + command: ['adb', '-s', serial, ...args].join(' '), + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }); + } +} + +class LimrunIosInteractor implements Interactor { + private readonly session: Extract; + + constructor(session: Extract) { + this.session = session; + } + + async open(app: string, options?: { url?: string }): Promise { + if (options?.url) { + await this.session.client.launchApp(app); + await this.session.client.openUrl(options.url); + return; + } + if (looksLikeUrl(app)) { + await this.session.client.openUrl(app); + return; + } + await this.session.client.launchApp(resolveIosTarget(app)); + } + + async openDevice(): Promise {} + + async close(app: string): Promise { + if (app) await this.session.client.terminateApp(resolveIosTarget(app)).catch(() => {}); + } + + async tap(x: number, y: number): Promise { + await this.session.client.tap(x, y); + } + + async tapElementSelector(selector: { + key: 'id' | 'label' | 'text' | 'value'; + value: string; + }): Promise | void> { + await this.session.client.tapElement(toIosSelector(selector)); + } + + async doubleTap(x: number, y: number): Promise { + await this.tap(x, y); + await this.tap(x, y); + } + + async swipe(): Promise { + throw unsupported('swipe', 'Limrun iOS direct sessions do not expose swipe yet.'); + } + + async pan(): Promise { + throw unsupported('pan', 'Limrun iOS direct sessions do not expose pan yet.'); + } + + async fling(): Promise { + throw unsupported('fling', 'Limrun iOS direct sessions do not expose fling yet.'); + } + + async longPress(): Promise { + throw unsupported('longpress', 'Limrun iOS direct sessions do not expose long press yet.'); + } + + async focus(x: number, y: number): Promise { + await this.tap(x, y); + } + + async type(text: string, delayMs?: number): Promise { + if (delayMs && delayMs > 0) { + for (const char of Array.from(text)) { + await this.session.client.typeText(char); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return; + } + await this.session.client.typeText(text); + } + + async fill(x: number, y: number, text: string): Promise { + await this.tap(x, y); + await this.session.client.typeText(text); + } + + async fillElementSelector( + selector: { key: 'id' | 'label' | 'text' | 'value'; value: string }, + text: string, + ): Promise { + await this.session.client.setElementValue(text, toIosSelector(selector)); + } + + async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { + await this.session.client.scroll(direction, options?.pixels ?? 300); + } + + async pinch(): Promise { + throw unsupported('pinch', 'Limrun iOS direct sessions do not expose multi-touch pinch yet.'); + } + + async screenshot(outPath: string): Promise { + const screenshot = await this.session.client.screenshot(); + await writeBase64File(outPath, screenshot.base64); + } + + async snapshot(_options?: SnapshotOptions): Promise { + const treeJson = await this.session.client.elementTree(); + const parsed = JSON.parse(treeJson) as IosTreeNode | IosTreeNode[]; + return { nodes: flattenIosTree(parsed), backend: 'xctest' }; + } + + async back(): Promise { + await this.session.client.pressKey('escape'); + } + + async home(): Promise { + throw unsupported('home', 'Limrun iOS direct sessions do not expose home yet.'); + } + + async rotate(orientation: 'portrait' | 'landscape-left' | 'landscape-right'): Promise { + await this.session.client.setOrientation(orientation === 'portrait' ? 'Portrait' : 'Landscape'); + } + + async rotateGesture(): Promise { + throw unsupported('rotate', 'Limrun iOS direct sessions do not expose rotate gestures yet.'); + } + + async transformGesture(): Promise { + throw unsupported( + 'transform', + 'Limrun iOS direct sessions do not expose transform gestures yet.', + ); + } + + async appSwitcher(): Promise { + throw unsupported('app-switcher', 'Limrun iOS direct sessions do not expose app switcher yet.'); + } + + async readClipboard(): Promise { + throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard read yet.'); + } + + async writeClipboard(): Promise { + throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard write yet.'); + } + + async setSetting(): Promise { + throw unsupported('settings', 'Limrun iOS direct sessions do not expose settings changes yet.'); + } +} + +class LimrunAndroidInteractor implements Interactor { + private readonly session: Extract; + + constructor(session: Extract) { + this.session = session; + } + + async open(app: string, options?: { url?: string }): Promise { + if (options?.url) { + await this.ensureReverseForUrl(options.url, 'launch-url'); + await this.runAdb([ + 'shell', + 'monkey', + '-p', + app, + '-c', + 'android.intent.category.LAUNCHER', + '1', + ]); + await this.session.client.openUrl(options.url); + return; + } + if (looksLikeUrl(app)) { + await this.ensureReverseForUrl(app, 'open-url'); + await this.session.client.openUrl(app); + return; + } + if (isAndroidSettingsTarget(app)) { + await this.runAdb(['shell', 'am', 'start', '-W', '-a', SETTINGS_INTENT]); + return; + } + await this.runAdb([ + 'shell', + 'monkey', + '-p', + app, + '-c', + 'android.intent.category.LAUNCHER', + '1', + ]); + } + + async openDevice(): Promise {} + + async close(app: string): Promise { + if (app) await this.runAdb(['shell', 'am', 'force-stop', app], { allowFailure: true }); + } + + async tap(x: number, y: number): Promise { + await this.session.client.tap({ x, y }); + } + + async tapElementSelector(selector: { + key: 'id' | 'label' | 'text' | 'value'; + value: string; + }): Promise { + await this.session.client.tap({ selector: toAndroidSelector(selector) }); + } + + async doubleTap(x: number, y: number): Promise { + await this.tap(x, y); + await this.tap(x, y); + } + + async swipe(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { + await this.runAdb([ + 'shell', + 'input', + 'swipe', + String(Math.round(x1)), + String(Math.round(y1)), + String(Math.round(x2)), + String(Math.round(y2)), + String(durationMs ?? 300), + ]); + } + + async pan(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { + await this.swipe(x1, y1, x2, y2, durationMs); + } + + async fling(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { + await this.swipe(x1, y1, x2, y2, durationMs); + } + + async longPress(x: number, y: number, durationMs?: number) { + await this.swipe(x, y, x, y, durationMs ?? 800); + } + + async focus(x: number, y: number): Promise { + await this.tap(x, y); + } + + async type(text: string): Promise { + await this.session.client.setText(undefined, text); + } + + async fill(x: number, y: number, text: string): Promise { + await this.session.client.setText({ x, y }, text); + } + + async fillElementSelector( + selector: { key: 'id' | 'label' | 'text' | 'value'; value: string }, + text: string, + ): Promise { + await this.session.client.setText({ selector: toAndroidSelector(selector) }, text); + } + + async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { + await this.session.client.scrollScreen(direction, options?.pixels); + } + + async pinch(): Promise { + throw unsupported('pinch', 'Limrun Android direct sessions do not expose pinch yet.'); + } + + async screenshot(outPath: string): Promise { + const screenshot = await this.session.client.screenshot(); + await writeDataUriFile(outPath, screenshot.dataUri); + } + + async snapshot(): Promise { + const tree = await this.session.client.getElementTree(); + return { + nodes: tree.nodes.map(mapAndroidNode), + ...readOptionalTruncation(tree), + backend: 'android', + }; + } + + async back(): Promise { + await this.session.client.pressKey('BACK'); + } + + async home(): Promise { + await this.session.client.pressKey('HOME'); + } + + async rotate(): Promise { + throw unsupported('rotate', 'Limrun Android direct sessions do not expose rotate yet.'); + } + + async rotateGesture(): Promise { + throw unsupported( + 'rotate', + 'Limrun Android direct sessions do not expose rotate gestures yet.', + ); + } + + async transformGesture(): Promise { + throw unsupported( + 'transform', + 'Limrun Android direct sessions do not expose transform gestures yet.', + ); + } + + async appSwitcher(): Promise { + await this.session.client.pressKey('APP_SWITCH'); + } + + async readClipboard(): Promise { + throw unsupported( + 'clipboard', + 'Limrun Android direct sessions do not expose clipboard read yet.', + ); + } + + async writeClipboard(): Promise { + throw unsupported( + 'clipboard', + 'Limrun Android direct sessions do not expose clipboard write yet.', + ); + } + + async setSetting(): Promise { + throw unsupported( + 'settings', + 'Limrun Android direct sessions do not expose settings changes yet.', + ); + } + + private async runAdb(args: string[], options?: AndroidAdbExecutorOptions): Promise { + await runLimrunAndroidAdb(this.session, args, options); + } + + private async ensureReverseForUrl(url: string, name: string): Promise { + const port = readLocalhostUrlPort(url); + if (!port) return; + await ensureAndroidPortReverse(this.session, { + devicePort: port, + hostPort: port, + name, + }); + } +} + +async function ensureAndroidPortReverse( + session: Extract, + options: { + devicePort: number; + hostPort: number; + name: string; + }, +): Promise { + const existing = session.reversedPorts.get(options.devicePort); + if (existing === options.name) return; + if (existing && existing !== options.name) { + throw new AppError('INVALID_ARGS', `Limrun Android port reverse already exists`, { + devicePort: options.devicePort, + existingName: existing, + requestedName: options.name, + }); + } + const serial = await ensurePersistentAndroidAdbSerial(session); + const result = await runCmd( + 'adb', + ['-s', serial, 'reverse', `tcp:${options.devicePort}`, `tcp:${options.hostPort}`], + { + timeoutMs: 30_000, + }, + ); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'Limrun Android ADB reverse failed', { + command: [ + 'adb', + '-s', + serial, + 'reverse', + `tcp:${options.devicePort}`, + `tcp:${options.hostPort}`, + ].join(' '), + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }); + } + session.reversedPorts.set(options.devicePort, options.name); +} + +async function removeAndroidPortReverse( + session: Extract, + devicePort: number, +): Promise { + if (!session.reversedPorts.has(devicePort)) return; + const serial = await ensurePersistentAndroidAdbSerial(session); + await runCmd('adb', ['-s', serial, 'reverse', '--remove', `tcp:${devicePort}`], { + allowFailure: true, + timeoutMs: 10_000, + }).catch(() => {}); + session.reversedPorts.delete(devicePort); +} + +function readOptionalTruncation(value: unknown): Pick { + const truncated = + value && typeof value === 'object' && 'truncated' in value + ? (value as { truncated?: unknown }).truncated + : undefined; + return typeof truncated === 'boolean' ? { truncated } : {}; +} + +async function ensurePersistentAndroidAdbSerial( + session: Extract, +): Promise { + if (session.adbTunnel && session.adbSerial) return session.adbSerial; + const tunnel = await session.client.startAdbTunnel(); + const serial = `${tunnel.address.address}:${tunnel.address.port}`; + session.adbTunnel = tunnel; + session.adbSerial = serial; + return serial; +} + +async function cleanupAndroidAdbTunnel( + session: Extract, +): Promise { + const serial = session.adbSerial; + if (serial) { + await Promise.allSettled( + Array.from(session.reversedPorts.keys()).map((port) => + runCmd('adb', ['-s', serial, 'reverse', '--remove', `tcp:${port}`], { + allowFailure: true, + timeoutMs: 10_000, + }), + ), + ); + await runCmd('adb', ['disconnect', serial], { + allowFailure: true, + timeoutMs: 10_000, + }).catch(() => {}); + } + session.reversedPorts.clear(); + session.adbTunnel?.close(); + session.adbTunnel = undefined; + session.adbSerial = undefined; +} + +function resolveIosTarget(app: string): string { + const normalized = app.trim().toLowerCase(); + if (normalized === 'settings') return 'com.apple.Preferences'; + return app; +} + +function isAndroidSettingsTarget(app: string): boolean { + const normalized = app.trim().toLowerCase(); + return normalized === 'settings' || normalized === 'com.android.settings'; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function inferAppNameFromPath(appPath: string): string | undefined { + const base = path.basename(appPath).replace(/\.(?:app|ipa|apk|aab|zip)$/i, ''); + return base || undefined; +} + +function inferAndroidAppName(packageName: string): string | undefined { + const segments = packageName.split('.').filter(Boolean); + const last = segments.at(-1); + return last ? last.replace(/[_-]+/g, ' ') : undefined; +} + +function buildAndroidAssetName(packageName: string | undefined, artifactPath: string): string { + const extension = path.extname(artifactPath) || '.apk'; + const prefix = packageName?.replace(/[^a-zA-Z0-9_.-]+/g, '-') || 'android-app'; + return `${prefix}${extension}`; +} + +function looksLikeUrl(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(value.trim()); +} + +export function readLocalhostUrlPort(value: string): number | undefined { + let url: URL; + try { + url = new URL(value); + } catch { + return undefined; + } + const hostname = url.hostname.toLowerCase(); + if ( + hostname !== 'localhost' && + hostname !== '127.0.0.1' && + hostname !== '::1' && + hostname !== '[::1]' + ) { + return undefined; + } + const port = Number(url.port); + if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined; + return port; +} + +function unsupported(command: string, message: string): never { + throw new AppError('UNSUPPORTED_OPERATION', message, { command }); +} diff --git a/src/providers/limrun/snapshot.ts b/src/providers/limrun/snapshot.ts new file mode 100644 index 000000000..42baa44ae --- /dev/null +++ b/src/providers/limrun/snapshot.ts @@ -0,0 +1,147 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { AndroidElementNode } from '@limrun/api/instance-client'; +import type { RawSnapshotNode } from '../../kernel/snapshot.ts'; + +type LimrunSelector = { key: 'id' | 'label' | 'text' | 'value'; value: string }; + +export type IosTreeNode = { + elementType?: string; + type?: string; + label?: string; + AXLabel?: string | null; + identifier?: string; + AXUniqueId?: string | null; + value?: string; + AXValue?: string | null; + frame?: { x?: number; y?: number; width?: number; height?: number }; + rect?: { x?: number; y?: number; width?: number; height?: number }; + enabled?: boolean; + role?: string; + selected?: boolean; + hittable?: boolean; + children?: IosTreeNode[]; + nodes?: IosTreeNode[]; + elements?: IosTreeNode[]; +}; + +export function flattenIosTree(input: IosTreeNode | IosTreeNode[]): RawSnapshotNode[] { + const roots = Array.isArray(input) ? input : [input]; + const nodes: RawSnapshotNode[] = []; + const visit = (node: IosTreeNode, depth: number, parentIndex?: number) => { + const index = nodes.length; + nodes.push(mapIosNode(node, { index, depth, parentIndex })); + for (const child of readIosNodeChildren(node)) { + visit(child, depth + 1, index); + } + }; + for (const root of roots) visit(root, 0); + return nodes; +} + +function mapIosNode( + node: IosTreeNode, + options: { index: number; depth: number; parentIndex?: number }, +): RawSnapshotNode { + return { + index: options.index, + type: readIosNodeType(node), + role: readIosNodeRole(node), + label: readIosNodeLabel(node), + value: readIosNodeValue(node), + identifier: readIosNodeIdentifier(node), + rect: readIosNodeRect(node), + enabled: node.enabled, + selected: node.selected, + hittable: node.hittable, + depth: options.depth, + parentIndex: options.parentIndex, + }; +} + +function readIosNodeType(node: IosTreeNode): string | undefined { + return node.elementType ?? node.type; +} + +function readIosNodeRole(node: IosTreeNode): string | undefined { + return node.role ?? readIosNodeType(node); +} + +function readIosNodeLabel(node: IosTreeNode): string | undefined { + return node.label ?? node.AXLabel ?? undefined; +} + +function readIosNodeValue(node: IosTreeNode): string | undefined { + return node.value ?? node.AXValue ?? undefined; +} + +function readIosNodeIdentifier(node: IosTreeNode): string | undefined { + return node.identifier ?? node.AXUniqueId ?? undefined; +} + +function readIosNodeRect(node: IosTreeNode): RawSnapshotNode['rect'] { + const rect = node.rect ?? node.frame; + if ( + !rect || + typeof rect.x !== 'number' || + typeof rect.y !== 'number' || + typeof rect.width !== 'number' || + typeof rect.height !== 'number' + ) { + return undefined; + } + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }; +} + +function readIosNodeChildren(node: IosTreeNode): IosTreeNode[] { + return node.children ?? node.nodes ?? node.elements ?? []; +} + +export function mapAndroidNode(node: AndroidElementNode, index: number): RawSnapshotNode { + const bounds = node.parsedBounds; + return { + index, + type: node.className, + role: node.className, + label: node.text || node.contentDesc, + value: node.text, + identifier: node.resourceId, + rect: bounds + ? { + x: bounds.left, + y: bounds.top, + width: Math.max(0, bounds.right - bounds.left), + height: Math.max(0, bounds.bottom - bounds.top), + } + : undefined, + enabled: node.enabled, + selected: node.selected, + hittable: node.clickable, + }; +} + +export function toIosSelector(selector: LimrunSelector) { + if (selector.key === 'id') return { accessibilityId: selector.value }; + if (selector.key === 'value') return { value: selector.value }; + return { label: selector.value }; +} + +export function toAndroidSelector(selector: LimrunSelector) { + if (selector.key === 'id') return { resourceId: selector.value }; + return { text: selector.value }; +} + +export async function writeDataUriFile(filePath: string, dataUri: string): Promise { + const match = /^data:[^;]+;base64,(?.+)$/u.exec(dataUri); + await writeBase64File(filePath, match?.groups?.data ?? dataUri); +} + +export async function writeBase64File(filePath: string, base64: string): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, Buffer.from(base64, 'base64')); +} diff --git a/src/remote/remote-config-schema.ts b/src/remote/remote-config-schema.ts index b7c1a7170..f8713de8a 100644 --- a/src/remote/remote-config-schema.ts +++ b/src/remote/remote-config-schema.ts @@ -22,6 +22,7 @@ export type RemoteConfigMetroOptions = { metroRuntimeFile?: string; metroNoReuseExisting?: boolean; metroNoInstallDeps?: boolean; + launchUrl?: string; }; export type RemoteConnectionProfileFields = { @@ -142,6 +143,7 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'metroRuntimeFile', type: 'string', path: true }, { key: 'metroNoReuseExisting', type: 'boolean' }, { key: 'metroNoInstallDeps', type: 'boolean' }, + { key: 'launchUrl', type: 'string' }, ] as const satisfies readonly RemoteConfigFieldSpec[]; const REMOTE_CONFIG_LEASE_FIELD_SPECS = [ From 9ed6891f4585bb628dc20c80d21bb3c66577d1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 13:54:51 +0200 Subject: [PATCH 02/13] refactor: reuse Android provider runtime for Limrun --- src/__tests__/limrun-runtime.test.ts | 39 ++- src/cli/connection/limrun-profile.ts | 2 +- src/daemon/server/daemon-runtime.ts | 9 +- .../__tests__/app-lifecycle-open.test.ts | 55 +++ src/platforms/android/app-lifecycle.ts | 1 + src/provider-device-runtimes.ts | 16 + src/providers/limrun/runtime.ts | 327 +++++------------- src/providers/limrun/snapshot.ts | 34 -- 8 files changed, 181 insertions(+), 302 deletions(-) create mode 100644 src/provider-device-runtimes.ts diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index e5de84590..19aa6781f 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; -import { LimrunRuntime, readLocalhostUrlPort } from '../providers/limrun/runtime.ts'; +import { LimrunRuntime } from '../providers/limrun/runtime.ts'; import type { SimulatorLease } from '../daemon/lease-registry.ts'; import type { DeviceInfo } from '../kernel/device.ts'; import { runCmd } from '../utils/exec.ts'; @@ -83,9 +83,13 @@ vi.mock('@limrun/api/instance-client', () => ({ })), })); -vi.mock('../utils/exec.ts', () => ({ - runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), -})); +vi.mock('../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + }; +}); afterEach(() => { limrunMockState.constructorOptions.length = 0; @@ -182,10 +186,7 @@ async function allocateLimrunDevice( function assertAndroidTunnelLifecycle(openUrl: string): void { assert.equal(limrunMockState.androidStartAdbTunnel.mock.calls.length, 1); - const androidOpenUrlCalls = limrunMockState.androidOpenUrl.mock.calls as unknown as Array< - [string] - >; - assert.equal(androidOpenUrlCalls[0]?.[0], openUrl); + assert.equal(limrunMockState.androidOpenUrl.mock.calls.length, 0); assert.deepEqual(vi.mocked(runCmd).mock.calls[0]?.[1], [ '-s', '127.0.0.1:62001', @@ -194,13 +195,25 @@ function assertAndroidTunnelLifecycle(openUrl: string): void { 'tcp:8081', ]); assert.deepEqual(vi.mocked(runCmd).mock.calls[1]?.[1], [ + '-s', + '127.0.0.1:62001', + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.VIEW', + '-d', + openUrl, + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[2]?.[1], [ '-s', '127.0.0.1:62001', 'reverse', '--remove', 'tcp:8081', ]); - assert.deepEqual(vi.mocked(runCmd).mock.calls[2]?.[1], ['disconnect', '127.0.0.1:62001']); + assert.deepEqual(vi.mocked(runCmd).mock.calls[3]?.[1], ['disconnect', '127.0.0.1:62001']); assert.equal(limrunMockState.androidTunnelClose.mock.calls.length, 1); } @@ -470,11 +483,3 @@ test('Limrun keeps session tracked when release fails so release can be retried' await runtime.shutdown(); } }); - -test('readLocalhostUrlPort recognizes loopback URLs only', () => { - assert.equal(readLocalhostUrlPort('exp://127.0.0.1:8081'), 8081); - assert.equal(readLocalhostUrlPort('http://localhost:19000/status'), 19000); - assert.equal(readLocalhostUrlPort('http://[::1]:8097'), 8097); - assert.equal(readLocalhostUrlPort('https://metro.agent-device.dev/status'), undefined); - assert.equal(readLocalhostUrlPort('not a url'), undefined); -}); diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index 29a4586c3..abf058c4c 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -1,7 +1,7 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; import type { RemoteConfigProfile } from '../../remote/remote-config-schema.ts'; import { AppError } from '../../kernel/errors.ts'; -import type { CliFlags } from '../parser/cli-flags.ts'; +import type { CliFlags } from '../../contracts/cli-flags.ts'; import type { EnvMap } from '../../utils/env-map.ts'; import { readMetroProfileFields } from './profile-fields.ts'; import { persistAndResolveGeneratedProfile } from './generated-config.ts'; diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 62259f1c4..8538aebf7 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -6,13 +6,11 @@ import { resolveDaemonPaths, resolveDaemonServerMode } from '../config.ts'; import { createDaemonHttpServer } from './http-server.ts'; import { trackDownloadableArtifact } from '../artifact-tracking.ts'; import { createDefaultCloudArtifactProvider } from '../../default-cloud-artifact-provider.ts'; -import { createDefaultCloudWebDriverProviderRuntimes } from '../../cloud-webdriver/provider-runtimes.ts'; -import { createLimrunRuntimeFromEnv } from '../../providers/limrun/runtime.ts'; import { composeCloudArtifactProviders, createProviderDeviceRuntimeRequestProviders, - type ProviderDeviceRuntime, } from '../../provider-device-runtime.ts'; +import { createDefaultProviderDeviceRuntimes } from '../../provider-device-runtimes.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { createRequestHandler } from '../request-router.ts'; import { teardownSessionResources } from '../session-teardown.ts'; @@ -100,10 +98,7 @@ export async function startDaemonRuntime( const token = crypto.randomBytes(24).toString('hex'); const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; const daemonCodeSignature = resolveDaemonCodeSignature(); - const providerDeviceRuntimes = [ - ...createDefaultCloudWebDriverProviderRuntimes(env), - createLimrunRuntimeFromEnv(env), - ].filter((runtime): runtime is ProviderDeviceRuntime => Boolean(runtime)); + const providerDeviceRuntimes = createDefaultProviderDeviceRuntimes(env); const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders(providerDeviceRuntimes); const cloudArtifactProvider = composeCloudArtifactProviders( diff --git a/src/platforms/android/__tests__/app-lifecycle-open.test.ts b/src/platforms/android/__tests__/app-lifecycle-open.test.ts index 49c356877..4d71829af 100644 --- a/src/platforms/android/__tests__/app-lifecycle-open.test.ts +++ b/src/platforms/android/__tests__/app-lifecycle-open.test.ts @@ -213,6 +213,61 @@ test('openAndroidApp ensures Android reverse before localhost deep link launch', ]); }); +test('openAndroidApp ensures Android reverse before localhost app-bound deep link launch', async () => { + const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }; + const calls: Array< + { kind: 'exec'; args: string[] } | { kind: 'reverse'; local: string; remote: string } + > = []; + + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push({ kind: 'exec', args }); + return { + stdout: args.join(' ') === 'shell pm list packages' ? 'package:com.example.app\n' : '', + stderr: '', + exitCode: 0, + }; + }, + reverse: { + ensure: async (mapping) => { + calls.push({ kind: 'reverse', local: mapping.local, remote: mapping.remote }); + }, + remove: async () => {}, + removeAllOwned: async () => {}, + }, + }, + { serial: 'emulator-5554' }, + async () => + await openAndroidApp(device, 'com.example.app', { url: 'http://localhost:8081/status' }), + ); + + assert.deepEqual(calls, [ + { kind: 'reverse', local: 'tcp:8081', remote: 'tcp:8081' }, + { + kind: 'exec', + args: [ + 'shell', + 'am', + 'start', + '-W', + '-a', + 'android.intent.action.VIEW', + '-d', + 'http://localhost:8081/status', + '-p', + 'com.example.app', + ], + }, + ]); +}); + test('openAndroidApp ensures Android reverse before IPv6 localhost deep link launch', async () => { const device: DeviceInfo = { platform: 'android', diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index e8dd4ff75..d64169b7d 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -394,6 +394,7 @@ async function openAndroidAppBoundDeepLink( if (!isDeepLinkTarget(deepLinkUrl)) { throw new AppError('INVALID_ARGS', 'Android app-bound open requires a valid URL target'); } + await ensureAndroidLocalhostReverse(device, deepLinkUrl); const resolved = await resolveAndroidPackageForOpen(device, app, 'app-bound open'); await runAndroidAdb(device, [ 'shell', diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts new file mode 100644 index 000000000..704ca73ef --- /dev/null +++ b/src/provider-device-runtimes.ts @@ -0,0 +1,16 @@ +import { createDefaultCloudWebDriverProviderRuntimes } from './cloud-webdriver/provider-runtimes.ts'; +import type { DefaultCloudWebDriverProviderRuntimeEnv } from './cloud-webdriver/provider-runtimes.ts'; +import type { ProviderDeviceRuntime } from './provider-device-runtime.ts'; +import { createLimrunRuntimeFromEnv } from './providers/limrun/runtime.ts'; + +export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & + NodeJS.ProcessEnv; + +export function createDefaultProviderDeviceRuntimes( + env: DefaultProviderDeviceRuntimeEnv = process.env, +): ProviderDeviceRuntime[] { + return [ + ...createDefaultCloudWebDriverProviderRuntimes(env), + createLimrunRuntimeFromEnv(env), + ].filter((runtime): runtime is ProviderDeviceRuntime => Boolean(runtime)); +} diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 5eb577b2c..898840b26 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -15,8 +15,16 @@ import { runCmd } from '../../utils/exec.ts'; import { readVersion } from '../../utils/version.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import type { Interactor, SnapshotOptions, SnapshotResult } from '../../core/interactor-types.ts'; +import { createAndroidInteractor } from '../../core/interactors/android.ts'; import type { DeviceInventoryProvider } from '../../core/dispatch-resolve.ts'; -import type { AndroidAdbExecutorOptions } from '../../platforms/android/adb-executor.ts'; +import { + withAndroidAdbProvider, + type AndroidAdbExecutorOptions, + type AndroidAdbExecutorResult, + type AndroidAdbProvider, + type AndroidPortReverseEndpoint, + type AndroidPortReverseProvider, +} from '../../platforms/android/adb-executor.ts'; import type { LeaseLifecycleProvider } from '../../daemon/handlers/lease.ts'; import type { DeviceLease } from '../../daemon/lease-registry.ts'; import type { @@ -31,15 +39,7 @@ import { platformForLimrunLeaseBackend, readLimrunLeaseIdFromInventoryRequest, } from './device.ts'; -import { - flattenIosTree, - mapAndroidNode, - toAndroidSelector, - toIosSelector, - writeBase64File, - writeDataUriFile, - type IosTreeNode, -} from './snapshot.ts'; +import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; type LimrunAdbTunnel = Awaited>; @@ -79,7 +79,6 @@ type LimrunRuntimeOptions = { }; const LIMRUN_CLIENT_HEADER = 'agent-device-cli'; -const SETTINGS_INTENT = 'android.settings.SETTINGS'; export function createLimrunRuntimeFromEnv(env: NodeJS.ProcessEnv): LimrunRuntime | undefined { const apiKey = env.LIMRUN_API_KEY?.trim() || env.LIM_API_KEY?.trim(); @@ -132,7 +131,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { if (!session) return undefined; return session.platform === 'ios' ? new LimrunIosInteractor(session) - : new LimrunAndroidInteractor(session); + : createLimrunAndroidInteractor(session); } async installApp( @@ -429,10 +428,12 @@ async function runLimrunAndroidAdb( session: Extract, args: string[], options?: AndroidAdbExecutorOptions, -): Promise { +): Promise { const serial = await ensurePersistentAndroidAdbSerial(session); const result = await runCmd('adb', ['-s', serial, ...args], { allowFailure: options?.allowFailure, + binaryStdout: options?.binaryStdout, + stdin: options?.stdin, timeoutMs: options?.timeoutMs ?? 30_000, signal: options?.signal, }); @@ -444,6 +445,72 @@ async function runLimrunAndroidAdb( stderr: result.stderr, }); } + return result; +} + +function createLimrunAndroidInteractor( + session: Extract, +): Interactor { + const base = createAndroidInteractor(session.device); + const provider = createLimrunAndroidAdbProvider(session); + return new Proxy(base, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => + withAndroidAdbProvider(provider, { serial: session.device.id }, async () => + value.apply(target, args), + ); + }, + }); +} + +function createLimrunAndroidAdbProvider( + session: Extract, +): AndroidAdbProvider { + return { + exec: async (args, options) => await runLimrunAndroidAdb(session, args, options), + reverse: createLimrunAndroidPortReverseProvider(session), + text: async (request) => { + await session.client.setText(request.target, request.text); + }, + }; +} + +function createLimrunAndroidPortReverseProvider( + session: Extract, +): AndroidPortReverseProvider { + return { + async ensure(mapping) { + await ensureAndroidPortReverse(session, { + devicePort: tcpEndpointPort(mapping.local), + hostPort: tcpEndpointPort(mapping.remote), + name: mapping.ownerId ?? 'android-adb-provider', + }); + }, + async remove(local) { + await removeAndroidPortReverse(session, tcpEndpointPort(local)); + }, + async removeAllOwned(ownerId) { + const ownedPorts = Array.from(session.reversedPorts.entries()) + .filter(([, name]) => name === ownerId) + .map(([port]) => port); + for (const port of ownedPorts) { + await removeAndroidPortReverse(session, port); + } + }, + }; +} + +function tcpEndpointPort(endpoint: AndroidPortReverseEndpoint): number { + if (!endpoint.startsWith('tcp:')) { + throw unsupported('port reverse', `Limrun Android only supports tcp reverse endpoints.`); + } + const port = Number(endpoint.slice('tcp:'.length)); + if (!Number.isInteger(port) || port <= 0) { + throw new AppError('INVALID_ARGS', `Invalid Android tcp reverse endpoint: ${endpoint}`); + } + return port; } class LimrunIosInteractor implements Interactor { @@ -577,6 +644,10 @@ class LimrunIosInteractor implements Interactor { throw unsupported('app-switcher', 'Limrun iOS direct sessions do not expose app switcher yet.'); } + async tvRemote(): Promise { + throw unsupported('tv-remote', 'Limrun iOS direct sessions do not expose tv remote control.'); + } + async readClipboard(): Promise { throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard read yet.'); } @@ -590,202 +661,6 @@ class LimrunIosInteractor implements Interactor { } } -class LimrunAndroidInteractor implements Interactor { - private readonly session: Extract; - - constructor(session: Extract) { - this.session = session; - } - - async open(app: string, options?: { url?: string }): Promise { - if (options?.url) { - await this.ensureReverseForUrl(options.url, 'launch-url'); - await this.runAdb([ - 'shell', - 'monkey', - '-p', - app, - '-c', - 'android.intent.category.LAUNCHER', - '1', - ]); - await this.session.client.openUrl(options.url); - return; - } - if (looksLikeUrl(app)) { - await this.ensureReverseForUrl(app, 'open-url'); - await this.session.client.openUrl(app); - return; - } - if (isAndroidSettingsTarget(app)) { - await this.runAdb(['shell', 'am', 'start', '-W', '-a', SETTINGS_INTENT]); - return; - } - await this.runAdb([ - 'shell', - 'monkey', - '-p', - app, - '-c', - 'android.intent.category.LAUNCHER', - '1', - ]); - } - - async openDevice(): Promise {} - - async close(app: string): Promise { - if (app) await this.runAdb(['shell', 'am', 'force-stop', app], { allowFailure: true }); - } - - async tap(x: number, y: number): Promise { - await this.session.client.tap({ x, y }); - } - - async tapElementSelector(selector: { - key: 'id' | 'label' | 'text' | 'value'; - value: string; - }): Promise { - await this.session.client.tap({ selector: toAndroidSelector(selector) }); - } - - async doubleTap(x: number, y: number): Promise { - await this.tap(x, y); - await this.tap(x, y); - } - - async swipe(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { - await this.runAdb([ - 'shell', - 'input', - 'swipe', - String(Math.round(x1)), - String(Math.round(y1)), - String(Math.round(x2)), - String(Math.round(y2)), - String(durationMs ?? 300), - ]); - } - - async pan(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { - await this.swipe(x1, y1, x2, y2, durationMs); - } - - async fling(x1: number, y1: number, x2: number, y2: number, durationMs?: number) { - await this.swipe(x1, y1, x2, y2, durationMs); - } - - async longPress(x: number, y: number, durationMs?: number) { - await this.swipe(x, y, x, y, durationMs ?? 800); - } - - async focus(x: number, y: number): Promise { - await this.tap(x, y); - } - - async type(text: string): Promise { - await this.session.client.setText(undefined, text); - } - - async fill(x: number, y: number, text: string): Promise { - await this.session.client.setText({ x, y }, text); - } - - async fillElementSelector( - selector: { key: 'id' | 'label' | 'text' | 'value'; value: string }, - text: string, - ): Promise { - await this.session.client.setText({ selector: toAndroidSelector(selector) }, text); - } - - async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { - await this.session.client.scrollScreen(direction, options?.pixels); - } - - async pinch(): Promise { - throw unsupported('pinch', 'Limrun Android direct sessions do not expose pinch yet.'); - } - - async screenshot(outPath: string): Promise { - const screenshot = await this.session.client.screenshot(); - await writeDataUriFile(outPath, screenshot.dataUri); - } - - async snapshot(): Promise { - const tree = await this.session.client.getElementTree(); - return { - nodes: tree.nodes.map(mapAndroidNode), - ...readOptionalTruncation(tree), - backend: 'android', - }; - } - - async back(): Promise { - await this.session.client.pressKey('BACK'); - } - - async home(): Promise { - await this.session.client.pressKey('HOME'); - } - - async rotate(): Promise { - throw unsupported('rotate', 'Limrun Android direct sessions do not expose rotate yet.'); - } - - async rotateGesture(): Promise { - throw unsupported( - 'rotate', - 'Limrun Android direct sessions do not expose rotate gestures yet.', - ); - } - - async transformGesture(): Promise { - throw unsupported( - 'transform', - 'Limrun Android direct sessions do not expose transform gestures yet.', - ); - } - - async appSwitcher(): Promise { - await this.session.client.pressKey('APP_SWITCH'); - } - - async readClipboard(): Promise { - throw unsupported( - 'clipboard', - 'Limrun Android direct sessions do not expose clipboard read yet.', - ); - } - - async writeClipboard(): Promise { - throw unsupported( - 'clipboard', - 'Limrun Android direct sessions do not expose clipboard write yet.', - ); - } - - async setSetting(): Promise { - throw unsupported( - 'settings', - 'Limrun Android direct sessions do not expose settings changes yet.', - ); - } - - private async runAdb(args: string[], options?: AndroidAdbExecutorOptions): Promise { - await runLimrunAndroidAdb(this.session, args, options); - } - - private async ensureReverseForUrl(url: string, name: string): Promise { - const port = readLocalhostUrlPort(url); - if (!port) return; - await ensureAndroidPortReverse(this.session, { - devicePort: port, - hostPort: port, - name, - }); - } -} - async function ensureAndroidPortReverse( session: Extract, options: { @@ -842,14 +717,6 @@ async function removeAndroidPortReverse( session.reversedPorts.delete(devicePort); } -function readOptionalTruncation(value: unknown): Pick { - const truncated = - value && typeof value === 'object' && 'truncated' in value - ? (value as { truncated?: unknown }).truncated - : undefined; - return typeof truncated === 'boolean' ? { truncated } : {}; -} - async function ensurePersistentAndroidAdbSerial( session: Extract, ): Promise { @@ -891,11 +758,6 @@ function resolveIosTarget(app: string): string { return app; } -function isAndroidSettingsTarget(app: string): boolean { - const normalized = app.trim().toLowerCase(); - return normalized === 'settings' || normalized === 'com.android.settings'; -} - function normalizeOptionalString(value: string | undefined): string | undefined { const normalized = value?.trim(); return normalized ? normalized : undefined; @@ -922,27 +784,6 @@ function looksLikeUrl(value: string): boolean { return /^[a-z][a-z0-9+.-]*:/i.test(value.trim()); } -export function readLocalhostUrlPort(value: string): number | undefined { - let url: URL; - try { - url = new URL(value); - } catch { - return undefined; - } - const hostname = url.hostname.toLowerCase(); - if ( - hostname !== 'localhost' && - hostname !== '127.0.0.1' && - hostname !== '::1' && - hostname !== '[::1]' - ) { - return undefined; - } - const port = Number(url.port); - if (!Number.isInteger(port) || port < 1 || port > 65_535) return undefined; - return port; -} - function unsupported(command: string, message: string): never { throw new AppError('UNSUPPORTED_OPERATION', message, { command }); } diff --git a/src/providers/limrun/snapshot.ts b/src/providers/limrun/snapshot.ts index 42baa44ae..765cbe55c 100644 --- a/src/providers/limrun/snapshot.ts +++ b/src/providers/limrun/snapshot.ts @@ -1,6 +1,5 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { AndroidElementNode } from '@limrun/api/instance-client'; import type { RawSnapshotNode } from '../../kernel/snapshot.ts'; type LimrunSelector = { key: 'id' | 'label' | 'text' | 'value'; value: string }; @@ -102,45 +101,12 @@ function readIosNodeChildren(node: IosTreeNode): IosTreeNode[] { return node.children ?? node.nodes ?? node.elements ?? []; } -export function mapAndroidNode(node: AndroidElementNode, index: number): RawSnapshotNode { - const bounds = node.parsedBounds; - return { - index, - type: node.className, - role: node.className, - label: node.text || node.contentDesc, - value: node.text, - identifier: node.resourceId, - rect: bounds - ? { - x: bounds.left, - y: bounds.top, - width: Math.max(0, bounds.right - bounds.left), - height: Math.max(0, bounds.bottom - bounds.top), - } - : undefined, - enabled: node.enabled, - selected: node.selected, - hittable: node.clickable, - }; -} - export function toIosSelector(selector: LimrunSelector) { if (selector.key === 'id') return { accessibilityId: selector.value }; if (selector.key === 'value') return { value: selector.value }; return { label: selector.value }; } -export function toAndroidSelector(selector: LimrunSelector) { - if (selector.key === 'id') return { resourceId: selector.value }; - return { text: selector.value }; -} - -export async function writeDataUriFile(filePath: string, dataUri: string): Promise { - const match = /^data:[^;]+;base64,(?.+)$/u.exec(dataUri); - await writeBase64File(filePath, match?.groups?.data ?? dataUri); -} - export async function writeBase64File(filePath: string, base64: string): Promise { fs.mkdirSync(path.dirname(filePath), { recursive: true }); await fs.promises.writeFile(filePath, Buffer.from(base64, 'base64')); From 9b548b296b78eb5200472a5931c08dad9c9ba18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 14:33:05 +0200 Subject: [PATCH 03/13] refactor: pass runner context to provider runtimes --- src/__tests__/limrun-runtime.test.ts | 2 +- src/__tests__/provider-device-runtime.test.ts | 14 +++++-- src/cloud-webdriver/runtime.ts | 4 +- src/core/interactors.ts | 2 +- src/provider-device-runtime.ts | 11 +++-- src/providers/limrun/runtime.ts | 35 +++++----------- src/utils/__tests__/interactors.test.ts | 42 ++++++++++++++++++- 7 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 19aa6781f..20778e875 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -145,7 +145,7 @@ test('Limrun Android reverses localhost URL ports through the persistent ADB tun try { const device = await allocateLimrunDevice(runtime, androidLease()); - const interactor = runtime.getInteractor(device); + const interactor = runtime.getInteractor(device, {}); if (!interactor) throw new Error('Limrun runtime must return an interactor'); await interactor.open('exp://127.0.0.1:8081'); diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index 439ee06e6..8e232ad4e 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -58,16 +58,18 @@ function makeProviderRuntimeWorld() { booted: true, }; const interactor = { open: async () => undefined } as unknown as Interactor; + const runnerContexts: unknown[] = []; const missRuntime = makeMissingRuntime(); const hitRuntime = makeRuntime({ provider: 'hit', leaseResult: { provider: 'hit' }, devices: [device], interactor, + runnerContexts, installResult: { bundleId: 'com.example.app' }, portReverseResult: { provider: 'hit' }, }); - return { lease, device, interactor, missRuntime, hitRuntime }; + return { lease, device, interactor, runnerContexts, missRuntime, hitRuntime }; } function makeMissingRuntime(): ProviderDeviceRuntime { @@ -82,7 +84,9 @@ function makeMissingRuntime(): ProviderDeviceRuntime { } async function assertProviderRuntimeDelegates(world: ReturnType) { - assert.equal(getProviderDeviceInteractor(world.device), world.interactor); + const runnerContext = { appBundleId: 'com.example.app', requestId: 'request-a' }; + assert.equal(getProviderDeviceInteractor(world.device, runnerContext), world.interactor); + assert.deepEqual(world.runnerContexts, [runnerContext]); assert.deepEqual( await installProviderDeviceApp(world.device, 'com.example.app', '/tmp/app.ipa'), { @@ -141,6 +145,7 @@ function makeRuntime(options: { leaseResult: Record | undefined; devices: DeviceInfo[] | null; interactor: Interactor | undefined; + runnerContexts?: unknown[]; installResult: { bundleId: string } | undefined; portReverseResult: Record | undefined; installHook?: boolean; @@ -154,7 +159,10 @@ function makeRuntime(options: { }, deviceInventoryProvider: async () => options.devices, ownsDevice: (device) => options.devices?.some((entry) => entry.id === device.id) ?? false, - getInteractor: () => options.interactor, + getInteractor: (_device, runnerContext) => { + options.runnerContexts?.push(runnerContext); + return options.interactor; + }, ...(options.installHook === false ? {} : { installApp: async () => options.installResult }), configurePortReverse: async () => options.portReverseResult, removePortReverse: async () => options.portReverseResult, diff --git a/src/cloud-webdriver/runtime.ts b/src/cloud-webdriver/runtime.ts index 9aa5420ce..8cfde958d 100644 --- a/src/cloud-webdriver/runtime.ts +++ b/src/cloud-webdriver/runtime.ts @@ -4,7 +4,7 @@ import type { CloudArtifactsResult, } from '../cloud-artifacts.ts'; import type { DeviceInventoryProvider } from '../core/dispatch-resolve.ts'; -import type { Interactor } from '../core/interactor-types.ts'; +import type { Interactor, RunnerContext } from '../core/interactor-types.ts'; import type { LeaseLifecycleProvider } from '../daemon/handlers/lease.ts'; import type { DeviceLease } from '../daemon/lease-registry.ts'; import type { DaemonRequest } from '../daemon/types.ts'; @@ -179,7 +179,7 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { return [...this.sessionsByLeaseId.values()].some((session) => session.device.id === device.id); } - getInteractor(device: DeviceInfo): Interactor | undefined { + getInteractor(device: DeviceInfo, _runnerContext: RunnerContext): Interactor | undefined { return [...this.sessionsByLeaseId.values()].find((session) => session.device.id === device.id) ?.interactor; } diff --git a/src/core/interactors.ts b/src/core/interactors.ts index 6cf470452..3328001f6 100644 --- a/src/core/interactors.ts +++ b/src/core/interactors.ts @@ -14,7 +14,7 @@ export async function getInteractor( runnerContext: RunnerContext, ): Promise { if (isActiveProviderDevice(device)) { - const providerInteractor = getProviderDeviceInteractor(device); + const providerInteractor = getProviderDeviceInteractor(device, runnerContext); if (providerInteractor) return providerInteractor; throw new AppError( 'UNSUPPORTED_OPERATION', diff --git a/src/provider-device-runtime.ts b/src/provider-device-runtime.ts index 52de51dd0..60307aab1 100644 --- a/src/provider-device-runtime.ts +++ b/src/provider-device-runtime.ts @@ -4,7 +4,7 @@ import type { CloudArtifactsQuery, CloudArtifactsResult, } from './cloud-artifacts.ts'; -import type { Interactor } from './core/interactor-types.ts'; +import type { Interactor, RunnerContext } from './core/interactor-types.ts'; import type { DeviceInventoryProvider } from './core/dispatch-resolve.ts'; import type { LeaseLifecycleContext, LeaseLifecycleProvider } from './daemon/handlers/lease.ts'; import type { DeviceLease } from './daemon/lease-registry.ts'; @@ -30,7 +30,7 @@ export type ProviderDeviceRuntime = { cloudArtifacts?: CloudArtifactProvider; deviceInventoryProvider: DeviceInventoryProvider; ownsDevice(device: DeviceInfo): boolean; - getInteractor(device: DeviceInfo): Interactor | undefined; + getInteractor(device: DeviceInfo, runnerContext: RunnerContext): Interactor | undefined; installApp?( device: DeviceInfo, app: string, @@ -80,10 +80,13 @@ async function withProviderDeviceRuntimeScope( return await providerDeviceRuntimeScope.run([...runtimes], task); } -export function getProviderDeviceInteractor(device: DeviceInfo): Interactor | undefined { +export function getProviderDeviceInteractor( + device: DeviceInfo, + runnerContext: RunnerContext, +): Interactor | undefined { for (const runtime of getActiveProviderDeviceRuntimes()) { if (!runtime.ownsDevice(device)) continue; - const interactor = runtime.getInteractor(device); + const interactor = runtime.getInteractor(device, runnerContext); if (interactor) return interactor; } return undefined; diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 898840b26..fe03dc810 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -14,7 +14,12 @@ import { AppError } from '../../kernel/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { readVersion } from '../../utils/version.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; -import type { Interactor, SnapshotOptions, SnapshotResult } from '../../core/interactor-types.ts'; +import type { + Interactor, + RunnerContext, + SnapshotOptions, + SnapshotResult, +} from '../../core/interactor-types.ts'; import { createAndroidInteractor } from '../../core/interactors/android.ts'; import type { DeviceInventoryProvider } from '../../core/dispatch-resolve.ts'; import { @@ -126,7 +131,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { return parseLimrunDeviceId(device.id) !== undefined; } - getInteractor(device: DeviceInfo): Interactor | undefined { + getInteractor(device: DeviceInfo, _runnerContext: RunnerContext): Interactor | undefined { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' @@ -555,18 +560,6 @@ class LimrunIosInteractor implements Interactor { await this.tap(x, y); } - async swipe(): Promise { - throw unsupported('swipe', 'Limrun iOS direct sessions do not expose swipe yet.'); - } - - async pan(): Promise { - throw unsupported('pan', 'Limrun iOS direct sessions do not expose pan yet.'); - } - - async fling(): Promise { - throw unsupported('fling', 'Limrun iOS direct sessions do not expose fling yet.'); - } - async longPress(): Promise { throw unsupported('longpress', 'Limrun iOS direct sessions do not expose long press yet.'); } @@ -602,10 +595,6 @@ class LimrunIosInteractor implements Interactor { await this.session.client.scroll(direction, options?.pixels ?? 300); } - async pinch(): Promise { - throw unsupported('pinch', 'Limrun iOS direct sessions do not expose multi-touch pinch yet.'); - } - async screenshot(outPath: string): Promise { const screenshot = await this.session.client.screenshot(); await writeBase64File(outPath, screenshot.base64); @@ -629,14 +618,10 @@ class LimrunIosInteractor implements Interactor { await this.session.client.setOrientation(orientation === 'portrait' ? 'Portrait' : 'Landscape'); } - async rotateGesture(): Promise { - throw unsupported('rotate', 'Limrun iOS direct sessions do not expose rotate gestures yet.'); - } - - async transformGesture(): Promise { + async performGesture(): Promise { throw unsupported( - 'transform', - 'Limrun iOS direct sessions do not expose transform gestures yet.', + 'gesture', + 'Limrun iOS direct sessions do not expose portable gesture execution yet.', ); } diff --git a/src/utils/__tests__/interactors.test.ts b/src/utils/__tests__/interactors.test.ts index a3f341a48..bc4cb32b5 100644 --- a/src/utils/__tests__/interactors.test.ts +++ b/src/utils/__tests__/interactors.test.ts @@ -1,8 +1,13 @@ -import { beforeEach, test, vi } from 'vitest'; +import { afterEach, beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import type { RunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; +import type { Interactor, RunnerContext } from '../../core/interactor-types.ts'; +import { + setActiveProviderDeviceRuntimes, + type ProviderDeviceRuntime, +} from '../../provider-device-runtime.ts'; vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { const actual = @@ -38,6 +43,10 @@ beforeEach(() => { mockRunAppleRunnerCommand.mockReset(); }); +afterEach(() => { + setActiveProviderDeviceRuntimes([]); +}); + test('resolveAppleBackRunnerCommand defaults plain back to in-app navigation', () => { assert.equal(resolveAppleBackRunnerCommand(), 'backInApp'); }); @@ -47,6 +56,37 @@ test('resolveAppleBackRunnerCommand maps explicit back modes to runner commands' assert.equal(resolveAppleBackRunnerCommand('system'), 'backSystem'); }); +test('provider device interactor receives runner context from core resolution', async () => { + const device: DeviceInfo = { + platform: 'apple', + id: 'provider:ios:lease-a', + name: 'Provider iOS', + kind: 'simulator', + booted: true, + }; + const interactor = { open: async () => undefined } as unknown as Interactor; + const runnerContext: RunnerContext = { + appBundleId: 'com.example.app', + requestId: 'request-a', + }; + const runnerContexts: RunnerContext[] = []; + const runtime: ProviderDeviceRuntime = { + provider: 'provider', + leaseLifecycle: {}, + deviceInventoryProvider: async () => [device], + ownsDevice: (candidate) => candidate.id === device.id, + getInteractor: (_device, context) => { + runnerContexts.push(context); + return interactor; + }, + shutdown: async () => {}, + }; + setActiveProviderDeviceRuntimes([runtime]); + + assert.equal(await getInteractor(device, runnerContext), interactor); + assert.deepEqual(runnerContexts, [runnerContext]); +}); + test('ios scroll sends a single fused scroll command and reports planned pixels', async () => { const commands: RunnerCommand[] = []; mockRunAppleRunnerCommand.mockImplementation(async (_device, command) => { From 7cf1c11f3d916c6ef9702871513401d1e7d87b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 13 Jul 2026 14:53:11 +0200 Subject: [PATCH 04/13] fix: remove Android gesture swipe fallback --- src/providers/limrun/runtime.ts | 32 +++++++++++++------ .../provider-scenarios/android-world.ts | 19 +++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index fe03dc810..1266d400a 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -435,22 +435,34 @@ async function runLimrunAndroidAdb( options?: AndroidAdbExecutorOptions, ): Promise { const serial = await ensurePersistentAndroidAdbSerial(session); - const result = await runCmd('adb', ['-s', serial, ...args], { + const adbArgs = limrunAdbArgs(serial, args); + const result = await runCmd('adb', adbArgs, limrunAdbRunOptions(options)); + if (result.exitCode !== 0 && options?.allowFailure !== true) + throw limrunAndroidAdbError(adbArgs, result); + return result; +} + +function limrunAdbArgs(serial: string, args: string[]): string[] { + return ['-s', serial, ...args]; +} + +function limrunAdbRunOptions(options: AndroidAdbExecutorOptions | undefined) { + return { allowFailure: options?.allowFailure, binaryStdout: options?.binaryStdout, stdin: options?.stdin, timeoutMs: options?.timeoutMs ?? 30_000, signal: options?.signal, + }; +} + +function limrunAndroidAdbError(adbArgs: string[], result: AndroidAdbExecutorResult): AppError { + return new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { + command: ['adb', ...adbArgs].join(' '), + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, }); - if (result.exitCode !== 0 && options?.allowFailure !== true) { - throw new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { - command: ['adb', '-s', serial, ...args].join(' '), - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, - }); - } - return result; } function createLimrunAndroidInteractor( diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index b556d5cc3..810328209 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -440,6 +440,13 @@ function androidCaptureAdbResult( searchText: string, snapshotXml?: () => string, ): AndroidAdbResult | undefined { + if (key.includes('com.callstack.agentdevice.multitouchhelper/.MultiTouchInstrumentation')) { + return { + stdout: androidMultiTouchHelperOutput(), + stderr: '', + exitCode: 0, + }; + } if (key.startsWith('shell am instrument ')) { return { stdout: androidSnapshotHelperOutput(snapshotXml?.() ?? androidSettingsXml(searchText)), @@ -469,6 +476,18 @@ export function androidSnapshotHelperOutput(xml: string): string { ].join('\n'); } +function androidMultiTouchHelperOutput(): string { + return [ + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-multitouch-helper-v1', + 'INSTRUMENTATION_RESULT: helperApiVersion=1', + 'INSTRUMENTATION_RESULT: kind=swipe', + 'INSTRUMENTATION_RESULT: ok=true', + 'INSTRUMENTATION_RESULT: injectedEvents=2', + 'INSTRUMENTATION_RESULT: elapsedMs=100', + 'INSTRUMENTATION_CODE: 0', + ].join('\n'); +} + export function androidSettingsXml( searchText: string, options: { duplicateAppsRow?: boolean } = {}, From 310ccf06705934d0b945ffefd6da48fac4035a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 21:17:33 +0200 Subject: [PATCH 05/13] fix: reconcile Limrun direct runtime with main --- .../test-app/replays/gesture-lab-android.ad | 14 ++--- src/__tests__/limrun-runtime.test.ts | 29 +++++++++++ .../__tests__/session-relaunch-close.test.ts | 52 +++++++++++++++++++ src/daemon/handlers/session-open.ts | 28 +++++++++- src/providers/limrun/runtime.ts | 9 +++- 5 files changed, 123 insertions(+), 9 deletions(-) diff --git a/examples/test-app/replays/gesture-lab-android.ad b/examples/test-app/replays/gesture-lab-android.ad index c3000c853..c4684c80d 100644 --- a/examples/test-app/replays/gesture-lab-android.ad +++ b/examples/test-app/replays/gesture-lab-android.ad @@ -9,16 +9,16 @@ wait "Gesture lab" 30000 wait "gesture canary ready" 5000 wait "two-pointer pan activations 0" 5000 -gesture pan 350 1100 180 0 500 +gesture pan 220 700 180 0 500 wait "two-pointer pan activations 0" 5000 -gesture pan 350 1100 180 0 500 --pointer-count 2 +gesture pan 220 700 180 0 500 --pointer-count 2 wait "two-pointer pan activations 1" 5000 -gesture fling left 850 1100 300 +gesture fling left 500 700 300 wait "fling 1" 5000 -gesture fling right 750 1100 300 +gesture fling left 500 700 300 wait "fling 2" 5000 open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" @@ -26,7 +26,7 @@ react-native dismiss-overlay wait "gesture canary ready" 5000 wait "pan changed no, pinch changed no, rotate changed no" 30000 -gesture pinch 1.5 750 1100 +gesture pinch 1.5 360 700 wait "pan changed no, pinch changed yes, rotate changed no" 5000 open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" @@ -34,7 +34,7 @@ react-native dismiss-overlay wait "gesture canary ready" 5000 wait "pan changed no, pinch changed no, rotate changed no" 30000 -gesture rotate 35 750 1100 +gesture rotate 35 360 700 wait "pan changed no, pinch changed no, rotate changed yes" 5000 open "${APP_TARGET}" --relaunch --launch-url "${APP_URL}" @@ -42,7 +42,7 @@ react-native dismiss-overlay wait "gesture canary ready" 5000 wait "pan changed no, pinch changed no, rotate changed no" 30000 -gesture transform 750 1100 60 0 1.75 30 600 +gesture transform 360 700 60 0 1.75 30 600 wait "pan changed yes, pinch changed yes, rotate changed yes" 5000 close diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 20778e875..d5e80278b 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -20,6 +20,7 @@ const limrunMockState = vi.hoisted(() => { bundleId: 'com.example.ios', url: 'https://assets.example/app', })), + iosSetOrientation: vi.fn(async () => undefined), androidOpenUrl: vi.fn(async () => undefined), androidDisconnect: vi.fn(), androidSendAsset: vi.fn(async () => undefined), @@ -71,6 +72,7 @@ vi.mock('@limrun/api/ios-client', () => ({ createInstanceClient: vi.fn(async () => ({ disconnect: vi.fn(), installApp: limrunMockState.iosInstallApp, + setOrientation: limrunMockState.iosSetOrientation, })), })); @@ -328,6 +330,33 @@ test('Limrun iOS installs direct local artifacts through Limrun assets', async ( } }); +test('Limrun iOS maps supported orientation and rejects unsupported upside-down orientation', async () => { + const runtime = new LimrunRuntime({ + apiKey: 'lim_test_key', + version: '9.9.9-test', + }); + + try { + const device = await allocateLimrunDevice(runtime, { + ...androidLease(), + leaseId: 'lease-ios-orientation', + backend: 'ios-instance', + }); + const interactor = runtime.getInteractor(device, {}); + if (!interactor) throw new Error('Limrun runtime must return an interactor'); + + await interactor.setOrientation('landscape-left'); + await assert.rejects( + () => interactor.setOrientation('portrait-upside-down'), + /not portrait upside-down/, + ); + + assert.deepEqual(limrunMockState.iosSetOrientation.mock.calls, [['Landscape']]); + } finally { + await runtime.shutdown(); + } +}); + test('Limrun Android configures and removes an explicit port reverse', async () => { const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 30dbf83b9..6399d3891 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -2,6 +2,7 @@ import { test, expect, vi } from 'vitest'; import * as os from 'node:os'; import * as path from 'node:path'; import { LeaseRegistry } from '../../lease-registry.ts'; +import { setActiveProviderDeviceRuntimes } from '../../../provider-device-runtime.ts'; import { mockDispatch, mockResolveTargetDevice, @@ -103,6 +104,57 @@ test('open --relaunch leaves the old frame expired when the close dispatch fails expect(sessionStore.get(sessionName)?.refFrameState).toBe('expired'); }); +test('open --relaunch skips provider pre-close before first provider session exists', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'provider-android-session'; + const device = { + platform: 'android' as const, + id: 'provider-android-1', + name: 'Provider Android', + kind: 'emulator' as const, + booted: true, + }; + setActiveProviderDeviceRuntimes([ + { + provider: 'fake-provider', + leaseLifecycle: {}, + deviceInventoryProvider: async () => [device], + ownsDevice: (candidate) => candidate.id === device.id, + getInteractor: () => undefined, + shutdown: async () => {}, + }, + ]); + mockResolveTargetDevice.mockResolvedValue(device); + + const calls: Array<{ command: string; positionals: string[] }> = []; + mockDispatch.mockImplementation(async (_device, command, positionals) => { + calls.push({ command, positionals }); + return {}; + }); + + try { + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['com.example.app'], + flags: { relaunch: true, platform: 'android' }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual([{ command: 'open', positionals: ['com.example.app'] }]); + } finally { + setActiveProviderDeviceRuntimes([]); + } +}); + test('open --relaunch on iOS stops runner before close/open', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-session'; diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index b5d87d5fd..92279bc22 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -35,6 +35,7 @@ import { resetAndroidFramePerfStats } from '../../platforms/android/perf.ts'; import { activateAndroidTestIme } from '../../platforms/android/ime-lifecycle.ts'; import { withKeyedLock } from '../../utils/keyed-lock.ts'; import { emitDiagnostic, getDiagnosticsMeta } from '../../utils/diagnostics.ts'; +import { isActiveProviderDevice } from '../../provider-device-runtime.ts'; import { inferAndroidPackageAfterOpen } from './session-open-target.ts'; import { invalidOpenArgs, @@ -166,6 +167,22 @@ function buildStartupPerfSample( }; } +function shouldRunRelaunchPreClose(params: { + shouldRelaunch: boolean; + openTarget: string | undefined; + collapseSimulatorRelaunch: boolean; + device: DeviceInfo; + existingSession: SessionState | undefined; +}): boolean { + if (!params.shouldRelaunch || !params.openTarget || params.collapseSimulatorRelaunch) { + return false; + } + if (!params.existingSession && isActiveProviderDevice(params.device)) { + return false; + } + return true; +} + // fallow-ignore-next-line complexity async function completeOpenCommand(params: { req: DaemonRequest; @@ -255,7 +272,16 @@ async function completeOpenCommand(params: { openPositionals.length === 1 && isIosSimulator(device) && req.flags?.clearAppState !== true; - if (shouldRelaunch && openTarget && !collapseSimulatorRelaunch) { + if ( + shouldRunRelaunchPreClose({ + shouldRelaunch, + openTarget, + collapseSimulatorRelaunch, + device, + existingSession, + }) && + openTarget + ) { // ADR 0014 side-effect seam: the relaunch close is the FIRST device dispatch // against the existing session. Expire its frame before awaiting the close, // so a close timeout/failure that may already have torn the app down still diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 1266d400a..845a50878 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -13,6 +13,7 @@ import { import { AppError } from '../../kernel/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { readVersion } from '../../utils/version.ts'; +import type { DeviceRotation } from '../../contracts/device-rotation.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import type { Interactor, @@ -626,7 +627,13 @@ class LimrunIosInteractor implements Interactor { throw unsupported('home', 'Limrun iOS direct sessions do not expose home yet.'); } - async rotate(orientation: 'portrait' | 'landscape-left' | 'landscape-right'): Promise { + async setOrientation(orientation: DeviceRotation): Promise { + if (orientation === 'portrait-upside-down') { + throw unsupported( + 'orientation', + 'Limrun iOS direct sessions support portrait and landscape orientation, not portrait upside-down.', + ); + } await this.session.client.setOrientation(orientation === 'portrait' ? 'Portrait' : 'Landscape'); } From b30d52a63436eaa90200f5698d8ed247eac76b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 21:19:22 +0200 Subject: [PATCH 06/13] refactor: compose Android provider interactors in core --- scripts/layering/model.ts | 1 + src/core/interactors/android.ts | 24 ++++++++++++++++++++++-- src/providers/limrun/runtime.ts | 14 +------------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 63f6becae..50edbe388 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -55,6 +55,7 @@ export const UNRANKED_ZONES: ReadonlySet = new Set([ 'compat', 'mcp', 'metro', + 'providers', 'recording', 'remote', 'replay', diff --git a/src/core/interactors/android.ts b/src/core/interactors/android.ts index 543514885..af9544c1f 100644 --- a/src/core/interactors/android.ts +++ b/src/core/interactors/android.ts @@ -20,6 +20,10 @@ import { executeAndroidTouchPlan, readAndroidGestureViewport, } from '../../platforms/android/touch-executor.ts'; +import { + withAndroidAdbProvider, + type AndroidAdbProvider, +} from '../../platforms/android/adb-executor.ts'; import { readAndroidClipboardText, writeAndroidClipboardText, @@ -32,8 +36,11 @@ import type { DeviceInfo } from '../../kernel/device.ts'; import type { Interactor } from '../interactor-types.ts'; import { snapshotCaptureAnnotationsFrom } from '../../snapshot-capture-annotations.ts'; -export function createAndroidInteractor(device: DeviceInfo): Interactor { - return { +export function createAndroidInteractor( + device: DeviceInfo, + provider?: AndroidAdbProvider, +): Interactor { + const interactor: Interactor = { open: (app, options) => openAndroidApp(device, app, { activity: options?.activity, @@ -89,4 +96,17 @@ export function createAndroidInteractor(device: DeviceInfo): Interactor { setSetting: (setting, state, appId, options) => setAndroidSetting(device, setting, state, appId, options), }; + if (!provider) return interactor; + return new Proxy(interactor, { + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver); + if (typeof value !== 'function') return value; + return (...args: unknown[]) => + withAndroidAdbProvider( + provider, + { serial: device.id }, + async () => await value.apply(target, args), + ); + }, + }); } diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 845a50878..185ec1a08 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -24,7 +24,6 @@ import type { import { createAndroidInteractor } from '../../core/interactors/android.ts'; import type { DeviceInventoryProvider } from '../../core/dispatch-resolve.ts'; import { - withAndroidAdbProvider, type AndroidAdbExecutorOptions, type AndroidAdbExecutorResult, type AndroidAdbProvider, @@ -469,18 +468,7 @@ function limrunAndroidAdbError(adbArgs: string[], result: AndroidAdbExecutorResu function createLimrunAndroidInteractor( session: Extract, ): Interactor { - const base = createAndroidInteractor(session.device); - const provider = createLimrunAndroidAdbProvider(session); - return new Proxy(base, { - get(target, property, receiver) { - const value = Reflect.get(target, property, receiver); - if (typeof value !== 'function') return value; - return (...args: unknown[]) => - withAndroidAdbProvider(provider, { serial: session.device.id }, async () => - value.apply(target, args), - ); - }, - }); + return createAndroidInteractor(session.device, createLimrunAndroidAdbProvider(session)); } function createLimrunAndroidAdbProvider( From 7a75e109e962a0495adfd43bd697922e4de13b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 21:29:47 +0200 Subject: [PATCH 07/13] fix: satisfy packaged Limrun runtime checks --- src/providers/limrun/runtime.ts | 14 ++++---------- test/integration/installed-package-metro.test.ts | 1 + 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 185ec1a08..61621bc1a 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -11,7 +11,7 @@ import { type InstanceClient as LimrunIosClient, } from '@limrun/api/ios-client'; import { AppError } from '../../kernel/errors.ts'; -import { runCmd } from '../../utils/exec.ts'; +import { execFailureDetails, runCmd } from '../../utils/exec.ts'; import { readVersion } from '../../utils/version.ts'; import type { DeviceRotation } from '../../contracts/device-rotation.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; @@ -415,9 +415,7 @@ async function prepareLimrunIosAsset(artifactPath: string): Promise<{ await fs.promises.rm(tempDir, { recursive: true, force: true }); throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { command: ['zip', '-qr', zipPath, path.basename(artifactPath)].join(' '), - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, + ...execFailureDetails(result), }); } return { @@ -459,9 +457,7 @@ function limrunAdbRunOptions(options: AndroidAdbExecutorOptions | undefined) { function limrunAndroidAdbError(adbArgs: string[], result: AndroidAdbExecutorResult): AppError { return new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { command: ['adb', ...adbArgs].join(' '), - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, + ...execFailureDetails(result), }); } @@ -688,9 +684,7 @@ async function ensureAndroidPortReverse( `tcp:${options.devicePort}`, `tcp:${options.hostPort}`, ].join(' '), - exitCode: result.exitCode, - stdout: result.stdout, - stderr: result.stderr, + ...execFailureDetails(result), }); } session.reversedPorts.set(options.devicePort, options.name); diff --git a/test/integration/installed-package-metro.test.ts b/test/integration/installed-package-metro.test.ts index c01f3acc8..74725fffa 100644 --- a/test/integration/installed-package-metro.test.ts +++ b/test/integration/installed-package-metro.test.ts @@ -78,6 +78,7 @@ function linkRuntimeDependencies(installedPackageRoot: string, consumerRoot: str const sourcePath = path.join(repoRoot, 'node_modules', dependencyName); const targetPath = path.join(consumerNodeModules, dependencyName); if (!fs.existsSync(sourcePath) || fs.existsSync(targetPath)) continue; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.symlinkSync(sourcePath, targetPath, 'junction'); } } From cc70b3ecb870ae69b980f3cfdc31a71c66fc00ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 15 Jul 2026 21:39:34 +0200 Subject: [PATCH 08/13] perf: load Limrun provider runtime on demand --- .../provider-device-runtimes.test.ts | 23 +++++++++++++++++++ src/daemon/server/daemon-runtime.ts | 2 +- src/provider-device-runtimes.ts | 15 ++++++------ 3 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/provider-device-runtimes.test.ts diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts new file mode 100644 index 000000000..3807104d0 --- /dev/null +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createDefaultProviderDeviceRuntimes } from '../provider-device-runtimes.ts'; + +test('default provider runtimes skip Limrun when no Limrun API key is configured', async () => { + const runtimes = await createDefaultProviderDeviceRuntimes({}); + + assert.equal( + runtimes.some((runtime) => runtime.provider === 'limrun'), + false, + ); + await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); +}); + +test('default provider runtimes load Limrun when a Limrun API key is configured', async () => { + const runtimes = await createDefaultProviderDeviceRuntimes({ LIMRUN_API_KEY: 'lim_test_key' }); + + assert.equal( + runtimes.some((runtime) => runtime.provider === 'limrun'), + true, + ); + await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); +}); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 8538aebf7..a0b2d89ca 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -98,7 +98,7 @@ export async function startDaemonRuntime( const token = crypto.randomBytes(24).toString('hex'); const daemonProcessStartTime = readProcessStartTime(process.pid) ?? undefined; const daemonCodeSignature = resolveDaemonCodeSignature(); - const providerDeviceRuntimes = createDefaultProviderDeviceRuntimes(env); + const providerDeviceRuntimes = await createDefaultProviderDeviceRuntimes(env); const providerRuntimeProviders = createProviderDeviceRuntimeRequestProviders(providerDeviceRuntimes); const cloudArtifactProvider = composeCloudArtifactProviders( diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 704ca73ef..be894760e 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,16 +1,17 @@ import { createDefaultCloudWebDriverProviderRuntimes } from './cloud-webdriver/provider-runtimes.ts'; import type { DefaultCloudWebDriverProviderRuntimeEnv } from './cloud-webdriver/provider-runtimes.ts'; import type { ProviderDeviceRuntime } from './provider-device-runtime.ts'; -import { createLimrunRuntimeFromEnv } from './providers/limrun/runtime.ts'; export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & NodeJS.ProcessEnv; -export function createDefaultProviderDeviceRuntimes( +export async function createDefaultProviderDeviceRuntimes( env: DefaultProviderDeviceRuntimeEnv = process.env, -): ProviderDeviceRuntime[] { - return [ - ...createDefaultCloudWebDriverProviderRuntimes(env), - createLimrunRuntimeFromEnv(env), - ].filter((runtime): runtime is ProviderDeviceRuntime => Boolean(runtime)); +): Promise { + const runtimes = createDefaultCloudWebDriverProviderRuntimes(env); + if (!env.LIMRUN_API_KEY?.trim() && !env.LIM_API_KEY?.trim()) return runtimes; + + const { createLimrunRuntimeFromEnv } = await import('./providers/limrun/runtime.ts'); + const limrunRuntime = createLimrunRuntimeFromEnv(env); + return limrunRuntime ? [...runtimes, limrunRuntime] : runtimes; } From 2e5d66c3bba250f9fcaf81012cc9e32b24a37674 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 08:15:27 +0200 Subject: [PATCH 09/13] docs: document Limrun device cloud flow --- src/cli-schema/command-overrides.ts | 2 +- .../parser/__tests__/cli-help-topics.test.ts | 6 ++- src/cli/parser/cli-help.ts | 18 +++++-- website/docs/docs/client-api.md | 6 +-- website/docs/docs/device-clouds.md | 53 +++++++++++++++---- 5 files changed, 67 insertions(+), 18 deletions(-) diff --git a/src/cli-schema/command-overrides.ts b/src/cli-schema/command-overrides.ts index 8757726c1..8b4dc6b47 100644 --- a/src/cli-schema/command-overrides.ts +++ b/src/cli-schema/command-overrides.ts @@ -31,7 +31,7 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { }, connect: { usageOverride: - 'connect [cloud|proxy|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', + 'connect [cloud|proxy|limrun|browserstack|aws-device-farm] [--remote-config ] [--daemon-base-url ] [--tenant ] [--run-id ] [--lease-id ] [--lease-backend ] [--force] [--no-login]', helpDescription: 'Connect to a remote daemon, authenticate when needed, and save remote session state. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', listUsageOverride: 'connect', diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index de4a7011f..c7dc349d4 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -407,10 +407,12 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /stores the shared proxy profile and client identity/); assert.match(help, /BrowserStack: agent-device connect browserstack/); assert.match(help, /AWS Device Farm: agent-device connect aws-device-farm/); + assert.match(help, /Limrun: agent-device connect limrun/); assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/); assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); assert.match(help, /connect browserstack --platform android/); assert.match(help, /connect aws-device-farm --platform android/); + assert.match(help, /connect limrun --platform android/); assert.match(help, /AWS_REGION=us-west-2 AWS_ACCESS_KEY_ID/); assert.match(help, /AWS Device Farm uses the AWS CLI credential chain/); assert.match(help, /Prefer short-lived AWS role credentials in CI/); @@ -430,9 +432,11 @@ test('usageForCommand resolves remote help topic', async () => { assert.match(help, /Multiple agents can share one proxy/); assert.match(help, /disconnect releases local connection state/); assert.match(help, /A busy direct-proxy device error means another agent owns the device/); - assert.match(help, /BrowserStack and AWS Device Farm through local provider profiles/); + assert.match(help, /Limrun, BrowserStack, and AWS Device Farm through local provider profiles/); + assert.match(help, /Limrun uses LIMRUN_API_KEY/); assert.match(help, /BrowserStack uses BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY/); assert.match(help, /Generated connection profiles store app\/device selectors and ARNs/); + assert.match(help, /Limrun Android supports direct ADB port reverse/); assert.match(help, /local\/proxy iOS reports that the runner is already owned/); assert.match(help, /same --remote-config to every operational command/); assert.match(help, /Do not use --config as a remote profile flag/); diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index b076283a2..5d9f15847 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -802,9 +802,10 @@ Providers: Direct proxy: agent-device connect proxy --daemon-base-url stores the shared proxy profile and client identity. BrowserStack: agent-device connect browserstack stores a local provider profile and creates the App Automate session on first open. AWS Device Farm: agent-device connect aws-device-farm stores a local provider profile and creates the remote access session on first open. + Limrun: agent-device connect limrun stores a local provider profile and creates a direct iOS or Android instance on first open. Device cloud interfaces: - CLI is the canonical bootstrap path: connect browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. + CLI is the canonical bootstrap path: connect limrun/browserstack/aws-device-farm, then use normal open/snapshot/click/close/artifacts/disconnect commands. JavaScript can skip persisted connect state by passing leaseProvider plus provider fields to createAgentDeviceClient or per-command options. MCP exposes operational tools such as open, snapshot, click, close, and artifacts. It does not expose connect/disconnect; run CLI connect first in the same state dir before relying on MCP tools. @@ -845,6 +846,14 @@ AWS Device Farm hosted-device flow: agent-device artifacts --json agent-device disconnect +Limrun direct-device flow: + LIMRUN_API_KEY=... + agent-device connect limrun --platform android + agent-device open com.example.app + agent-device snapshot -i + agent-device close + agent-device disconnect + Local profile flow: agent-device connect --remote-config ./remote-config.json agent-device open com.example.app @@ -861,9 +870,10 @@ Rules: Use connect without --remote-config when the cloud control plane owns the connection profile. 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 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. 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. - Prefer short-lived AWS role credentials in CI. Generated connection profiles store app/device selectors and ARNs, not BrowserStack access keys or AWS credentials. + 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 (LIM_API_KEY is accepted as an alias). 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. + 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. + 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. Device leases are acquired on open and expire after five minutes without commands. Multiple agents can share one proxy when each uses connect proxy, open, commands, close, and disconnect. diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 3816700a9..90da296ee 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -113,7 +113,7 @@ standalone Simulator app. or contacts the daemon. Pass `{ stateDir }` to resolve an explicit override the same way the CLI resolves `--state-dir`. `client.sessions.artifacts({ provider, providerSessionId })` mirrors `artifacts --provider ... --provider-session ...` and returns provider-hosted `cloudArtifacts`. -Use it for BrowserStack or AWS Device Farm session videos/logs after a cloud session has stopped, or omit `providerSessionId` when an embedding host has registered a cloud runtime that can infer the active lease. +Use it for BrowserStack or AWS Device Farm session videos/logs after a cloud session has stopped, or omit `providerSessionId` when an embedding host has registered a provider runtime that can infer the active lease. Limrun does not currently expose provider artifacts through this command. ```ts const result = await client.sessions.artifacts({ @@ -128,7 +128,7 @@ for (const artifact of result.cloudArtifacts) { ## Device cloud sessions -BrowserStack and AWS Device Farm can be driven through the normal typed client methods. Use the CLI `connect browserstack` / `connect aws-device-farm` flow when you want persisted local connection state. Use direct client config when a Node integration already owns credentials and provider selectors. +Limrun, BrowserStack, and AWS Device Farm can be driven through the normal typed client methods. Use the corresponding CLI `connect` flow when you want persisted local connection state. Use direct client config when a Node integration already owns credentials and provider selectors. ```ts import { createAgentDeviceClient } from 'agent-device'; @@ -146,7 +146,7 @@ await client.capture.snapshot({ interactiveOnly: true }); const closed = await client.sessions.close(); ``` -Use `client.sessions.artifacts({ provider, providerSessionId })` with `closed.provider?.providerSessionId` to fetch hosted video/log URLs after close. See [Device Clouds & Farms](/docs/device-clouds) for BrowserStack, AWS Device Farm, CLI, JavaScript, and MCP flows. +Use `client.sessions.artifacts({ provider, providerSessionId })` with `closed.provider?.providerSessionId` to fetch hosted video/log URLs after close when the provider supports them. See [Device Clouds & Farms](/docs/device-clouds) for Limrun, BrowserStack, AWS Device Farm, CLI, JavaScript, and MCP flows. ## Web sessions diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index 1f631d712..b06c1c5f2 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -1,18 +1,19 @@ --- title: Device Clouds & Farms -description: Connect agents to BrowserStack device clouds and AWS Device Farm without interactive login. +description: Connect agents to Limrun, BrowserStack, and AWS Device Farm without interactive login. --- # Device Clouds & Farms -Use device cloud and device farm connections when the agent should drive BrowserStack App Automate or AWS Device Farm remote access through the local `agent-device` daemon: +Use device cloud and device farm connections when the agent should drive Limrun direct instances, BrowserStack App Automate, or AWS Device Farm remote access through the local `agent-device` daemon: ```bash agent-device connect browserstack ... agent-device connect aws-device-farm ... +agent-device connect limrun ... ``` -These providers are not remote `agent-device` daemons. `connect browserstack` and `connect aws-device-farm` write a local generated profile, then the first lease-allocating command such as `open` creates the hosted WebDriver session. +These providers are not remote `agent-device` daemons. `connect` writes a local generated profile, then the first lease-allocating command such as `open` creates the provider session. BrowserStack and AWS Device Farm use hosted WebDriver sessions; Limrun uses its direct iOS/Android provider runtime. ## Interface Summary @@ -20,11 +21,11 @@ Device cloud providers have one setup model and three ways to drive the resultin | Interface | What it does well | How provider setup works | | ----------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| CLI | Best default for agents and CI. It creates the local provider profile once, then normal commands inherit it. | Run `agent-device connect browserstack` or `agent-device connect aws-device-farm`, then `open`, `snapshot`, `click`, `close`, `artifacts`, and `disconnect`. | +| CLI | Best default for agents and CI. It creates the local provider profile once, then normal commands inherit it. | Run `agent-device connect limrun`, `agent-device connect browserstack`, or `agent-device connect aws-device-farm`, then normal device commands. | | JavaScript client | Best for Node integrations that already own process configuration. | Pass provider fields in `createAgentDeviceClient(...)` config or per-command options, then call normal client methods such as `client.apps.open(...)` and `client.capture.snapshot(...)`. | | MCP | Best for tool-only agents after bootstrap. MCP exposes operational tools backed by the same command contracts. | Run CLI `connect ...` before starting or using the MCP server in the same effective state directory. MCP intentionally does not expose `connect` or `disconnect` as provider setup tools. | -The CLI is the canonical bootstrap path because it persists a non-secret generated profile and keeps BrowserStack/AWS credentials in the environment. After bootstrap, CLI, JavaScript, and MCP all use the same daemon/session behavior. +The CLI is the canonical bootstrap path because it persists a non-secret generated profile and keeps provider credentials in the environment. After bootstrap, CLI, JavaScript, and MCP all use the same daemon/session behavior. ## Autonomous Agent Requirements @@ -32,7 +33,7 @@ Agents can connect autonomously when all required credentials and selectors are - Do not rely on browser-based login inside the agent workflow. - Put provider credentials in CI secrets, a local ignored env file, or the CI platform's secret store. -- Keep generated remote profiles non-secret. They may contain provider app ids, Device Farm ARNs, device names, OS versions, and labels; they must not contain BrowserStack access keys or AWS secret keys. +- Keep generated remote profiles non-secret. They may contain provider app ids, Device Farm ARNs, device names, OS versions, and labels; they must not contain Limrun API keys, BrowserStack access keys, or AWS secret keys. - Run `agent-device artifacts --json` after `close` when the provider has video/log URLs to fetch. ## CLI Experience @@ -46,6 +47,39 @@ The CLI experience is: 5. Run `agent-device artifacts --json` to retrieve provider-hosted video/log/dashboard URLs. 6. Run `agent-device disconnect` to clear local connection state. +### Limrun + +Required environment: + +```bash +export LIMRUN_API_KEY=... +``` + +`LIM_API_KEY` is accepted as a compatibility alias. `LIMRUN_REGION` (or `LIM_REGION`) optionally selects a Limrun region. + +Choose the platform to create a matching instance: + +```bash +agent-device connect limrun --platform android +``` + +Full Android flow: + +```bash +export LIMRUN_API_KEY=... + +agent-device connect limrun --platform android +agent-device open com.example.app +agent-device snapshot -i +agent-device click 'label="Continue"' +agent-device close +agent-device disconnect +``` + +Limrun Android uses the direct ADB tunnel, so the normal Android helper-backed snapshots, installs, and port reverse flow are available. This makes a local Metro server reachable through the normal Android reverse setup. + +Limrun iOS uses the direct Limrun iOS client. It supports normal app lifecycle, snapshots, screenshots, taps, text input, scrolling, and app install, but it cannot reverse a remote device port to a local host port. For iOS Metro or React DevTools, use a publicly reachable HTTPS endpoint or a bridge URL rather than a local-only address. Limrun does not currently expose provider artifacts through `agent-device artifacts`. + ### BrowserStack Required environment: @@ -213,11 +247,11 @@ const client = createAgentDeviceClient({ }); ``` -The JavaScript client does not publish a hosted WebDriver SDK subpath. Use the normal typed client methods; provider implementation details stay internal. +The JavaScript client does not publish provider SDK subpaths. Use the normal typed client methods; provider implementation details stay internal. Limrun uses the same client shape with `leaseProvider: 'limrun'`, `platform: 'android'` or `platform: 'ios'`, and `LIMRUN_API_KEY` in the daemon environment. ## MCP Experience -The MCP server exposes operational command tools such as `open`, `snapshot`, `click`, `close`, and `artifacts`. It does not expose `connect browserstack` or `connect aws-device-farm`. +The MCP server exposes operational command tools such as `open`, `snapshot`, `click`, `close`, and `artifacts`. It does not expose provider `connect` commands. For MCP-only operation, bootstrap with the CLI first in the same effective state directory: @@ -238,7 +272,7 @@ close {} artifacts {} ``` -Use the same pattern for AWS Device Farm: provide AWS credentials in the MCP server environment, run CLI `connect aws-device-farm ...` once, then let MCP tools operate on the active connection. If an integration cannot run CLI bootstrap, use the JavaScript client path instead of MCP for provider setup. +Use the same pattern for Limrun or AWS Device Farm: provide the provider credentials in the MCP server environment, run the corresponding CLI `connect` command once, then let MCP tools operate on the active connection. If an integration cannot run CLI bootstrap, use the JavaScript client path instead of MCP for provider setup. ## Artifact Lookup @@ -256,4 +290,5 @@ BrowserStack can return session video, Appium logs, device logs, dashboard URL, - If BrowserStack connect fails before opening a session, check `BROWSERSTACK_USERNAME`, `BROWSERSTACK_ACCESS_KEY`, `--provider-app`, `--provider-os-version`, and `--device`. - If AWS allocation fails, first run `aws sts get-caller-identity` in the same CI step to confirm the AWS CLI credential chain is active, then verify the Device Farm ARNs and region. +- If Limrun allocation fails, check `LIMRUN_API_KEY`, `--platform ios|android`, and the optional `LIMRUN_REGION`. Keep the key available in the environment used to start the local daemon. - If artifact lookup is pending immediately after `close`, retry `agent-device artifacts --json`. Some providers finalize video/log URLs asynchronously after the hosted session stops. From 101d132cc52cab74c682ebac4aa91095f0dbf67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 08:32:17 +0200 Subject: [PATCH 10/13] refactor: reuse Android reverse provider for Limrun --- .../cli-react-devtools-session.test.ts | 61 +- src/__tests__/cli-react-devtools.test.ts | 1 + src/__tests__/limrun-runtime.test.ts | 42 +- src/cli.ts | 23 +- src/cli/commands/react-devtools.ts | 29 +- .../android/__tests__/adb-executor.test.ts | 11 + src/platforms/android/adb-executor.ts | 9 +- src/providers/limrun/android.ts | 228 +++++++ src/providers/limrun/device.ts | 14 +- src/providers/limrun/ios.ts | 270 ++++++++ src/providers/limrun/runtime.ts | 596 ++---------------- 11 files changed, 714 insertions(+), 570 deletions(-) create mode 100644 src/providers/limrun/android.ts create mode 100644 src/providers/limrun/ios.ts diff --git a/src/__tests__/cli-react-devtools-session.test.ts b/src/__tests__/cli-react-devtools-session.test.ts index f772c376d..38ca9b748 100644 --- a/src/__tests__/cli-react-devtools-session.test.ts +++ b/src/__tests__/cli-react-devtools-session.test.ts @@ -15,7 +15,7 @@ import { hashRemoteConfigFile, writeRemoteConnectionState, } from '../remote/remote-connection-state.ts'; -import type { DaemonResponse } from '../daemon/client/daemon-client.ts'; +import type { DaemonRequest, DaemonResponse } from '../daemon/client/daemon-client.ts'; afterEach(() => { vi.clearAllMocks(); @@ -73,3 +73,62 @@ test('react-devtools uses active remote connection session after defaults are me assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls.length, 1); assert.equal(vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1]?.session, 'adc-android'); }); + +test('react-devtools starts Limrun port reverse through the daemon', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-react-devtools-limrun-')); + const stateDir = path.join(tempRoot, 'state'); + const originalExit = process.exit; + let exitCode: number | undefined; + const restoreEnv = installIsolatedCliTestEnv(); + (process as any).exit = ((code?: number) => { + exitCode = code ?? 0; + }) as typeof process.exit; + const remoteConfigPath = path.join(tempRoot, 'limrun.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ platform: 'android' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'limrun-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: {}, + tenant: 'limrun', + runId: 'run-1', + leaseId: 'lease-1', + leaseBackend: 'android-instance', + leaseProvider: 'limrun', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + const requests: Array> = []; + const sendToDaemon = async (request: Omit): Promise => { + requests.push(request); + return { ok: true, data: {} }; + }; + vi.mocked(runReactDevtoolsCommand).mockImplementationOnce(async (_args, options) => { + await options?.configureDirectPortReverse?.(); + return 0; + }); + + try { + await runCli(['react-devtools', 'start', '--state-dir', stateDir], { sendToDaemon }); + } finally { + restoreEnv(); + process.exit = originalExit; + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + assert.equal(exitCode, 0); + assert.equal(requests.length, 1); + const request = requests[0]; + assert.equal(request?.command, 'runtime'); + assert.deepEqual(request?.positionals, ['port-reverse']); + assert.equal(request?.session, 'limrun-android'); + assert.equal(request?.flags?.leaseProvider, 'limrun'); + assert.equal(request?.flags?.devicePort, 8097); + assert.equal(request?.flags?.hostPort, 8097); + assert.equal(request?.flags?.portReverseName, 'react-devtools'); +}); diff --git a/src/__tests__/cli-react-devtools.test.ts b/src/__tests__/cli-react-devtools.test.ts index c11476d7d..f9896250f 100644 --- a/src/__tests__/cli-react-devtools.test.ts +++ b/src/__tests__/cli-react-devtools.test.ts @@ -250,6 +250,7 @@ test('react-devtools start configures direct remote port reverse', async () => { ...remoteBridgeScope, metroProxyBaseUrl: undefined, leaseBackend: 'android-instance', + leaseProvider: 'limrun', remoteConfig: '/tmp/remote.json', session: 'default', }, diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index d5e80278b..1f3923cc7 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -146,6 +146,12 @@ test('Limrun Android reverses localhost URL ports through the persistent ADB tun }); try { + vi.mocked(runCmd).mockImplementation(async (_command, args) => ({ + stdout: + args[2] === 'reverse' && args[3] === '--list' ? '127.0.0.1:62001 tcp:8081 tcp:8081\n' : '', + stderr: '', + exitCode: 0, + })); const device = await allocateLimrunDevice(runtime, androidLease()); const interactor = runtime.getInteractor(device, {}); if (!interactor) throw new Error('Limrun runtime must return an interactor'); @@ -209,13 +215,19 @@ function assertAndroidTunnelLifecycle(openUrl: string): void { openUrl, ]); assert.deepEqual(vi.mocked(runCmd).mock.calls[2]?.[1], [ + '-s', + '127.0.0.1:62001', + 'reverse', + '--list', + ]); + assert.deepEqual(vi.mocked(runCmd).mock.calls[3]?.[1], [ '-s', '127.0.0.1:62001', 'reverse', '--remove', 'tcp:8081', ]); - assert.deepEqual(vi.mocked(runCmd).mock.calls[3]?.[1], ['disconnect', '127.0.0.1:62001']); + assert.deepEqual(vi.mocked(runCmd).mock.calls[4]?.[1], ['disconnect', '127.0.0.1:62001']); assert.equal(limrunMockState.androidTunnelClose.mock.calls.length, 1); } @@ -266,13 +278,39 @@ test('Limrun Android installs direct local artifacts through Limrun assets', asy assert.deepEqual(result, { packageName: 'com.example.android', launchTarget: 'com.example.android', - appName: 'android', + appName: 'Example', }); } finally { await runtime.shutdown(); } }); +test('Limrun Android shares an in-flight ADB tunnel across concurrent port reverse requests', async () => { + const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', version: '9.9.9-test' }); + + try { + await allocateLimrunDevice(runtime, androidLease()); + await Promise.all([ + runtime.configurePortReverse({ + leaseId: 'lease-android', + devicePort: 8081, + hostPort: 8081, + name: 'metro', + }), + runtime.configurePortReverse({ + leaseId: 'lease-android', + devicePort: 8097, + hostPort: 8097, + name: 'react-devtools', + }), + ]); + + assert.equal(limrunMockState.androidStartAdbTunnel.mock.calls.length, 1); + } finally { + await runtime.shutdown(); + } +}); + test('Limrun iOS installs direct local artifacts through Limrun assets', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-limrun-install-')); const ipaPath = path.join(tempRoot, 'Demo.ipa'); diff --git a/src/cli.ts b/src/cli.ts index 4b8f7412d..c9acd341b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ import { parseRawArgs, usage, usageForCommand } from './cli/parser/args.ts'; import { suggestCommandFor } from './cli/parser/command-suggestions.ts'; import { asAppError, AppError, normalizeError } from './kernel/errors.ts'; +import { throwDaemonError } from './daemon-error.ts'; import { printHumanError, printJson } from './utils/output.ts'; import { readVersion } from './utils/version.ts'; import { pathToFileURL } from 'node:url'; @@ -37,7 +38,7 @@ import { import { resolveRemoteAuthForCli } from './cli/auth-session.ts'; import type { CliFlags, FlagKey } from './commands/cli-grammar/flag-types.ts'; import type { SessionRuntimeHints } from './kernel/contracts.ts'; -import { isKnownCliCommandName } from './command-catalog.ts'; +import { INTERNAL_COMMANDS, isKnownCliCommandName } from './command-catalog.ts'; type CliDeps = { sendToDaemon: typeof sendToDaemon; @@ -210,11 +211,29 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): try { if (command === 'react-devtools') { const exitCode = await runReactDevtoolsCommand(positionals, { - flags: effectiveFlags, + flags: { + ...effectiveFlags, + leaseProvider: connectionDefaults?.connection?.leaseProvider, + }, stateDir: daemonPaths.baseDir, session: effectiveFlags.session ?? sessionName, cwd: process.cwd(), env: process.env, + configureDirectPortReverse: async () => { + const response = await deps.sendToDaemon({ + command: INTERNAL_COMMANDS.runtime, + positionals: ['port-reverse'], + flags: { + ...effectiveFlags, + leaseProvider: connectionDefaults?.connection?.leaseProvider, + devicePort: 8097, + hostPort: 8097, + portReverseName: 'react-devtools', + }, + session: effectiveFlags.session ?? sessionName, + }); + if (!response.ok) throwDaemonError(response.error); + }, }); process.exit(exitCode); return; diff --git a/src/cli/commands/react-devtools.ts b/src/cli/commands/react-devtools.ts index 9cee03516..ecf2ba9b1 100644 --- a/src/cli/commands/react-devtools.ts +++ b/src/cli/commands/react-devtools.ts @@ -11,18 +11,22 @@ const AGENT_REACT_DEVTOOLS_VERSION = '0.4.0'; export const AGENT_REACT_DEVTOOLS_PACKAGE = `agent-react-devtools@${AGENT_REACT_DEVTOOLS_VERSION}`; const AGENT_REACT_DEVTOOLS_BIN = 'agent-react-devtools'; +type ReactDevtoolsFlags = Pick< + CliFlags, + | 'leaseBackend' + | 'metroProxyBaseUrl' + | 'metroBearerToken' + | 'tenant' + | 'runId' + | 'leaseId' + | 'remoteConfig' + | 'session' +> & { + leaseProvider?: string; +}; + type ReactDevtoolsCommandOptions = { - flags?: Pick< - CliFlags, - | 'leaseBackend' - | 'metroProxyBaseUrl' - | 'metroBearerToken' - | 'tenant' - | 'runId' - | 'leaseId' - | 'remoteConfig' - | 'session' - >; + flags?: ReactDevtoolsFlags; stateDir?: string; session?: string; cwd?: string; @@ -164,7 +168,8 @@ function shouldConfigureDirectReverse( const { flags } = options; if (!flags) return false; return ( - isRemoteBridgeBackend(flags.leaseBackend) && + flags.leaseProvider === 'limrun' && + flags.leaseBackend === 'android-instance' && flags.metroProxyBaseUrl === undefined && options.configureDirectPortReverse !== undefined ); diff --git a/src/platforms/android/__tests__/adb-executor.test.ts b/src/platforms/android/__tests__/adb-executor.test.ts index 098aa0f00..2fc4f1637 100644 --- a/src/platforms/android/__tests__/adb-executor.test.ts +++ b/src/platforms/android/__tests__/adb-executor.test.ts @@ -29,6 +29,7 @@ import { resolveAndroidAdbExecutor, resolveAndroidAdbProvider, withAndroidAdbProvider, + type AndroidAdbProvider, } from '../adb-executor.ts'; import { runCmd, runCmdBackground } from '../../../utils/exec.ts'; import { AppError, normalizeError } from '../../../kernel/errors.ts'; @@ -221,6 +222,16 @@ test('createAndroidPortReverseManager rejects mappings owned by another session' ); }); +test('createAndroidPortReverseManager reuses a provider-held canonical manager', () => { + const provider: AndroidAdbProvider = { + exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }; + const manager = createAndroidPortReverseManager(provider); + provider.reverse = manager; + + assert.strictEqual(createAndroidPortReverseManager(provider), manager); +}); + test('createAndroidPortReverseManager lists parsed reverse mappings with owners', async () => { const manager = createAndroidPortReverseManager(async (args) => { if (args.join(' ') === 'reverse --list') { diff --git a/src/platforms/android/adb-executor.ts b/src/platforms/android/adb-executor.ts index 3ae326501..ba55aa54c 100644 --- a/src/platforms/android/adb-executor.ts +++ b/src/platforms/android/adb-executor.ts @@ -498,9 +498,12 @@ export function createAndroidPortReverseManager( provider: AndroidAdbProvider | AndroidAdbExecutor, ): AndroidPortReverseProvider { const normalized = normalizeAndroidAdbProvider(provider); + if (normalized.reverse && managedAndroidPortReverseProviders.has(normalized.reverse)) { + return normalized.reverse; + } const reverse = normalized.reverse ?? createExecAndroidPortReverseProvider(normalized.exec); const active = new Map(); - return { + const manager: AndroidPortReverseProvider = { async ensure(mapping, options) { const current = active.get(mapping.local); if (current && current.ownerId !== mapping.ownerId) { @@ -541,6 +544,8 @@ export function createAndroidPortReverseManager( return reverse.list ? await reverse.list(options) : [...active.values()]; }, }; + managedAndroidPortReverseProviders.add(manager); + return manager; } function normalizeAndroidAdbProvider( @@ -648,6 +653,8 @@ function stripAdbSerialArgs(args: string[], expectedSerial: string): string[] | return args.slice(2); } +const managedAndroidPortReverseProviders = new WeakSet(); + function createExecAndroidPortReverseProvider(adb: AndroidAdbExecutor): AndroidPortReverseProvider { const owned = new Map>(); return { diff --git a/src/providers/limrun/android.ts b/src/providers/limrun/android.ts new file mode 100644 index 000000000..9276a2b12 --- /dev/null +++ b/src/providers/limrun/android.ts @@ -0,0 +1,228 @@ +import path from 'node:path'; +import type Limrun from '@limrun/api'; +import { + createInstanceClient as createAndroidInstanceClient, + type InstanceClient as LimrunAndroidClient, +} from '@limrun/api/instance-client'; +import { createAndroidInteractor } from '../../core/interactors/android.ts'; +import type { Interactor } from '../../core/interactor-types.ts'; +import type { DeviceLease } from '../../daemon/lease-registry.ts'; +import { AppError } from '../../kernel/errors.ts'; +import type { DeviceInfo } from '../../kernel/device.ts'; +import { inferAndroidAppName } from '../../platforms/android/app-lifecycle.ts'; +import { + createAndroidPortReverseManager, + type AndroidAdbExecutorOptions, + type AndroidAdbExecutorResult, + type AndroidAdbProvider, + type AndroidPortReverseEndpoint, +} from '../../platforms/android/adb-executor.ts'; +import type { + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, + ProviderPortReverseOptions, +} from '../../provider-device-runtime.ts'; +import { execFailureDetails, runCmd } from '../../utils/exec.ts'; + +type LimrunAdbTunnel = Awaited>; + +type LimrunAndroidAdbSession = { + platform: 'android'; + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + client: LimrunAndroidClient; + adbTunnel?: LimrunAdbTunnel; + adbSerial?: string; + adbTunnelPromise?: Promise; +}; + +export type LimrunAndroidSession = LimrunAndroidAdbSession & { + adbProvider: AndroidAdbProvider; +}; + +export async function createLimrunAndroidSession(options: { + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + apiUrl: string; + adbUrl: string; + token: string; +}): Promise { + const client = await createAndroidInstanceClient({ + apiUrl: options.apiUrl, + adbUrl: options.adbUrl, + token: options.token, + logLevel: 'warn', + }); + const session: LimrunAndroidAdbSession = { + platform: 'android', + lease: options.lease, + instanceId: options.instanceId, + device: options.device, + client, + }; + const adbProvider: AndroidAdbProvider = { + exec: async (args, execOptions) => await runLimrunAndroidAdb(session, args, execOptions), + text: async (request) => { + await client.setText(request.target, request.text); + }, + }; + adbProvider.reverse = createAndroidPortReverseManager(adbProvider); + return Object.assign(session, { adbProvider }); +} + +export function createLimrunAndroidInteractor(session: LimrunAndroidSession): Interactor { + return createAndroidInteractor(session.device, session.adbProvider); +} + +export async function installLimrunAndroidApp( + limrun: Limrun, + session: LimrunAndroidSession, + installablePath: string, + options?: ProviderDeviceInstallOptions, +): Promise { + const packageName = normalizeOptionalString(options?.packageNameHint); + if (options?.relaunch && packageName) { + await runLimrunAndroidAdb(session, ['shell', 'am', 'force-stop', packageName], { + allowFailure: true, + }); + } + const asset = await limrun.assets.getOrUpload({ + path: installablePath, + name: buildAndroidAssetName(packageName, installablePath), + }); + await session.client.sendAsset(asset.signedDownloadUrl); + return { + ...(packageName ? { packageName, launchTarget: packageName } : {}), + ...(packageName ? { appName: inferAndroidAppName(packageName) } : {}), + }; +} + +export async function configureLimrunAndroidPortReverse( + session: LimrunAndroidSession, + options: ProviderPortReverseOptions, +): Promise { + await session.adbProvider.reverse?.ensure({ + local: tcpEndpoint(options.devicePort), + remote: tcpEndpoint(options.hostPort), + ownerId: options.name, + }); +} + +export async function removeLimrunAndroidPortReverse( + session: LimrunAndroidSession, + options: ProviderPortReverseOptions, +): Promise { + await session.adbProvider.reverse?.remove(tcpEndpoint(options.devicePort)); +} + +export async function cleanupLimrunAndroidAdbTunnel(session: LimrunAndroidSession): Promise { + await session.adbTunnelPromise?.catch(() => {}); + const serial = session.adbSerial; + if (serial) { + await cleanupAndroidPortReverse(session); + await runCmd('adb', ['disconnect', serial], { + allowFailure: true, + timeoutMs: 10_000, + }).catch(() => {}); + } + session.adbTunnel?.close(); + session.adbTunnel = undefined; + session.adbSerial = undefined; + session.adbTunnelPromise = undefined; +} + +async function cleanupAndroidPortReverse(session: LimrunAndroidSession): Promise { + const reverse = session.adbProvider.reverse; + if (!reverse?.list) return; + const mappings = await reverse.list().catch(() => []); + const owners = new Set(); + const unownedLocals: AndroidPortReverseEndpoint[] = []; + for (const mapping of mappings) { + if (mapping.ownerId) owners.add(mapping.ownerId); + else unownedLocals.push(mapping.local); + } + await Promise.allSettled([ + ...[...owners].map(async (ownerId) => await reverse.removeAllOwned(ownerId)), + ...unownedLocals.map(async (local) => await reverse.remove(local)), + ]); +} + +async function runLimrunAndroidAdb( + session: LimrunAndroidAdbSession, + args: string[], + options?: AndroidAdbExecutorOptions, +): Promise { + const { adbArgs, result } = await executeLimrunAndroidAdb(session, args, options); + return requireSuccessfulLimrunAndroidAdb(adbArgs, result, options?.allowFailure); +} + +async function executeLimrunAndroidAdb( + session: LimrunAndroidAdbSession, + args: string[], + options?: AndroidAdbExecutorOptions, +): Promise<{ adbArgs: string[]; result: AndroidAdbExecutorResult }> { + const serial = await ensurePersistentAndroidAdbSerial(session); + const adbArgs = ['-s', serial, ...args]; + const result = await runCmd('adb', adbArgs, { + allowFailure: options?.allowFailure, + binaryStdout: options?.binaryStdout, + stdin: options?.stdin, + timeoutMs: options?.timeoutMs ?? 30_000, + signal: options?.signal, + }); + return { adbArgs, result }; +} + +function requireSuccessfulLimrunAndroidAdb( + adbArgs: string[], + result: AndroidAdbExecutorResult, + allowFailure: boolean | undefined, +): AndroidAdbExecutorResult { + if (result.exitCode !== 0 && allowFailure !== true) { + throw new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { + command: ['adb', ...adbArgs].join(' '), + ...execFailureDetails(result), + }); + } + return result; +} + +async function ensurePersistentAndroidAdbSerial(session: LimrunAndroidAdbSession): Promise { + if (session.adbSerial) return session.adbSerial; + const pending = session.adbTunnelPromise ?? startAndroidAdbTunnel(session); + session.adbTunnelPromise = pending; + try { + return await pending; + } catch (error) { + if (session.adbTunnelPromise === pending) session.adbTunnelPromise = undefined; + throw error; + } +} + +async function startAndroidAdbTunnel(session: LimrunAndroidAdbSession): Promise { + const tunnel = await session.client.startAdbTunnel(); + const serial = `${tunnel.address.address}:${tunnel.address.port}`; + session.adbTunnel = tunnel; + session.adbSerial = serial; + return serial; +} + +function tcpEndpoint(port: number): AndroidPortReverseEndpoint { + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new AppError('INVALID_ARGS', `Invalid Android tcp reverse port: ${port}`); + } + return `tcp:${port}`; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function buildAndroidAssetName(packageName: string | undefined, artifactPath: string): string { + const extension = path.extname(artifactPath) || '.apk'; + const prefix = packageName?.replace(/[^a-zA-Z0-9_.-]+/g, '-') || 'android-app'; + return `${prefix}${extension}`; +} diff --git a/src/providers/limrun/device.ts b/src/providers/limrun/device.ts index 193eb0f07..85ab35bee 100644 --- a/src/providers/limrun/device.ts +++ b/src/providers/limrun/device.ts @@ -31,20 +31,14 @@ export function buildLimrunDevice( export function parseLimrunDeviceId( value: string, -): { platform: LimrunPlatform; leaseId: string } | null { +): { platform: LimrunPlatform; leaseId: string } | undefined { const [prefix, platform, leaseId] = value.split(':'); - if (prefix !== LIMRUN_DEVICE_ID_PREFIX) return null; - if (platform !== 'ios' && platform !== 'android') return null; - if (!leaseId) return null; + if (prefix !== LIMRUN_DEVICE_ID_PREFIX) return undefined; + if (platform !== 'ios' && platform !== 'android') return undefined; + if (!leaseId) return undefined; return { platform, leaseId }; } -export function readLimrunLeaseIdFromInventoryRequest(request: { - leaseId?: string; -}): string | undefined { - return request.leaseId; -} - function limrunDeviceId(platform: LimrunPlatform, leaseId: string): string { return `${LIMRUN_DEVICE_ID_PREFIX}:${platform}:${leaseId}`; } diff --git a/src/providers/limrun/ios.ts b/src/providers/limrun/ios.ts new file mode 100644 index 000000000..ca27bd04e --- /dev/null +++ b/src/providers/limrun/ios.ts @@ -0,0 +1,270 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type Limrun from '@limrun/api'; +import { + createInstanceClient as createIosInstanceClient, + type InstanceClient as LimrunIosClient, +} from '@limrun/api/ios-client'; +import type { DeviceRotation } from '../../contracts/device-rotation.ts'; +import type { Interactor, SnapshotOptions, SnapshotResult } from '../../core/interactor-types.ts'; +import type { DeviceLease } from '../../daemon/lease-registry.ts'; +import type { DeviceInfo } from '../../kernel/device.ts'; +import { AppError } from '../../kernel/errors.ts'; +import type { + ProviderDeviceInstallOptions, + ProviderDeviceInstallResult, +} from '../../provider-device-runtime.ts'; +import { execFailureDetails, runCmd } from '../../utils/exec.ts'; +import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; + +export type LimrunIosSession = { + platform: 'ios'; + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + client: LimrunIosClient; +}; + +export async function createLimrunIosSession(options: { + lease: DeviceLease; + instanceId: string; + device: DeviceInfo; + apiUrl: string; + token: string; +}): Promise { + const client = await createIosInstanceClient({ + apiUrl: options.apiUrl, + token: options.token, + logLevel: 'warn', + }); + return { + platform: 'ios', + lease: options.lease, + instanceId: options.instanceId, + device: options.device, + client, + }; +} + +export async function installLimrunIosApp( + limrun: Limrun, + session: LimrunIosSession, + installablePath: string, + options?: ProviderDeviceInstallOptions, +): Promise { + const prepared = await prepareLimrunIosAsset(installablePath); + try { + const asset = await limrun.assets.getOrUpload({ + path: prepared.uploadPath, + name: prepared.assetName, + }); + const result = await session.client.installApp(asset.signedDownloadUrl, { + md5: asset.md5, + launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'ForegroundIfRunning', + }); + const bundleId = normalizeOptionalString(result.bundleId) ?? options?.appIdentifierHint; + return { + ...(bundleId ? { bundleId, launchTarget: bundleId } : {}), + appName: inferAppNameFromPath(installablePath), + }; + } finally { + await prepared.cleanup(); + } +} + +export function createLimrunIosInteractor(session: LimrunIosSession): Interactor { + return new LimrunIosInteractor(session); +} + +class LimrunIosInteractor implements Interactor { + private readonly session: LimrunIosSession; + + constructor(session: LimrunIosSession) { + this.session = session; + } + + async open(app: string, options?: { url?: string }): Promise { + if (options?.url) { + await this.session.client.launchApp(app); + await this.session.client.openUrl(options.url); + return; + } + if (looksLikeUrl(app)) { + await this.session.client.openUrl(app); + return; + } + await this.session.client.launchApp(resolveIosTarget(app)); + } + + async openDevice(): Promise {} + + async close(app: string): Promise { + if (app) await this.session.client.terminateApp(resolveIosTarget(app)).catch(() => {}); + } + + async tap(x: number, y: number): Promise { + await this.session.client.tap(x, y); + } + + async tapElementSelector(selector: { + key: 'id' | 'label' | 'text' | 'value'; + value: string; + }): Promise | void> { + await this.session.client.tapElement(toIosSelector(selector)); + } + + async doubleTap(x: number, y: number): Promise { + await this.tap(x, y); + await this.tap(x, y); + } + + async longPress(): Promise { + throw unsupported('longpress', 'Limrun iOS direct sessions do not expose long press yet.'); + } + + async focus(x: number, y: number): Promise { + await this.tap(x, y); + } + + async type(text: string, delayMs?: number): Promise { + if (delayMs && delayMs > 0) { + for (const char of Array.from(text)) { + await this.session.client.typeText(char); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return; + } + await this.session.client.typeText(text); + } + + async fill(x: number, y: number, text: string): Promise { + await this.tap(x, y); + await this.session.client.typeText(text); + } + + async fillElementSelector( + selector: { key: 'id' | 'label' | 'text' | 'value'; value: string }, + text: string, + ): Promise { + await this.session.client.setElementValue(text, toIosSelector(selector)); + } + + async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { + await this.session.client.scroll(direction, options?.pixels ?? 300); + } + + async screenshot(outPath: string): Promise { + const screenshot = await this.session.client.screenshot(); + await writeBase64File(outPath, screenshot.base64); + } + + async snapshot(_options?: SnapshotOptions): Promise { + const treeJson = await this.session.client.elementTree(); + const parsed = JSON.parse(treeJson) as IosTreeNode | IosTreeNode[]; + return { nodes: flattenIosTree(parsed), backend: 'xctest' }; + } + + async back(): Promise { + await this.session.client.pressKey('escape'); + } + + async home(): Promise { + throw unsupported('home', 'Limrun iOS direct sessions do not expose home yet.'); + } + + async setOrientation(orientation: DeviceRotation): Promise { + if (orientation === 'portrait-upside-down') { + throw unsupported( + 'orientation', + 'Limrun iOS direct sessions support portrait and landscape orientation, not portrait upside-down.', + ); + } + await this.session.client.setOrientation(orientation === 'portrait' ? 'Portrait' : 'Landscape'); + } + + async performGesture(): Promise { + throw unsupported( + 'gesture', + 'Limrun iOS direct sessions do not expose portable gesture execution yet.', + ); + } + + async appSwitcher(): Promise { + throw unsupported('app-switcher', 'Limrun iOS direct sessions do not expose app switcher yet.'); + } + + async tvRemote(): Promise { + throw unsupported('tv-remote', 'Limrun iOS direct sessions do not expose tv remote control.'); + } + + async readClipboard(): Promise { + throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard read yet.'); + } + + async writeClipboard(): Promise { + throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard write yet.'); + } + + async setSetting(): Promise { + throw unsupported('settings', 'Limrun iOS direct sessions do not expose settings changes yet.'); + } +} + +async function prepareLimrunIosAsset(artifactPath: string): Promise<{ + uploadPath: string; + assetName: string; + cleanup: () => Promise; +}> { + const stat = await fs.promises.stat(artifactPath); + if (!stat.isDirectory()) { + return { + uploadPath: artifactPath, + assetName: path.basename(artifactPath), + cleanup: async () => {}, + }; + } + + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-limrun-ios-app-')); + const zipPath = path.join(tempDir, `${path.basename(artifactPath)}.zip`); + const result = await runCmd('zip', ['-qr', zipPath, path.basename(artifactPath)], { + cwd: path.dirname(artifactPath), + timeoutMs: 120_000, + }); + if (result.exitCode !== 0) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { + command: ['zip', '-qr', zipPath, path.basename(artifactPath)].join(' '), + ...execFailureDetails(result), + }); + } + return { + uploadPath: zipPath, + assetName: path.basename(zipPath), + cleanup: async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + }, + }; +} + +function resolveIosTarget(app: string): string { + return app.trim().toLowerCase() === 'settings' ? 'com.apple.Preferences' : app; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + +function inferAppNameFromPath(appPath: string): string | undefined { + const base = path.basename(appPath).replace(/\.(?:app|ipa|apk|aab|zip)$/i, ''); + return base || undefined; +} + +function looksLikeUrl(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(value.trim()); +} + +function unsupported(command: string, message: string): never { + throw new AppError('UNSUPPORTED_OPERATION', message, { command }); +} diff --git a/src/providers/limrun/runtime.ts b/src/providers/limrun/runtime.ts index 61621bc1a..c79263cac 100644 --- a/src/providers/limrun/runtime.ts +++ b/src/providers/limrun/runtime.ts @@ -1,52 +1,38 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import Limrun from '@limrun/api'; -import { - createInstanceClient as createAndroidInstanceClient, - type InstanceClient as LimrunAndroidClient, -} from '@limrun/api/instance-client'; -import { - createInstanceClient as createIosInstanceClient, - type InstanceClient as LimrunIosClient, -} from '@limrun/api/ios-client'; -import { AppError } from '../../kernel/errors.ts'; -import { execFailureDetails, runCmd } from '../../utils/exec.ts'; -import { readVersion } from '../../utils/version.ts'; -import type { DeviceRotation } from '../../contracts/device-rotation.ts'; -import type { DeviceInfo } from '../../kernel/device.ts'; -import type { - Interactor, - RunnerContext, - SnapshotOptions, - SnapshotResult, -} from '../../core/interactor-types.ts'; -import { createAndroidInteractor } from '../../core/interactors/android.ts'; +import type { Interactor, RunnerContext } from '../../core/interactor-types.ts'; import type { DeviceInventoryProvider } from '../../core/dispatch-resolve.ts'; -import { - type AndroidAdbExecutorOptions, - type AndroidAdbExecutorResult, - type AndroidAdbProvider, - type AndroidPortReverseEndpoint, - type AndroidPortReverseProvider, -} from '../../platforms/android/adb-executor.ts'; import type { LeaseLifecycleProvider } from '../../daemon/handlers/lease.ts'; import type { DeviceLease } from '../../daemon/lease-registry.ts'; +import type { DeviceInfo } from '../../kernel/device.ts'; +import { AppError } from '../../kernel/errors.ts'; import type { ProviderDeviceInstallOptions, ProviderDeviceInstallResult, ProviderDeviceRuntime, + ProviderPortReverseOptions, } from '../../provider-device-runtime.ts'; +import { readVersion } from '../../utils/version.ts'; +import { + cleanupLimrunAndroidAdbTunnel, + configureLimrunAndroidPortReverse, + createLimrunAndroidInteractor, + createLimrunAndroidSession, + installLimrunAndroidApp, + removeLimrunAndroidPortReverse, + type LimrunAndroidSession, +} from './android.ts'; import { buildLimrunDevice, LIMRUN_PROVIDER, parseLimrunDeviceId, platformForLimrunLeaseBackend, - readLimrunLeaseIdFromInventoryRequest, } from './device.ts'; -import { flattenIosTree, toIosSelector, writeBase64File, type IosTreeNode } from './snapshot.ts'; - -type LimrunAdbTunnel = Awaited>; +import { + createLimrunIosInteractor, + createLimrunIosSession, + installLimrunIosApp, + type LimrunIosSession, +} from './ios.ts'; type LimrunInstance = { metadata: { id: string }; @@ -54,28 +40,10 @@ type LimrunInstance = { token: string; apiUrl?: string; adbWebSocketUrl?: string; - state?: string; }; }; -type LimrunRuntimeSession = - | { - platform: 'ios'; - lease: DeviceLease; - instanceId: string; - device: DeviceInfo; - client: LimrunIosClient; - } - | { - platform: 'android'; - lease: DeviceLease; - instanceId: string; - device: DeviceInfo; - client: LimrunAndroidClient; - adbTunnel?: LimrunAdbTunnel; - adbSerial?: string; - reversedPorts: Map; - }; +type LimrunRuntimeSession = LimrunIosSession | LimrunAndroidSession; type LimrunRuntimeOptions = { apiKey: string; @@ -107,10 +75,8 @@ export class LimrunRuntime implements ProviderDeviceRuntime { }; readonly deviceInventoryProvider: DeviceInventoryProvider = async (request) => { - if (request.leaseProvider !== this.provider) return null; - const leaseId = readLimrunLeaseIdFromInventoryRequest(request); - if (!leaseId) return null; - const session = this.sessions.get(leaseId); + if (request.leaseProvider !== this.provider || !request.leaseId) return null; + const session = this.sessions.get(request.leaseId); if (!session) return null; if (request.platform && request.platform !== session.platform) return []; return [session.device]; @@ -135,7 +101,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { const session = this.getSessionForDevice(device); if (!session) return undefined; return session.platform === 'ios' - ? new LimrunIosInteractor(session) + ? createLimrunIosInteractor(session) : createLimrunAndroidInteractor(session); } @@ -164,33 +130,27 @@ export class LimrunRuntime implements ProviderDeviceRuntime { : await installLimrunAndroidApp(this.limrun, session, installablePath, options); } - async configurePortReverse(options: { - leaseId: string; - devicePort: number; - hostPort: number; - name: string; - }): Promise | undefined> { - const session = this.getAndroidPortReverseSession(options.leaseId, { throwOnIos: true }); + async configurePortReverse( + options: ProviderPortReverseOptions, + ): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId, true); if (!session) return undefined; - await ensureAndroidPortReverse(session, options); + await configureLimrunAndroidPortReverse(session, options); return portReverseResult(options); } - async removePortReverse(options: { - leaseId: string; - devicePort: number; - hostPort: number; - name: string; - }): Promise | undefined> { - const session = this.getAndroidPortReverseSession(options.leaseId); + async removePortReverse( + options: ProviderPortReverseOptions, + ): Promise | undefined> { + const session = this.getAndroidPortReverseSession(options.leaseId, false); if (!session) return undefined; - await removeAndroidPortReverse(session, options.devicePort); + await removeLimrunAndroidPortReverse(session, options); return portReverseResult(options); } async shutdown(): Promise { - const sessions = Array.from(this.sessions.values()); - await Promise.allSettled(sessions.map((session) => this.terminateSession(session))); + const sessions = [...this.sessions.values()]; + await Promise.allSettled(sessions.map(async (session) => await this.terminateSession(session))); this.sessions.clear(); } @@ -199,9 +159,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { 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 }; - } + if (existing) return { limrunInstanceId: existing.instanceId, device: existing.device }; const session = platform === 'ios' @@ -211,7 +169,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { return { limrunInstanceId: session.instanceId, device: session.device }; } - private async createIosSession(lease: DeviceLease): Promise { + private async createIosSession(lease: DeviceLease): Promise { const instance = (await this.limrun.iosInstances.create({ wait: true, metadata: this.buildInstanceMetadata(lease), @@ -221,25 +179,20 @@ export class LimrunRuntime implements ProviderDeviceRuntime { if (!instance.status.apiUrl) { throw new AppError('COMMAND_FAILED', 'Limrun iOS instance did not expose apiUrl'); } - const client = await createIosInstanceClient({ - apiUrl: instance.status.apiUrl, - token: instance.status.token, - logLevel: 'warn', - }); - return { - platform: 'ios', + return await createLimrunIosSession({ lease, instanceId: instance.metadata.id, device: buildLimrunDevice('ios', lease, instance.metadata.id), - client, - }; + apiUrl: instance.status.apiUrl, + token: instance.status.token, + }); } catch (error) { await this.limrun.iosInstances.delete(instance.metadata.id).catch(() => {}); throw error; } } - private async createAndroidSession(lease: DeviceLease): Promise { + private async createAndroidSession(lease: DeviceLease): Promise { const instance = (await this.limrun.androidInstances.create({ wait: true, metadata: this.buildInstanceMetadata(lease), @@ -252,20 +205,14 @@ export class LimrunRuntime implements ProviderDeviceRuntime { 'Limrun Android instance did not expose API and ADB websocket endpoints', ); } - const client = await createAndroidInstanceClient({ + 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, - logLevel: 'warn', }); - return { - platform: 'android', - lease, - instanceId: instance.metadata.id, - device: buildLimrunDevice('android', lease, instance.metadata.id), - client, - reversedPorts: new Map(), - }; } catch (error) { await this.limrun.androidInstances.delete(instance.metadata.id).catch(() => {}); throw error; @@ -299,7 +246,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { await this.limrun.iosInstances.delete(session.instanceId); return; } - await cleanupAndroidAdbTunnel(session); + await cleanupLimrunAndroidAdbTunnel(session); await this.limrun.androidInstances.delete(session.instanceId); } @@ -307,18 +254,16 @@ export class LimrunRuntime implements ProviderDeviceRuntime { const parsed = parseLimrunDeviceId(device.id); if (!parsed) return undefined; const session = this.sessions.get(parsed.leaseId); - if (!session || session.platform !== parsed.platform) return undefined; - return session; + return session?.platform === parsed.platform ? session : undefined; } private getAndroidPortReverseSession( leaseId: string, - options: { throwOnIos?: boolean } = {}, - ): Extract | undefined { + throwOnIos: boolean, + ): LimrunAndroidSession | undefined { const session = this.sessions.get(leaseId); - if (!session) return undefined; - if (session.platform === 'android') return session; - if (options.throwOnIos) { + if (!session || session.platform === 'android') return session; + if (throwOnIos) { throw unsupported( 'port reverse', 'Direct Limrun iOS sessions cannot reach local host ports; use a bridge public URL.', @@ -328,12 +273,7 @@ export class LimrunRuntime implements ProviderDeviceRuntime { } } -function portReverseResult(options: { - leaseId: string; - devicePort: number; - hostPort: number; - name: string; -}): Record { +function portReverseResult(options: ProviderPortReverseOptions): Record { return { leaseId: options.leaseId, devicePort: options.devicePort, @@ -342,434 +282,6 @@ function portReverseResult(options: { }; } -async function installLimrunIosApp( - limrun: Limrun, - session: Extract, - installablePath: string, - options?: ProviderDeviceInstallOptions, -): Promise { - const prepared = await prepareLimrunIosAsset(installablePath); - try { - const asset = await limrun.assets.getOrUpload({ - path: prepared.uploadPath, - name: prepared.assetName, - }); - const result = await session.client.installApp(asset.signedDownloadUrl, { - md5: asset.md5, - launchMode: options?.relaunch ? 'RelaunchIfRunning' : 'ForegroundIfRunning', - }); - const bundleId = normalizeOptionalString(result.bundleId) ?? options?.appIdentifierHint; - return { - ...(bundleId ? { bundleId, launchTarget: bundleId } : {}), - appName: inferAppNameFromPath(installablePath), - }; - } finally { - await prepared.cleanup(); - } -} - -async function installLimrunAndroidApp( - limrun: Limrun, - session: Extract, - installablePath: string, - options?: ProviderDeviceInstallOptions, -): Promise { - const packageName = normalizeOptionalString(options?.packageNameHint); - if (options?.relaunch && packageName) { - await runLimrunAndroidAdb(session, ['shell', 'am', 'force-stop', packageName], { - allowFailure: true, - }); - } - const asset = await limrun.assets.getOrUpload({ - path: installablePath, - name: buildAndroidAssetName(packageName, installablePath), - }); - await session.client.sendAsset(asset.signedDownloadUrl); - return { - ...(packageName ? { packageName, launchTarget: packageName } : {}), - ...(packageName ? { appName: inferAndroidAppName(packageName) } : {}), - }; -} - -async function prepareLimrunIosAsset(artifactPath: string): Promise<{ - uploadPath: string; - assetName: string; - cleanup: () => Promise; -}> { - const stat = await fs.promises.stat(artifactPath); - if (!stat.isDirectory()) { - return { - uploadPath: artifactPath, - assetName: path.basename(artifactPath), - cleanup: async () => {}, - }; - } - - const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-limrun-ios-app-')); - const zipPath = path.join(tempDir, `${path.basename(artifactPath)}.zip`); - const result = await runCmd('zip', ['-qr', zipPath, path.basename(artifactPath)], { - cwd: path.dirname(artifactPath), - timeoutMs: 120_000, - }); - if (result.exitCode !== 0) { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - throw new AppError('COMMAND_FAILED', 'Failed to package iOS .app for Limrun install', { - command: ['zip', '-qr', zipPath, path.basename(artifactPath)].join(' '), - ...execFailureDetails(result), - }); - } - return { - uploadPath: zipPath, - assetName: path.basename(zipPath), - cleanup: async () => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - }, - }; -} - -async function runLimrunAndroidAdb( - session: Extract, - args: string[], - options?: AndroidAdbExecutorOptions, -): Promise { - const serial = await ensurePersistentAndroidAdbSerial(session); - const adbArgs = limrunAdbArgs(serial, args); - const result = await runCmd('adb', adbArgs, limrunAdbRunOptions(options)); - if (result.exitCode !== 0 && options?.allowFailure !== true) - throw limrunAndroidAdbError(adbArgs, result); - return result; -} - -function limrunAdbArgs(serial: string, args: string[]): string[] { - return ['-s', serial, ...args]; -} - -function limrunAdbRunOptions(options: AndroidAdbExecutorOptions | undefined) { - return { - allowFailure: options?.allowFailure, - binaryStdout: options?.binaryStdout, - stdin: options?.stdin, - timeoutMs: options?.timeoutMs ?? 30_000, - signal: options?.signal, - }; -} - -function limrunAndroidAdbError(adbArgs: string[], result: AndroidAdbExecutorResult): AppError { - return new AppError('COMMAND_FAILED', 'Limrun Android ADB command failed', { - command: ['adb', ...adbArgs].join(' '), - ...execFailureDetails(result), - }); -} - -function createLimrunAndroidInteractor( - session: Extract, -): Interactor { - return createAndroidInteractor(session.device, createLimrunAndroidAdbProvider(session)); -} - -function createLimrunAndroidAdbProvider( - session: Extract, -): AndroidAdbProvider { - return { - exec: async (args, options) => await runLimrunAndroidAdb(session, args, options), - reverse: createLimrunAndroidPortReverseProvider(session), - text: async (request) => { - await session.client.setText(request.target, request.text); - }, - }; -} - -function createLimrunAndroidPortReverseProvider( - session: Extract, -): AndroidPortReverseProvider { - return { - async ensure(mapping) { - await ensureAndroidPortReverse(session, { - devicePort: tcpEndpointPort(mapping.local), - hostPort: tcpEndpointPort(mapping.remote), - name: mapping.ownerId ?? 'android-adb-provider', - }); - }, - async remove(local) { - await removeAndroidPortReverse(session, tcpEndpointPort(local)); - }, - async removeAllOwned(ownerId) { - const ownedPorts = Array.from(session.reversedPorts.entries()) - .filter(([, name]) => name === ownerId) - .map(([port]) => port); - for (const port of ownedPorts) { - await removeAndroidPortReverse(session, port); - } - }, - }; -} - -function tcpEndpointPort(endpoint: AndroidPortReverseEndpoint): number { - if (!endpoint.startsWith('tcp:')) { - throw unsupported('port reverse', `Limrun Android only supports tcp reverse endpoints.`); - } - const port = Number(endpoint.slice('tcp:'.length)); - if (!Number.isInteger(port) || port <= 0) { - throw new AppError('INVALID_ARGS', `Invalid Android tcp reverse endpoint: ${endpoint}`); - } - return port; -} - -class LimrunIosInteractor implements Interactor { - private readonly session: Extract; - - constructor(session: Extract) { - this.session = session; - } - - async open(app: string, options?: { url?: string }): Promise { - if (options?.url) { - await this.session.client.launchApp(app); - await this.session.client.openUrl(options.url); - return; - } - if (looksLikeUrl(app)) { - await this.session.client.openUrl(app); - return; - } - await this.session.client.launchApp(resolveIosTarget(app)); - } - - async openDevice(): Promise {} - - async close(app: string): Promise { - if (app) await this.session.client.terminateApp(resolveIosTarget(app)).catch(() => {}); - } - - async tap(x: number, y: number): Promise { - await this.session.client.tap(x, y); - } - - async tapElementSelector(selector: { - key: 'id' | 'label' | 'text' | 'value'; - value: string; - }): Promise | void> { - await this.session.client.tapElement(toIosSelector(selector)); - } - - async doubleTap(x: number, y: number): Promise { - await this.tap(x, y); - await this.tap(x, y); - } - - async longPress(): Promise { - throw unsupported('longpress', 'Limrun iOS direct sessions do not expose long press yet.'); - } - - async focus(x: number, y: number): Promise { - await this.tap(x, y); - } - - async type(text: string, delayMs?: number): Promise { - if (delayMs && delayMs > 0) { - for (const char of Array.from(text)) { - await this.session.client.typeText(char); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - return; - } - await this.session.client.typeText(text); - } - - async fill(x: number, y: number, text: string): Promise { - await this.tap(x, y); - await this.session.client.typeText(text); - } - - async fillElementSelector( - selector: { key: 'id' | 'label' | 'text' | 'value'; value: string }, - text: string, - ): Promise { - await this.session.client.setElementValue(text, toIosSelector(selector)); - } - - async scroll(direction: 'up' | 'down' | 'left' | 'right', options?: { pixels?: number }) { - await this.session.client.scroll(direction, options?.pixels ?? 300); - } - - async screenshot(outPath: string): Promise { - const screenshot = await this.session.client.screenshot(); - await writeBase64File(outPath, screenshot.base64); - } - - async snapshot(_options?: SnapshotOptions): Promise { - const treeJson = await this.session.client.elementTree(); - const parsed = JSON.parse(treeJson) as IosTreeNode | IosTreeNode[]; - return { nodes: flattenIosTree(parsed), backend: 'xctest' }; - } - - async back(): Promise { - await this.session.client.pressKey('escape'); - } - - async home(): Promise { - throw unsupported('home', 'Limrun iOS direct sessions do not expose home yet.'); - } - - async setOrientation(orientation: DeviceRotation): Promise { - if (orientation === 'portrait-upside-down') { - throw unsupported( - 'orientation', - 'Limrun iOS direct sessions support portrait and landscape orientation, not portrait upside-down.', - ); - } - await this.session.client.setOrientation(orientation === 'portrait' ? 'Portrait' : 'Landscape'); - } - - async performGesture(): Promise { - throw unsupported( - 'gesture', - 'Limrun iOS direct sessions do not expose portable gesture execution yet.', - ); - } - - async appSwitcher(): Promise { - throw unsupported('app-switcher', 'Limrun iOS direct sessions do not expose app switcher yet.'); - } - - async tvRemote(): Promise { - throw unsupported('tv-remote', 'Limrun iOS direct sessions do not expose tv remote control.'); - } - - async readClipboard(): Promise { - throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard read yet.'); - } - - async writeClipboard(): Promise { - throw unsupported('clipboard', 'Limrun iOS direct sessions do not expose clipboard write yet.'); - } - - async setSetting(): Promise { - throw unsupported('settings', 'Limrun iOS direct sessions do not expose settings changes yet.'); - } -} - -async function ensureAndroidPortReverse( - session: Extract, - options: { - devicePort: number; - hostPort: number; - name: string; - }, -): Promise { - const existing = session.reversedPorts.get(options.devicePort); - if (existing === options.name) return; - if (existing && existing !== options.name) { - throw new AppError('INVALID_ARGS', `Limrun Android port reverse already exists`, { - devicePort: options.devicePort, - existingName: existing, - requestedName: options.name, - }); - } - const serial = await ensurePersistentAndroidAdbSerial(session); - const result = await runCmd( - 'adb', - ['-s', serial, 'reverse', `tcp:${options.devicePort}`, `tcp:${options.hostPort}`], - { - timeoutMs: 30_000, - }, - ); - if (result.exitCode !== 0) { - throw new AppError('COMMAND_FAILED', 'Limrun Android ADB reverse failed', { - command: [ - 'adb', - '-s', - serial, - 'reverse', - `tcp:${options.devicePort}`, - `tcp:${options.hostPort}`, - ].join(' '), - ...execFailureDetails(result), - }); - } - session.reversedPorts.set(options.devicePort, options.name); -} - -async function removeAndroidPortReverse( - session: Extract, - devicePort: number, -): Promise { - if (!session.reversedPorts.has(devicePort)) return; - const serial = await ensurePersistentAndroidAdbSerial(session); - await runCmd('adb', ['-s', serial, 'reverse', '--remove', `tcp:${devicePort}`], { - allowFailure: true, - timeoutMs: 10_000, - }).catch(() => {}); - session.reversedPorts.delete(devicePort); -} - -async function ensurePersistentAndroidAdbSerial( - session: Extract, -): Promise { - if (session.adbTunnel && session.adbSerial) return session.adbSerial; - const tunnel = await session.client.startAdbTunnel(); - const serial = `${tunnel.address.address}:${tunnel.address.port}`; - session.adbTunnel = tunnel; - session.adbSerial = serial; - return serial; -} - -async function cleanupAndroidAdbTunnel( - session: Extract, -): Promise { - const serial = session.adbSerial; - if (serial) { - await Promise.allSettled( - Array.from(session.reversedPorts.keys()).map((port) => - runCmd('adb', ['-s', serial, 'reverse', '--remove', `tcp:${port}`], { - allowFailure: true, - timeoutMs: 10_000, - }), - ), - ); - await runCmd('adb', ['disconnect', serial], { - allowFailure: true, - timeoutMs: 10_000, - }).catch(() => {}); - } - session.reversedPorts.clear(); - session.adbTunnel?.close(); - session.adbTunnel = undefined; - session.adbSerial = undefined; -} - -function resolveIosTarget(app: string): string { - const normalized = app.trim().toLowerCase(); - if (normalized === 'settings') return 'com.apple.Preferences'; - return app; -} - -function normalizeOptionalString(value: string | undefined): string | undefined { - const normalized = value?.trim(); - return normalized ? normalized : undefined; -} - -function inferAppNameFromPath(appPath: string): string | undefined { - const base = path.basename(appPath).replace(/\.(?:app|ipa|apk|aab|zip)$/i, ''); - return base || undefined; -} - -function inferAndroidAppName(packageName: string): string | undefined { - const segments = packageName.split('.').filter(Boolean); - const last = segments.at(-1); - return last ? last.replace(/[_-]+/g, ' ') : undefined; -} - -function buildAndroidAssetName(packageName: string | undefined, artifactPath: string): string { - const extension = path.extname(artifactPath) || '.apk'; - const prefix = packageName?.replace(/[^a-zA-Z0-9_.-]+/g, '-') || 'android-app'; - return `${prefix}${extension}`; -} - -function looksLikeUrl(value: string): boolean { - return /^[a-z][a-z0-9+.-]*:/i.test(value.trim()); -} - function unsupported(command: string, message: string): never { throw new AppError('UNSUPPORTED_OPERATION', message, { command }); } From 40b1571261d38f5729adf8e54c6e2381be81a489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 08:47:47 +0200 Subject: [PATCH 11/13] fix: isolate provider-owned iOS sessions --- src/core/dispatch-resolve.ts | 8 +- src/daemon/__tests__/lease-lifecycle.test.ts | 37 +++ src/daemon/apple-runner-options.ts | 8 +- src/daemon/direct-ios-selector.ts | 5 + .../__tests__/session-close-shutdown.test.ts | 61 +++-- src/daemon/handlers/lease.ts | 8 +- src/daemon/handlers/session-close.ts | 52 +++-- src/daemon/handlers/session-open.ts | 1 + src/daemon/lease-lifecycle.ts | 27 ++- src/daemon/lease-registry.ts | 23 +- src/daemon/selector-runtime-backend.ts | 2 + .../provider-ios-selector-runtime.test.ts | 218 ++++++++++++++++++ 12 files changed, 382 insertions(+), 68 deletions(-) create mode 100644 test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 1302e54a6..e4097c161 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -63,11 +63,14 @@ type ResolveTargetDeviceOptions = { async function resolveAppleDevice( devices: DeviceInfo[], selector: AppleDeviceSelector, - context: { simulatorSetPath?: string }, + context: { simulatorSetPath?: string; allowLocalSimulatorFallback?: boolean }, ): Promise { const selected = await resolveAppleDeviceCandidate(devices, selector, context); - if (shouldUseAppleSimulatorFallback(selector, selected)) { + if ( + context.allowLocalSimulatorFallback !== false && + shouldUseAppleSimulatorFallback(selector, selected) + ) { const { findBootableIosSimulator } = await import('../platforms/apple/core/devices.ts'); const simulator = await findBootableIosSimulator({ simulatorSetPath: context.simulatorSetPath, @@ -151,6 +154,7 @@ export async function resolveTargetDevice( cacheKey, await resolveAppleDevice(injectedDevices, selector as AppleDeviceSelector, { simulatorSetPath: iosSimulatorSetPath, + allowLocalSimulatorFallback: false, }), ); } diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index ede428747..1e7eb09e3 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -119,6 +119,43 @@ test('releaseSessionLease releases with the stored session owner scope', async ( expect(provider).toEqual({ provider: 'proxy' }); }); +test('releaseSessionLease keeps the local lease when the provider release fails', async () => { + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseBackend: 'ios-instance', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + clientId: 'client-a', + }); + const session = makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }, + }); + + await expect( + releaseSessionLease({ + session, + leaseRegistry, + leaseLifecycleProvider: { + release: async () => { + throw new Error('provider unavailable'); + }, + }, + }), + ).rejects.toThrow('provider unavailable'); + + expect(leaseRegistry.listActiveLeases()).toEqual([lease]); +}); + test('resolveSessionLeaseForRequest prefers admitted lease and falls back to existing lease', () => { const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ diff --git a/src/daemon/apple-runner-options.ts b/src/daemon/apple-runner-options.ts index 95b5c3269..005be947a 100644 --- a/src/daemon/apple-runner-options.ts +++ b/src/daemon/apple-runner-options.ts @@ -3,6 +3,7 @@ import type { SessionSurface } from '../contracts/session-surface.ts'; import type { AppleRunnerLifecycleOptions } from '../platforms/apple/core/runner/runner-provider.ts'; import { prewarmAppleRunnerCache } from '../platforms/apple/core/runner/runner-client.ts'; import { isIosFamily, type DeviceInfo } from '../kernel/device.ts'; +import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import { contextFromFlags } from './context.ts'; import type { DaemonRequest } from './types.ts'; @@ -63,7 +64,12 @@ export function createAppleRunnerCachePrewarmOnColdBoot(params: { enabled: boolean; }): ((device: DeviceInfo) => void) | undefined { const { req, logPath, device, traceLogPath, enabled } = params; - if (!enabled || !isIosFamily(device) || device.kind !== 'simulator') { + if ( + !enabled || + !isIosFamily(device) || + isActiveProviderDevice(device) || + device.kind !== 'simulator' + ) { return undefined; } return (bootingDevice) => diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index 3054446dd..45225eae5 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -1,4 +1,5 @@ import { isIosFamily } from '../kernel/device.ts'; +import { isActiveProviderDevice } from '../provider-device-runtime.ts'; import type { SessionState } from './types.ts'; import { tryParseSelectorChain } from '../selectors/index.ts'; import { asAppError } from '../kernel/errors.ts'; @@ -13,6 +14,10 @@ export function readSimpleIosSelectorTarget(params: { const { session, selectorExpression } = params; if (!session) return null; if (!isIosFamily(session.device)) return null; + // This fast path talks directly to the local XCTest runner. Provider-owned + // iOS devices must resolve through their interactor-backed snapshot runtime + // instead, which keeps selectors and interaction guarantees on one backend. + if (isActiveProviderDevice(session.device)) return null; if (session.postGestureStabilization) return null; const chain = tryParseSelectorChain(selectorExpression); if (!chain) return null; diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index 8d74bb4e4..a0102f900 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -481,7 +481,7 @@ test('close dispatches web session cleanup without a positional target', async ( expect(sessionStore.get(sessionName)).toBeUndefined(); }); -test('close deletes local session when provider lease release fails', async () => { +test('close preserves the session and lease when provider release fails so it can be retried', async () => { const sessionStore = makeSessionStore(); const leaseRegistry = new LeaseRegistry(); const sessionName = 'provider-release-failure-session'; @@ -506,28 +506,49 @@ test('close deletes local session when provider lease release fails', async () = }, }); - await expect( - handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'close', - positionals: [], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - leaseRegistry, - leaseLifecycleProvider: { - release: async () => { + let releaseAttempts = 0; + const request = { + req: { + token: 't', + session: sessionName, + command: 'close', + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + leaseRegistry, + leaseLifecycleProvider: { + release: async () => { + releaseAttempts += 1; + if (releaseAttempts === 1) { throw new AppError('COMMAND_FAILED', 'provider cleanup failed'); - }, + } + return { releasedBy: 'provider' }; }, - invoke: noopInvoke, - }), - ).rejects.toThrow('provider cleanup failed'); + }, + invoke: noopInvoke, + }; + const failed = await handleSessionCommands(request); + expect(failed).toMatchObject({ + ok: false, + error: { + code: 'COMMAND_FAILED', + retriable: true, + details: { session: sessionName }, + }, + }); + expect(sessionStore.get(sessionName)?.lease?.leaseId).toBe(lease.leaseId); + expect(leaseRegistry.listActiveLeases()).toHaveLength(1); + + const retried = await handleSessionCommands(request); + expect(retried).toMatchObject({ + ok: true, + data: { provider: { releasedBy: 'provider' } }, + }); + expect(releaseAttempts).toBe(2); expect(sessionStore.get(sessionName)).toBeUndefined(); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index 70560ff75..974b2b42b 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -98,10 +98,12 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise | undefined; // Resource teardown is failure-isolated: a rejected step is collected instead of // short-circuiting the rest, so every subsequent resource (and the runner stop) - // is still attempted. Lease release and session deletion below run regardless, - // and any collected failures are surfaced as an aggregate after cleanup. + // is still attempted. The provider lease is committed only after that teardown, + // and a failed provider release keeps the session retryable. const cleanupFailures: SessionCleanupFailure[] = []; - let platformCloseError: unknown; + const platformCloseError = await runSessionCloseTeardown({ + req, + session, + sessionName, + logPath, + sessionStore, + cleanupFailures, + repairArmed, + // The platform close for a repair-armed session already ran (and was + // confirmed to succeed) above, before the commit — never dispatch it twice. + skipPlatformClose: repairArmed, + }); try { - platformCloseError = await runSessionCloseTeardown({ - req, - session, - sessionName, - logPath, - sessionStore, - cleanupFailures, - repairArmed, - // The platform close for a repair-armed session already ran (and was - // confirmed to succeed) above, before the commit — never dispatch it twice. - skipPlatformClose: repairArmed, - }); - } finally { - // Always drop the local session, even if provider-side release fails: - // a failed close must not strand device ownership until inactivity expiry. - try { - providerData = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider }); - } finally { - sessionStore.delete(sessionName); - } + providerData = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider }); + } catch (error) { + const normalized = normalizeError(error); + return { + ok: false, + error: { + ...normalized, + hint: 'The provider device could not be released. Retry close after the provider is reachable.', + details: { ...normalized.details, session: session.name }, + retriable: true, + }, + }; } + sessionStore.delete(sessionName); const cleanupAggregate = reportSessionCleanupFailures({ sessionName, phase: 'session_close_cleanup_failed', diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 92279bc22..7ec1cd5d8 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -220,6 +220,7 @@ async function completeOpenCommand(params: { const shouldPrewarmIosRunner = isIosFamily(device) && + !isActiveProviderDevice(device) && surface === 'app' && openPositionals.length > 0 && Boolean(sessionAppBundleId); diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index d61a511e4..edb398f8c 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -101,17 +101,20 @@ export async function releaseSessionLease(params: { }): Promise | undefined> { const lease = params.session.lease; if (!lease) return undefined; - const result = params.leaseRegistry.releaseLease( - leaseScopeToReleaseRequest({ - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - leaseBackend: lease.leaseBackend, - leaseProvider: lease.leaseProvider, - deviceKey: lease.deviceKey, - clientId: lease.clientId, - }), - ); + const releaseRequest = leaseScopeToReleaseRequest({ + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.leaseBackend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + }); + const activeLease = params.leaseRegistry.getLease(releaseRequest); + const providerData = activeLease + ? await params.leaseLifecycleProvider?.release?.(activeLease) + : undefined; + const result = params.leaseRegistry.releaseLease(releaseRequest); emitDiagnostic({ level: 'info', phase: 'session_lease_released', @@ -121,5 +124,5 @@ export async function releaseSessionLease(params: { released: result.released, }, }); - return result.lease ? await params.leaseLifecycleProvider?.release?.(result.lease) : undefined; + return providerData; } diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index cda11fb7c..37283e597 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -297,17 +297,28 @@ export class LeaseRegistry { } releaseLease(request: ReleaseLeaseRequest): { released: boolean; lease?: DeviceLease } { - const leaseId = this.normalizeRequiredLeaseId(request.leaseId); - this.cleanupExpiredLeases(); - const lease = this.leases.get(leaseId); + const lease = this.getLease(request); if (!lease) { return { released: false }; } + this.leases.delete(lease.leaseId); + this.unbindLease(lease); + return { released: true, lease }; + } + + /** + * Reads a releasable lease without committing its removal. Callers with an + * external provider use this to release the remote resource first, so a + * transient provider failure leaves the local lease available for retry. + */ + getLease(request: ReleaseLeaseRequest): DeviceLease | undefined { + const leaseId = this.normalizeRequiredLeaseId(request.leaseId); + this.cleanupExpiredLeases(); + const lease = this.leases.get(leaseId); + if (!lease) return undefined; this.assertRequiredScopeForDeviceAwareLease(lease, request); this.assertOptionalScopeMatch(lease, request); - this.leases.delete(leaseId); - this.unbindLease(lease); - return { released: true, lease: { ...lease } }; + return { ...lease }; } assertLeaseAdmission(request: AdmissionRequest): void { diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 4a048391f..49f343ecb 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -21,6 +21,7 @@ import type { ContextFromFlags } from './handlers/interaction-common.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { createSelectorCaptureRuntime } from './selector-capture-runtime.ts'; +import { isActiveProviderDevice } from '../provider-device-runtime.ts'; export type SelectorRuntimeParams = { req: DaemonRequest; @@ -189,6 +190,7 @@ function readAppleRunnerFindTextTarget( params: SelectorRuntimeDeviceParams, ): AppleRunnerFindTextTarget | null { if (!isApplePlatform(params.device.platform)) return null; + if (isActiveProviderDevice(params.device)) return null; if (!params.session?.appBundleId) return null; return { device: params.device, diff --git a/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts b/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts new file mode 100644 index 000000000..b8085ac06 --- /dev/null +++ b/test/integration/provider-scenarios/provider-ios-selector-runtime.test.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + createProviderDeviceRuntimeRequestProviders, + type ProviderDeviceRuntime, +} from '../../../src/provider-device-runtime.ts'; +import type { DeviceInventoryProvider } from '../../../src/core/dispatch-resolve.ts'; +import type { Interactor, SnapshotResult } from '../../../src/core/interactor-types.ts'; +import type { LeaseLifecycleProvider } from '../../../src/daemon/handlers/lease.ts'; +import type { DeviceLease } from '../../../src/daemon/lease-registry.ts'; +import type { DaemonRequest } from '../../../src/daemon/types.ts'; +import type { DeviceInfo } from '../../../src/kernel/device.ts'; +import { assertRpcOk } from './assertions.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; + +const PROVIDER = 'fake-ios-provider'; +const DEVICE: DeviceInfo = { + platform: 'apple', + id: 'fake-ios-provider:device-1', + name: 'Fake Provider iPhone', + kind: 'device', + booted: true, +}; + +test('provider-owned iOS selectors use the shared interactor runtime instead of local XCTest', async () => { + await withProviderScenarioResource(createProviderIosSelectorWorld, async ({ daemon, calls }) => { + const lease = await allocateLease(daemon); + const request = { flags: leaseFlags(lease.leaseId), meta: leaseMeta(lease.leaseId) }; + + assertRpcOk(await daemon.callCommand('open', ['com.example.app'], request.flags, request)); + assert.match( + String( + assertRpcOk( + await daemon.callCommand( + 'get', + ['text', 'label="Provider Ready"'], + request.flags, + request, + ), + ).text, + ), + /^Provider Ready$/, + ); + assert.equal( + assertRpcOk( + await daemon.callCommand( + 'is', + ['exists', 'label="Provider Ready"'], + request.flags, + request, + ), + ).pass, + true, + ); + assertRpcOk( + await daemon.callCommand('wait', ['label="Provider Ready"'], request.flags, request), + ); + assertRpcOk(await daemon.callCommand('wait', ['Provider Ready'], request.flags, request)); + + const click = assertRpcOk( + await daemon.callCommand('click', ['label="Provider Ready"'], request.flags, request), + ); + assert.equal(click.x, 60); + assert.equal(click.y, 30); + const fill = assertRpcOk( + await daemon.callCommand('fill', ['label="Provider Input"', 'hello'], request.flags, request), + ); + assert.equal(fill.x, 60); + assert.equal(fill.y, 90); + + assert.deepEqual(calls.taps, [{ x: 60, y: 30 }]); + assert.deepEqual(calls.fills, [{ x: 60, y: 90, text: 'hello' }]); + assert.ok( + calls.snapshots >= 5, + `expected shared snapshot runtime calls, got ${calls.snapshots}`, + ); + }); +}); + +async function allocateLease( + daemon: Awaited>, +): Promise { + const response = await daemon.callCommand('lease_allocate', [], leaseFlags(), { + meta: leaseMeta(), + }); + return assertRpcOk<{ lease: DeviceLease }>(response).lease; +} + +async function createProviderIosSelectorWorld() { + const calls = { + snapshots: 0, + taps: [] as Array<{ x: number; y: number }>, + fills: [] as Array<{ x: number; y: number; text: string }>, + }; + const runtime = createProviderRuntime(calls); + const providers = createProviderDeviceRuntimeRequestProviders([runtime]); + const daemon = await createProviderScenarioHarness({ + ...providers, + deviceInventoryProvider: providers.deviceInventoryProvider!, + }); + return { + daemon, + calls, + close: async () => { + await runtime.shutdown(); + await daemon.close(); + }, + }; +} + +function createProviderRuntime(calls: { + snapshots: number; + taps: Array<{ x: number; y: number }>; + fills: Array<{ x: number; y: number; text: string }>; +}): ProviderDeviceRuntime { + const interactor = createProviderInteractor(calls); + const leaseLifecycle: LeaseLifecycleProvider = { + allocate: async (lease) => + lease.leaseProvider === PROVIDER ? { provider: PROVIDER, deviceId: DEVICE.id } : undefined, + }; + const deviceInventoryProvider: DeviceInventoryProvider = async (request) => + request.leaseProvider === PROVIDER && request.leaseId ? [DEVICE] : null; + return { + provider: PROVIDER, + leaseLifecycle, + deviceInventoryProvider, + ownsDevice: (device) => device.id === DEVICE.id, + getInteractor: (device) => (device.id === DEVICE.id ? interactor : undefined), + shutdown: async () => undefined, + }; +} + +function createProviderInteractor(calls: { + snapshots: number; + taps: Array<{ x: number; y: number }>; + fills: Array<{ x: number; y: number; text: string }>; +}): Interactor { + return new Proxy>( + { + open: async () => undefined, + tap: async (x, y) => { + calls.taps.push({ x, y }); + return { x, y }; + }, + fill: async (x, y, text) => { + calls.fills.push({ x, y, text }); + return { x, y, text }; + }, + snapshot: async (): Promise => { + calls.snapshots += 1; + return { + backend: 'xctest', + nodes: [ + { + index: 0, + type: 'Application', + label: 'Example', + rect: { x: 0, y: 0, width: 400, height: 800 }, + }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Provider Ready', + hittable: true, + rect: { x: 20, y: 10, width: 80, height: 40 }, + }, + { + index: 2, + parentIndex: 0, + type: 'TextField', + label: 'Provider Input', + hittable: true, + rect: { x: 20, y: 70, width: 80, height: 40 }, + }, + ], + }; + }, + }, + { + get(target, property, receiver) { + if ( + property === 'then' || + property === 'tapElementSelector' || + property === 'fillElementSelector' + ) { + return undefined; + } + if (property in target) return Reflect.get(target, property, receiver); + return () => { + throw new Error(`Unexpected provider iOS interactor call: ${String(property)}`); + }; + }, + }, + ) as Interactor; +} + +function leaseFlags(leaseId?: string): DaemonRequest['flags'] { + return { + platform: 'ios', + tenant: 'team-a', + runId: 'run-a', + leaseId, + leaseProvider: PROVIDER, + }; +} + +function leaseMeta(leaseId?: string): DaemonRequest['meta'] { + return { + tenantId: 'team-a', + runId: 'run-a', + leaseId, + leaseBackend: 'ios-instance', + leaseProvider: PROVIDER, + deviceKey: DEVICE.id, + clientId: 'client-a', + }; +} From 2b0a5865145df46b9d4969f14c6a741e853669f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 10:09:55 +0200 Subject: [PATCH 12/13] fix: preserve provider runtime boundaries --- src/core/dispatch-resolve.ts | 2 +- src/providers/limrun/android.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index e4097c161..42ba547fa 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -154,7 +154,7 @@ export async function resolveTargetDevice( cacheKey, await resolveAppleDevice(injectedDevices, selector as AppleDeviceSelector, { simulatorSetPath: iosSimulatorSetPath, - allowLocalSimulatorFallback: false, + allowLocalSimulatorFallback: inventoryRequest.leaseProvider === undefined, }), ); } diff --git a/src/providers/limrun/android.ts b/src/providers/limrun/android.ts index 9276a2b12..ed4fa4ff3 100644 --- a/src/providers/limrun/android.ts +++ b/src/providers/limrun/android.ts @@ -9,9 +9,7 @@ import type { Interactor } from '../../core/interactor-types.ts'; import type { DeviceLease } from '../../daemon/lease-registry.ts'; import { AppError } from '../../kernel/errors.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; -import { inferAndroidAppName } from '../../platforms/android/app-lifecycle.ts'; import { - createAndroidPortReverseManager, type AndroidAdbExecutorOptions, type AndroidAdbExecutorResult, type AndroidAdbProvider, @@ -68,6 +66,8 @@ export async function createLimrunAndroidSession(options: { await client.setText(request.target, request.text); }, }; + const { createAndroidPortReverseManager } = + await import('../../platforms/android/adb-executor.ts'); adbProvider.reverse = createAndroidPortReverseManager(adbProvider); return Object.assign(session, { adbProvider }); } @@ -93,9 +93,12 @@ export async function installLimrunAndroidApp( name: buildAndroidAssetName(packageName, installablePath), }); await session.client.sendAsset(asset.signedDownloadUrl); + const appName = packageName + ? (await import('../../platforms/android/app-lifecycle.ts')).inferAndroidAppName(packageName) + : undefined; return { ...(packageName ? { packageName, launchTarget: packageName } : {}), - ...(packageName ? { appName: inferAndroidAppName(packageName) } : {}), + ...(appName ? { appName } : {}), }; } From 4042d69d813589326978a5e10293aa64e2955a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 10:16:40 +0200 Subject: [PATCH 13/13] refactor: split close repair lifecycle --- src/daemon/handlers/session-close.ts | 127 ++++++++++++++++----------- 1 file changed, 77 insertions(+), 50 deletions(-) diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 750cc5b6e..24ea9edb4 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -266,6 +266,70 @@ function repairPlatformCloseIdentity(req: DaemonRequest): string { return JSON.stringify(req.positionals ?? []); } +type RepairClosePreparation = + | { repairArmed: boolean; healedScriptPath?: string } + | { response: DaemonResponse }; + +async function prepareRepairClose(params: { + req: DaemonRequest; + session: SessionState; + logPath: string; + sessionStore: SessionStore; +}): Promise { + const { req, session, logPath, sessionStore } = params; + const repairArmed = session.saveScriptBoundary !== undefined; + const closeIdentity = repairPlatformCloseIdentity(req); + if ( + repairArmed && + !(session.repairPlatformCloseSucceeded && session.repairPlatformCloseIdentity === closeIdentity) + ) { + const platformCloseError = await dispatchTargetedPlatformClose({ req, session, logPath }); + if (platformCloseError) { + return { + response: buildRepairCloseFailureResponse( + session, + toRepairPlatformCloseFailure(platformCloseError), + ), + }; + } + session.repairPlatformCloseSucceeded = true; + session.repairPlatformCloseIdentity = closeIdentity; + } + const repairCommit = commitRepairBeforeClose(sessionStore, session, req); + if (repairCommit.kind === 'failed') { + return { response: buildRepairCloseFailureResponse(session, repairCommit.error) }; + } + session.repairPlatformCloseSucceeded = false; + session.repairPlatformCloseIdentity = undefined; + return { + repairArmed, + ...(repairCommit.kind === 'committed' ? { healedScriptPath: repairCommit.path } : {}), + }; +} + +async function releaseProviderLeaseForClose(params: { + session: SessionState; + leaseRegistry: LeaseRegistry; + leaseLifecycleProvider: LeaseLifecycleProvider | undefined; +}): Promise<{ providerData?: Record; response?: DaemonResponse }> { + try { + return { providerData: await releaseSessionLease(params) }; + } catch (error) { + const normalized = normalizeError(error); + return { + response: { + ok: false, + error: { + ...normalized, + hint: 'The provider device could not be released. Retry close after the provider is reachable.', + details: { ...normalized.details, session: params.session.name }, + retriable: true, + }, + }, + }; + } +} + async function dispatchTargetedPlatformClose(params: { req: DaemonRequest; session: SessionState; @@ -353,8 +417,6 @@ export async function handleCloseCommand(params: { if (req.internal?.closeAppOnly === true) { return await closeAppWithoutEndingSession({ req, session, logPath }); } - const repairArmed = session.saveScriptBoundary !== undefined; - const closeIdentity = repairPlatformCloseIdentity(req); // ADR 0012 decision 6 (BLOCKER 2): for a repair-armed session, the platform // close must run and SUCCEED before anything is committed or torn down — // otherwise a committed healed `.ad` could claim a successful `close` that @@ -373,35 +435,8 @@ export async function handleCloseCommand(params: { // identity DIFFERS (third follow-up: e.g. untargeted -> targeted, or a // changed target) never matches — the marker only ever attests to the // identity it was recorded under, so the platform close runs (again). - if ( - repairArmed && - !(session.repairPlatformCloseSucceeded && session.repairPlatformCloseIdentity === closeIdentity) - ) { - const platformCloseError = await dispatchTargetedPlatformClose({ req, session, logPath }); - if (platformCloseError) { - return buildRepairCloseFailureResponse( - session, - toRepairPlatformCloseFailure(platformCloseError), - ); - } - session.repairPlatformCloseSucceeded = true; - session.repairPlatformCloseIdentity = closeIdentity; - } - // Commit the repair transaction BEFORE any destructive teardown. On failure, - // return without tearing the session down — it stays addressable so the - // agent can fix the cause and retry. - const repairCommit = commitRepairBeforeClose(sessionStore, session, req); - if (repairCommit.kind === 'failed') { - return buildRepairCloseFailureResponse(session, repairCommit.error); - } - // The transaction has now either committed for real or intentionally - // aborted (an incomplete transaction never reaches 'failed') — either way - // this close attempt's outcome is settled, so the platform-close-succeeded - // marker (BLOCKER 3) has served its purpose and must not linger. - session.repairPlatformCloseSucceeded = false; - session.repairPlatformCloseIdentity = undefined; - const healedScriptPath = repairCommit.kind === 'committed' ? repairCommit.path : undefined; - let providerData: Record | undefined; + const repair = await prepareRepairClose({ req, session, logPath, sessionStore }); + if ('response' in repair) return repair.response; // Resource teardown is failure-isolated: a rejected step is collected instead of // short-circuiting the rest, so every subsequent resource (and the runner stop) // is still attempted. The provider lease is committed only after that teardown, @@ -414,25 +449,17 @@ export async function handleCloseCommand(params: { logPath, sessionStore, cleanupFailures, - repairArmed, + repairArmed: repair.repairArmed, // The platform close for a repair-armed session already ran (and was // confirmed to succeed) above, before the commit — never dispatch it twice. - skipPlatformClose: repairArmed, + skipPlatformClose: repair.repairArmed, }); - try { - providerData = await releaseSessionLease({ session, leaseRegistry, leaseLifecycleProvider }); - } catch (error) { - const normalized = normalizeError(error); - return { - ok: false, - error: { - ...normalized, - hint: 'The provider device could not be released. Retry close after the provider is reachable.', - details: { ...normalized.details, session: session.name }, - retriable: true, - }, - }; - } + const leaseRelease = await releaseProviderLeaseForClose({ + session, + leaseRegistry, + leaseLifecycleProvider, + }); + if (leaseRelease.response) return leaseRelease.response; sessionStore.delete(sessionName); const cleanupAggregate = reportSessionCleanupFailures({ sessionName, @@ -451,7 +478,7 @@ export async function handleCloseCommand(params: { // ADR 0012 decision 6 (BLOCKER 2a): positively report the committed healed // artifact path so the agent learns the repair published (and where) without // an extra round-trip. - const savedScript = healedScriptPath ? { savedScript: healedScriptPath } : {}; + const savedScript = repair.healedScriptPath ? { savedScript: repair.healedScriptPath } : {}; if (shutdownResult) { return { ok: true, @@ -460,7 +487,7 @@ export async function handleCloseCommand(params: { session: session.name, shutdown: shutdownResult, ...savedScript, - ...(providerData ? { provider: providerData } : {}), + ...(leaseRelease.providerData ? { provider: leaseRelease.providerData } : {}), }, `Closed: ${session.name}`, ), @@ -472,7 +499,7 @@ export async function handleCloseCommand(params: { session: session.name, ...successText(`Closed: ${session.name}`), ...savedScript, - ...(providerData ? { provider: providerData } : {}), + ...(leaseRelease.providerData ? { provider: leaseRelease.providerData } : {}), }, }; }