diff --git a/package.json b/package.json index cfd17ac384..c39ec40173 100644 --- a/package.json +++ b/package.json @@ -317,7 +317,7 @@ "@agent-device/xml": "workspace:*", "@arethetypeswrong/cli": "^0.18.5", "@chenglou/freerange": "^0.0.4", - "@limrun/api": "^0.60.0", + "@limrun/api": "^0.64.1", "@nkzw/eslint-plugin": "^2.0.0", "@nkzw/oxlint-config": "^1.3.0", "@statelyai/graph": "2.4.0", diff --git a/packages/provider-limrun/package.json b/packages/provider-limrun/package.json index 151c0fd666..b83e398072 100644 --- a/packages/provider-limrun/package.json +++ b/packages/provider-limrun/package.json @@ -17,6 +17,6 @@ } }, "devDependencies": { - "@limrun/api": "^0.60.0" + "@limrun/api": "^0.64.1" } } diff --git a/packages/provider-limrun/src/client-options.test.ts b/packages/provider-limrun/src/client-options.test.ts new file mode 100644 index 0000000000..d1e7149ce9 --- /dev/null +++ b/packages/provider-limrun/src/client-options.test.ts @@ -0,0 +1,27 @@ +import Limrun from '@limrun/api'; +import { expect, test } from 'vitest'; +import { limrunUserAgent } from './client-options.ts'; + +test('the Limrun SDK sends the agent-device prefix ahead of its own User-Agent', async () => { + const userAgents: Array = []; + const limrun = new Limrun({ + apiKey: 'lim_test_key', + baseURL: 'https://limrun.example', + maxRetries: 0, + userAgent: limrunUserAgent('1.2.3'), + fetch: async (_url, init) => { + userAgents.push(new Headers(init?.headers).get('user-agent')); + return new Response(JSON.stringify({ metadata: { id: 'instance-1' }, status: {} }), { + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + await limrun.iosInstances.create({}); + await limrun.androidInstances.delete('instance-1'); + + expect(userAgents).toHaveLength(2); + for (const userAgent of userAgents) { + expect(userAgent).toMatch(/^agent-device-cli\/1\.2\.3 Limrun\/JS /); + } +}); diff --git a/packages/provider-limrun/src/client-options.ts b/packages/provider-limrun/src/client-options.ts index 7d6a5cdfe0..b424486e1b 100644 --- a/packages/provider-limrun/src/client-options.ts +++ b/packages/provider-limrun/src/client-options.ts @@ -3,6 +3,11 @@ import { AppError } from '@agent-device/kernel/errors'; export const LIMRUN_CLIENT_ID = 'agent-device-cli'; +/** Prefixes the SDK User-Agent so Limrun attributes API-key traffic, including instance creation, to agent-device. */ +export function limrunUserAgent(clientVersion: string): string { + return `${LIMRUN_CLIENT_ID}/${clientVersion}`; +} + /** The organization client, or a typed refusal for an operation that needs the API key. */ export function requireLimrunOrgClient( limrun: Limrun | undefined, diff --git a/packages/provider-limrun/src/connection-verification.test.ts b/packages/provider-limrun/src/connection-verification.test.ts index 4693fa30b7..c8bce6b516 100644 --- a/packages/provider-limrun/src/connection-verification.test.ts +++ b/packages/provider-limrun/src/connection-verification.test.ts @@ -42,6 +42,7 @@ afterEach(() => { test('Limrun verification reads the selected instance service without creating an instance', async () => { const result = await verifyLimrunConnection({ apiKey: 'lim_test_key', + clientVersion: '1.2.3', platform: 'android', }); @@ -63,6 +64,7 @@ test('Limrun verification reads the selected instance service without creating a assert.equal(mockState.iosList.mock.calls.length, 0); assert.equal(mockState.constructorOptions[0]?.apiKey, 'lim_test_key'); assert.equal(mockState.constructorOptions[0]?.defaultHeaders, undefined); + assert.equal(mockState.constructorOptions[0]?.userAgent, 'agent-device-cli/1.2.3'); }); test('Limrun verification classifies authentication failures', async () => { @@ -71,6 +73,7 @@ test('Limrun verification classifies authentication failures', async () => { await assert.rejects( verifyLimrunConnection({ apiKey: 'lim_bad_key', + clientVersion: '1.2.3', platform: 'ios', }), (error: unknown) => { @@ -85,6 +88,7 @@ test('Limrun verification connects to an attached instance instead of the contro const android = { apiUrl: 'https://attached.example/api', token: 'tok', adbUrl: 'wss://adb' }; const result = await verifyLimrunConnection({ apiKey: 'lim_test_key', + clientVersion: '1.2.3', instances: { android }, platform: 'android', }); @@ -117,11 +121,14 @@ test('Limrun verification names the variables to check when an instance rejects ] as const; for (const { platform, instances, create, variables } of cases) { create.mockRejectedValueOnce(new Error('Unexpected server response: 401')); - await assert.rejects(verifyLimrunConnection({ instances, platform }), (error: unknown) => { - assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); - assert.match(JSON.stringify(error), variables); - assert.doesNotMatch(JSON.stringify(error), /lim_st_secret/); - return true; - }); + await assert.rejects( + verifyLimrunConnection({ clientVersion: '1.2.3', instances, platform }), + (error: unknown) => { + assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); + assert.match(JSON.stringify(error), variables); + assert.doesNotMatch(JSON.stringify(error), /lim_st_secret/); + return true; + }, + ); } }); diff --git a/packages/provider-limrun/src/connection-verification.ts b/packages/provider-limrun/src/connection-verification.ts index ee27f98478..905d322cf6 100644 --- a/packages/provider-limrun/src/connection-verification.ts +++ b/packages/provider-limrun/src/connection-verification.ts @@ -2,6 +2,7 @@ import Limrun from '@limrun/api'; import { AppError } from '@agent-device/kernel/errors'; import type { ProviderConnectionVerification } from '@agent-device/contracts/remote'; import type { LimrunInstanceAccess } from './instance-access.ts'; +import { limrunUserAgent } from './client-options.ts'; export type LimrunConnectionVerification = ProviderConnectionVerification & { provider: 'limrun'; @@ -19,6 +20,7 @@ export type LimrunConnectionVerification = ProviderConnectionVerification & { export type LimrunConnectionVerificationOptions = { apiKey?: string; + clientVersion: string; instances?: LimrunInstanceAccess; platform: 'android' | 'ios'; region?: string; @@ -38,6 +40,7 @@ export async function verifyLimrunConnection( } const client = new Limrun({ apiKey: options.apiKey, + userAgent: limrunUserAgent(options.clientVersion), timeout: 15_000, maxRetries: 0, }); diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index 6dc3d7bc50..52ad6b6536 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -46,7 +46,7 @@ import type { import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import type { LimrunAppLogDescriptor } from './app-log-descriptor.ts'; import type { LimrunAppLogReader } from './app-log-runtime.ts'; -import { LIMRUN_CLIENT_ID, requireLimrunOrgClient } from './client-options.ts'; +import { LIMRUN_CLIENT_ID, limrunUserAgent, requireLimrunOrgClient } from './client-options.ts'; import { resolveLimrunRuntimeInstance } from './runtime-instance.ts'; import type { LimrunRequestOperationDrain } from './request-cancellation.ts'; import type { LimrunAppAsset } from './app-catalog.ts'; @@ -156,7 +156,12 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { } this.options = options; this.dependencies = dependencies; - this.limrun = options.apiKey ? new Limrun({ apiKey: options.apiKey }) : undefined; + this.limrun = options.apiKey + ? new Limrun({ + apiKey: options.apiKey, + userAgent: limrunUserAgent(dependencies.clientVersion), + }) + : undefined; } ownsDevice(device: DeviceInfo): boolean { diff --git a/packages/provider-limrun/src/session-allocation.test.ts b/packages/provider-limrun/src/session-allocation.test.ts deleted file mode 100644 index 3cf053184d..0000000000 --- a/packages/provider-limrun/src/session-allocation.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import Limrun from '@limrun/api'; -import { expect, test, vi } from 'vitest'; -import type { DeviceLease } from '@agent-device/contracts/device'; -import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; -import { allocateLimrunAndroidSession, allocateLimrunIosSession } from './session-allocation.ts'; - -vi.mock('./android.ts', () => ({ createLimrunAndroidSession: vi.fn() })); -vi.mock('./ios.ts', () => ({ createLimrunIosSession: vi.fn() })); - -test.for([ - { platform: 'ios', allocate: allocateLimrunIosSession, backend: 'ios-instance' }, - { platform: 'android', allocate: allocateLimrunAndroidSession, backend: 'android-instance' }, -] as const)( - '$platform creation sends partner attribution in User-Agent only on creation', - async ({ platform, allocate, backend }) => { - const requests: Array<{ method: string; headers: Headers; url: string }> = []; - let instanceReady = true; - const limrun = new Limrun({ - apiKey: 'lim_test_key', - baseURL: 'https://limrun.example', - maxRetries: 0, - fetch: async (url, init) => { - requests.push({ - method: init?.method ?? 'GET', - headers: new Headers(init?.headers), - url: String(url), - }); - return new Response( - JSON.stringify({ - metadata: { id: 'instance-1' }, - status: { - token: 'instance-token', - ...(instanceReady - ? { - apiUrl: 'https://instance.example', - adbWebSocketUrl: 'wss://adb.example', - } - : {}), - }, - }), - { headers: { 'content-type': 'application/json' } }, - ); - }, - }); - const lease: DeviceLease = { - leaseId: 'lease-attribution', - tenantId: 'team-a', - runId: 'run-a', - backend, - leaseProvider: 'limrun', - createdAt: 1, - heartbeatAt: 1, - expiresAt: 60_001, - }; - - const params = { - limrun, - lease, - metadata: { displayName: 'Attribution test', labels: {} }, - // Session construction is mocked; allocation only consumes the client version. - dependencies: { clientVersion: '1.2.3' } as LimrunRuntimeDependencies, - }; - await allocate(params); - await limrun[platform === 'ios' ? 'iosInstances' : 'androidInstances'].delete('instance-1'); - - instanceReady = false; - await expect(allocate(params)).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); - - expect(requests.map(({ method }) => method)).toEqual(['POST', 'DELETE', 'POST', 'DELETE']); - for (const request of [requests[0]!, requests[2]!]) { - expect(new URL(request.url).pathname).toBe(`/v1/${platform}_instances`); - expect(request.headers.get('user-agent')).toBe('agent-device-cli/1.2.3'); - } - for (const request of [requests[1]!, requests[3]!]) { - expect(new URL(request.url).pathname).toBe(`/v1/${platform}_instances/instance-1`); - expect(request.headers.get('user-agent')).toMatch(/^Limrun\/JS /); - } - for (const request of requests) { - expect(request.headers.has('x-agent-device-client')).toBe(false); - expect(request.headers.has('x-agent-device-version')).toBe(false); - } - }, -); diff --git a/packages/provider-limrun/src/session-allocation.ts b/packages/provider-limrun/src/session-allocation.ts index 0a772e1acd..7c9fda7219 100644 --- a/packages/provider-limrun/src/session-allocation.ts +++ b/packages/provider-limrun/src/session-allocation.ts @@ -13,7 +13,6 @@ import { } from './app-catalog.ts'; import { createLimrunDeviceSession } from './device-session.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; -import { LIMRUN_CLIENT_ID } from './client-options.ts'; type LimrunInstance = { metadata: { id: string }; @@ -69,30 +68,25 @@ export async function resolvePreinstalledAppId( export async function allocateLimrunIosSession( params: SessionAllocationParams, ): Promise { - const instance = (await params.limrun.iosInstances.create( - { - wait: true, - metadata: params.metadata, - spec: { - ...(params.region ? { region: params.region } : {}), - ...(params.app - ? { - initialAssets: [ - { - kind: 'App' as const, - source: 'AssetID' as const, - assetId: params.app.id, - launchMode: 'RelaunchIfRunning' as const, - }, - ], - } - : {}), - }, - }, - { - headers: { 'User-Agent': `${LIMRUN_CLIENT_ID}/${params.dependencies.clientVersion}` }, + const instance = (await params.limrun.iosInstances.create({ + wait: true, + metadata: params.metadata, + spec: { + ...(params.region ? { region: params.region } : {}), + ...(params.app + ? { + initialAssets: [ + { + kind: 'App' as const, + source: 'AssetID' as const, + assetId: params.app.id, + launchMode: 'RelaunchIfRunning' as const, + }, + ], + } + : {}), }, - )) as LimrunInstance; + })) as LimrunInstance; try { if (!instance.status.apiUrl) { throw new AppError('COMMAND_FAILED', 'Limrun iOS instance did not expose apiUrl'); @@ -117,29 +111,24 @@ export async function allocateLimrunIosSession( export async function allocateLimrunAndroidSession( params: SessionAllocationParams, ): Promise { - const instance = (await params.limrun.androidInstances.create( - { - wait: true, - metadata: params.metadata, - spec: { - ...(params.region ? { region: params.region } : {}), - ...(params.app - ? { - initialAssets: [ - { - kind: 'App' as const, - source: 'AssetIDs' as const, - assetIds: [params.app.id], - }, - ], - } - : {}), - }, - }, - { - headers: { 'User-Agent': `${LIMRUN_CLIENT_ID}/${params.dependencies.clientVersion}` }, + const instance = (await params.limrun.androidInstances.create({ + wait: true, + metadata: params.metadata, + spec: { + ...(params.region ? { region: params.region } : {}), + ...(params.app + ? { + initialAssets: [ + { + kind: 'App' as const, + source: 'AssetIDs' as const, + assetIds: [params.app.id], + }, + ], + } + : {}), }, - )) as LimrunInstance; + })) as LimrunInstance; try { if (!instance.status.apiUrl || !instance.status.adbWebSocketUrl) { throw new AppError( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20993cbdd1..1e619bb7a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,8 +110,8 @@ importers: specifier: ^0.0.4 version: 0.0.4 '@limrun/api': - specifier: ^0.60.0 - version: 0.60.0(supports-color@7.2.0) + specifier: ^0.64.1 + version: 0.64.1(supports-color@7.2.0) '@nkzw/eslint-plugin': specifier: ^2.0.0 version: 2.0.0(eslint@10.9.1(supports-color@7.2.0)) @@ -576,8 +576,8 @@ importers: version: link:../platform-android devDependencies: '@limrun/api': - specifier: ^0.60.0 - version: 0.60.0(supports-color@7.2.0) + specifier: ^0.64.1 + version: 0.64.1(supports-color@7.2.0) packages/provider-testmu: devDependencies: @@ -1269,8 +1269,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@limrun/api@0.60.0': - resolution: {integrity: sha512-w7A0PiqNF5XHBU6xUzvAsi1BEnJeBhzYKckuVETp/Nkehv5QF28u2q90LgkjifdFEJkULus7IzeCqnqTnynJCA==} + '@limrun/api@0.64.1': + resolution: {integrity: sha512-nrKFfalo3I+9wFPEoIs7kqlUxgih14qvaQqBr1OsUM7j0rjfxje2XiIHb/401PIccPEvuXSFbSJ4uru+asDbwg==} '@limrun/xdelta3-wasm@0.2.0': resolution: {integrity: sha512-mEohO7CYj4Gdc0v1Gr/AjlBhfbXQnIz6+2wmMhRfwz8hXZrsIC/yqYQhfoDhu1ysqQwBnU5lDDLPUGdUu+8hPQ==} @@ -4887,7 +4887,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@limrun/api@0.60.0(supports-color@7.2.0)': + '@limrun/api@0.64.1(supports-color@7.2.0)': dependencies: '@limrun/xdelta3-wasm': 0.2.0 '@types/har-format': 1.2.16 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 27eff70a33..481443feeb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ allowBuilds: fallow: true minimumReleaseAgeExclude: - '@chenglou/freerange@0.0.4' + - '@limrun/api@0.64.1' overrides: brace-expansion@5: '^5.0.12' fast-uri: 3.1.7 diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index a125382405..77510b0b48 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; +import type { ClientOptions } from '@limrun/api'; import { createInstanceClient as createAndroidInstanceClient } from '@limrun/api/instance-client'; import { createInstanceClient as createIosInstanceClient } from '@limrun/api/ios-client'; import { LimrunRuntime } from '../sdk/limrun.ts'; @@ -20,7 +21,7 @@ type LimrunInstancePage = { const limrunMockState = vi.hoisted(() => { const androidTunnelClose = vi.fn(); return { - constructorOptions: [] as Array<{ defaultHeaders?: Record }>, + constructorOptions: [] as ClientOptions[], assetsGetOrUpload: vi.fn(async () => ({ signedDownloadUrl: 'https://assets.example/app', md5: 'asset-md5', @@ -86,7 +87,7 @@ vi.mock('@limrun/api', () => ({ getOrUpload: limrunMockState.assetsGetOrUpload, }; - constructor(options: { defaultHeaders?: Record }) { + constructor(options: ClientOptions) { limrunMockState.constructorOptions.push(options); } }, @@ -160,10 +161,13 @@ test('Limrun runtime identifies direct CLI usage when creating an instance', asy assert.equal(deviceSession ? 'client' in deviceSession : true, false); assert.equal(limrunMockState.constructorOptions[0]?.defaultHeaders, undefined); + assert.equal( + limrunMockState.constructorOptions[0]?.userAgent, + `agent-device-cli/${readVersion()}`, + ); const iosCreateCalls = limrunMockState.iosCreate.mock.calls as unknown as Array< - [{ metadata?: { labels?: Record } }, { headers: Record }] + [{ metadata?: { labels?: Record } }] >; - assert.equal(iosCreateCalls[0]?.[1].headers['User-Agent'], `agent-device-cli/${readVersion()}`); assert.deepEqual(iosCreateCalls[0]?.[0].metadata?.labels, { tenantId: 'team-a', runId: 'run-a', diff --git a/src/cli/connection/connect-provider-adapters.ts b/src/cli/connection/connect-provider-adapters.ts index 4d8b54cbb2..084c465665 100644 --- a/src/cli/connection/connect-provider-adapters.ts +++ b/src/cli/connection/connect-provider-adapters.ts @@ -2,6 +2,7 @@ import type { CliFlags } from '@agent-device/contracts/command'; import { requireResolvedProfilePlatform } from '@agent-device/contracts/provider-profile-fields'; import type { ProviderConnectionVerification } from '@agent-device/contracts/remote'; import { verifyLimrunConnection } from '@agent-device/provider-limrun'; +import { readVersion } from '@agent-device/host-kit/version'; import { leaseBackendForPlatform } from '@agent-device/kernel/contracts'; import { AppError } from '@agent-device/kernel/errors'; import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; @@ -206,6 +207,7 @@ async function verifyLimrun( ): Promise { return await verifyLimrunConnection({ ...readLimrunCredentials(context.env), + clientVersion: readVersion(), platform: requireResolvedProfilePlatform(context.flags.platform, 'Limrun'), }); } diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index b4cf15c391..6c67680459 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -18,7 +18,7 @@ agent-device connect limrun --platform android `--platform android` or `--platform ios` selects the instance type. `connect` checks the credentials for that platform without creating an instance. -When you create an instance through agent-device, the creation request sends `User-Agent: agent-device-cli/` so Limrun can attribute it to agent-device for revenue sharing. +Requests agent-device makes with your API key, including instance creation, start their `User-Agent` with `agent-device-cli/` so Limrun can attribute them to agent-device for revenue sharing. ## Drive an existing instance