diff --git a/src/cli/connection/limrun-profile.ts b/src/cli/connection/limrun-profile.ts index 56f786cb8f..ab8e60a8ef 100644 --- a/src/cli/connection/limrun-profile.ts +++ b/src/cli/connection/limrun-profile.ts @@ -10,6 +10,7 @@ import { persistAndResolveGeneratedProfile } from './generated-config.ts'; import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts'; import { limrunInstanceVariables, + limrunPlatformForLeaseBackend, readLimrunCredentials, } from '../../provider-limrun-credentials.ts'; @@ -23,7 +24,7 @@ export function resolveLimrunConnectProfile(options: { const env = options.env ?? process.env; const profile = buildLimrunRemoteProfile({ flags: options.flags }); const credentials = readLimrunCredentials(env); - const platform = profile.leaseBackend === 'ios-instance' ? 'ios' : 'android'; + const platform = limrunPlatformForLeaseBackend(profile.leaseBackend) ?? 'android'; if (!credentials?.apiKey && !credentials?.instances?.[platform]) { throw new AppError( 'INVALID_ARGS', diff --git a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts b/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts index 6e5e3a3d8f..52a769d2b4 100644 --- a/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts +++ b/src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts @@ -19,6 +19,11 @@ import { providerCredentialFingerprint } from '../../provider-credential-fingerp import { LIMRUN_CREDENTIAL_VARIABLES } from '../../provider-limrun-credentials.ts'; const API_KEY = 'lim-secret-key'; +const ANDROID_ATTACH = { + LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', + LIM_ANDROID_INSTANCE_TOKEN: 'android-token', + LIM_ANDROID_INSTANCE_ADB_URL: 'wss://region.limrun.example/v1/android_x/adb', +}; afterEach(() => { vi.unstubAllEnvs(); @@ -38,14 +43,20 @@ test.sequential.for(['socket', 'http'] as const)( vi.stubEnv('AGENT_DEVICE_DAEMON_AUTH_TOKEN', undefined); const stateDir = mkdtempForTestSync('agent-device-provider-credentials-daemon-'); const daemon = await startLocalDaemon(stateDir, transport); - const send = (command: string) => + const send = (command: string, leaseBackend?: 'ios-instance' | 'android-instance') => sendToDaemon({ session: 'default', command, positionals: [], flags: { stateDir, daemonTransport: transport }, - meta: { requestId: `req-${command}`, leaseProvider: 'limrun', tenantId: 'tenant-a' }, + meta: { + requestId: `req-${command}`, + leaseProvider: 'limrun', + tenantId: 'tenant-a', + ...(leaseBackend ? { leaseBackend } : {}), + }, }); + const attachedEnv = { LIMRUN_API_KEY: API_KEY, ...ANDROID_ATTACH }; try { vi.stubEnv('LIMRUN_API_KEY', API_KEY); @@ -53,16 +64,23 @@ test.sequential.for(['socket', 'http'] as const)( await send('devices'); vi.stubEnv('LIMRUN_API_KEY', undefined); await send('lease_allocate'); + for (const [name, value] of Object.entries(attachedEnv)) vi.stubEnv(name, value); + await send('lease_allocate', 'ios-instance'); + await send('lease_allocate', 'android-instance'); } finally { await daemon.close(); fs.rmSync(stateDir, { recursive: true, force: true }); } + const iosFingerprint = providerCredentialFingerprint('limrun', attachedEnv, 'ios-instance'); expect(daemon.bodies.map(readFingerprint)).toEqual([ providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: API_KEY }), undefined, undefined, + iosFingerprint, + providerCredentialFingerprint('limrun', attachedEnv, 'android-instance'), ]); + expect(iosFingerprint).not.toBe(providerCredentialFingerprint('limrun', attachedEnv)); for (const body of daemon.bodies) expect(body).not.toContain(API_KEY); }, ); diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index f5e010803f..5285b8f52c 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -142,10 +142,10 @@ async function readLocalProviderCredentialFingerprint( info: DaemonInfo, ): Promise { if (isRemoteDaemon(info) || request.command !== 'lease_allocate') return undefined; - const provider = leaseScopeFromRequest(request).leaseProvider; + const { leaseProvider: provider, leaseBackend } = leaseScopeFromRequest(request); if (!provider) return undefined; const { providerCredentialFingerprint } = await import('../provider-credential-fingerprint.ts'); - return providerCredentialFingerprint(provider, process.env); + return providerCredentialFingerprint(provider, process.env, leaseBackend); } // A developer dir is a path on the client's host, so only a local daemon can use it. diff --git a/src/daemon/handlers/__tests__/lease.test.ts b/src/daemon/handlers/__tests__/lease.test.ts index 05290692fa..8de59f7099 100644 --- a/src/daemon/handlers/__tests__/lease.test.ts +++ b/src/daemon/handlers/__tests__/lease.test.ts @@ -344,6 +344,7 @@ const BROWSERSTACK_ENV = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KE function providerAllocateRequest( leaseProvider: string, providerCredentialFingerprint: string | undefined, + leaseBackend: 'ios-instance' | 'android-instance' = 'ios-instance', ): DaemonRequest { const request = allocateRequest(); return { @@ -351,7 +352,7 @@ function providerAllocateRequest( meta: { ...request.meta, leaseProvider, - leaseBackend: 'ios-instance', + leaseBackend, providerCredentialFingerprint, }, }; @@ -400,6 +401,31 @@ test('a daemon started with only LIMRUN_API_KEY refuses a shell with instance va ); }); +test('a Limrun lease compares only the leased platform instance variables', async () => { + const daemonEnv = { + ...LIMRUN_ATTACH_ENV, + LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', + LIM_ANDROID_INSTANCE_TOKEN: 'android-token', + LIM_ANDROID_INSTANCE_ADB_URL: 'wss://region.limrun.example/v1/android_x/adb', + }; + const shellEnv = { ...daemonEnv, LIM_ANDROID_INSTANCE_TOKEN: 'android-token-2' }; + const allocate = async (leaseBackend: 'ios-instance' | 'android-instance') => + await allocateWithDaemonEnv( + providerAllocateRequest( + 'limrun', + providerCredentialFingerprint('limrun', shellEnv, leaseBackend), + leaseBackend, + ), + daemonEnv, + ); + + assert.equal((await allocate('ios-instance')).error, undefined); + assert.equal( + (await allocate('android-instance')).error?.details?.reason, + 'provider-credentials-changed', + ); +}); + test('a daemon holding rotated BrowserStack keys refuses before allocation', async () => { const outcome = await allocateWithDaemonEnv( providerAllocateRequest( diff --git a/src/daemon/handlers/lease.ts b/src/daemon/handlers/lease.ts index f04ad088d4..8f869d23c0 100644 --- a/src/daemon/handlers/lease.ts +++ b/src/daemon/handlers/lease.ts @@ -75,7 +75,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise, 'leaseProvider' | 'leaseBackend'>, requested: string | undefined, daemon: DaemonProviderCredentials | undefined, ): void { if (!daemon || !provider || !requested) return; - const current = daemon.fingerprints[provider]; + const current = daemon.fingerprint(provider, leaseBackend); if (requested === current) return; throw new AppError( 'INVALID_ARGS', diff --git a/src/provider-credential-fingerprint.test.ts b/src/provider-credential-fingerprint.test.ts index d977bd21e2..a73beacb14 100644 --- a/src/provider-credential-fingerprint.test.ts +++ b/src/provider-credential-fingerprint.test.ts @@ -41,7 +41,7 @@ test.for(['limrun', 'browserstack'])( (provider) => { expect(providerCredentialFingerprint(provider, {})).toBe(undefined); expect(providerCredentialFingerprint(provider, { LIMRUN_REGION: ' ' })).toBe(undefined); - expect(readDaemonProviderCredentials({}, '/state').fingerprints[provider]).toBe(undefined); + expect(readDaemonProviderCredentials({}, '/state').fingerprint(provider)).toBe(undefined); }, ); @@ -62,18 +62,50 @@ test('a provider name that only matches an inherited object key has no fingerpri }); test('AWS Device Farm has no environment fingerprint', () => { - expect(providerCredentialFingerprint('aws-device-farm', { AWS_ACCESS_KEY_ID: 'id' })).toBe( + const env = { AWS_ACCESS_KEY_ID: 'id' }; + expect(providerCredentialFingerprint('aws-device-farm', env)).toBe(undefined); + expect(readDaemonProviderCredentials(env, '/state').fingerprint('aws-device-farm')).toBe( undefined, ); - expect(Object.keys(readDaemonProviderCredentials({}, '/state').fingerprints).sort()).toEqual([ - 'browserstack', - 'limrun', - ]); }); test('a fingerprint never contains a credential value', () => { - const fingerprints = JSON.stringify( - readDaemonProviderCredentials({ ...BROWSERSTACK_ENV, LIMRUN_API_KEY: 'lim-key' }, '/state'), + const daemon = readDaemonProviderCredentials( + { ...BROWSERSTACK_ENV, LIMRUN_API_KEY: 'lim-key' }, + '/state', ); + const fingerprints = JSON.stringify([ + daemon.fingerprint('browserstack'), + daemon.fingerprint('limrun'), + ]); for (const value of ['user', 'key-1', 'lim-key']) expect(fingerprints).not.toContain(value); }); + +const IOS_ATTACH = { + LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api', + LIM_IOS_INSTANCE_TOKEN: 'ios-token', +}; +const ANDROID_ATTACH = { + LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api', + LIM_ANDROID_INSTANCE_TOKEN: 'android-token', + LIM_ANDROID_INSTANCE_ADB_URL: 'wss://region.limrun.example/v1/android_x/adb', +}; + +test('a Limrun lease fingerprint covers only the leased platform and the account', () => { + const before = { LIMRUN_API_KEY: 'lim-key', ...IOS_ATTACH, ...ANDROID_ATTACH }; + const rotatedAndroid = { ...before, LIM_ANDROID_INSTANCE_TOKEN: 'android-token-2' }; + const ios = (env: Record) => + providerCredentialFingerprint('limrun', env, 'ios-instance'); + const android = (env: Record) => + providerCredentialFingerprint('limrun', env, 'android-instance'); + + expect(ios(rotatedAndroid)).toBe(ios(before)); + expect(android(rotatedAndroid)).not.toBe(android(before)); + expect(ios({ ...before, LIMRUN_API_KEY: 'lim-rotated' })).not.toBe(ios(before)); + expect(providerCredentialFingerprint('limrun', rotatedAndroid)).not.toBe( + providerCredentialFingerprint('limrun', before), + ); + expect( + readDaemonProviderCredentials(before, '/state').fingerprint('limrun', 'ios-instance'), + ).toBe(ios(rotatedAndroid)); +}); diff --git a/src/provider-credential-fingerprint.ts b/src/provider-credential-fingerprint.ts index e2ca0321e2..d6d12ee404 100644 --- a/src/provider-credential-fingerprint.ts +++ b/src/provider-credential-fingerprint.ts @@ -12,35 +12,41 @@ type CredentialValues = Readonly>; // Each provider's own reader, so the fingerprint sees exactly the values the provider uses. AWS // Device Farm is absent: it reads the AWS CLI credential chain, which no env hash identifies. -const PROVIDER_CREDENTIAL_READERS: ReadonlyMap CredentialValues> = new Map( +const PROVIDER_CREDENTIAL_READERS: ReadonlyMap< + string, + (env: EnvMap, leaseBackend?: string) => CredentialValues +> = new Map([ + ['limrun' satisfies typeof LIMRUN_PROVIDER, readLimrunCredentialValues], [ - ['limrun' satisfies typeof LIMRUN_PROVIDER, readLimrunCredentialValues], - [ - CLOUD_WEBDRIVER_PROVIDERS.browserStack, - (env: EnvMap): CredentialValues => { - const { username, accessKey } = readBrowserStackCredentials(env); - return { - [BROWSERSTACK_CREDENTIAL_VARIABLES.username]: username, - [BROWSERSTACK_CREDENTIAL_VARIABLES.accessKey]: accessKey, - }; - }, - ], + CLOUD_WEBDRIVER_PROVIDERS.browserStack, + (env: EnvMap): CredentialValues => { + const { username, accessKey } = readBrowserStackCredentials(env); + return { + [BROWSERSTACK_CREDENTIAL_VARIABLES.username]: username, + [BROWSERSTACK_CREDENTIAL_VARIABLES.accessKey]: accessKey, + }; + }, ], -); +]); /** - * A versioned, non-reversible digest of the credentials a provider reads from `env`, or undefined - * when `env` holds none of them or the provider's credentials do not come from the environment. + * A versioned, non-reversible digest of the credentials a lease on `leaseBackend` reads from `env`, + * or undefined when `env` holds none of them or the provider's credentials do not come from the + * environment. */ -export function providerCredentialFingerprint(provider: string, env: EnvMap): string | undefined { +export function providerCredentialFingerprint( + provider: string, + env: EnvMap, + leaseBackend?: string, +): string | undefined { const read = PROVIDER_CREDENTIAL_READERS.get(provider); - return read ? digest(read(env)) : undefined; + return read ? digest(read(env, leaseBackend)) : undefined; } /** The provider credentials a daemon started with, and the state dir that names that daemon. */ export type DaemonProviderCredentials = Readonly<{ - /** Undefined for a provider whose credentials the daemon's environment does not hold. */ - fingerprints: Readonly>; + /** Undefined when the daemon's environment holds none of the credentials such a lease reads. */ + fingerprint(provider: string, leaseBackend?: string): string | undefined; stateDir: string; }>; @@ -48,13 +54,12 @@ export function readDaemonProviderCredentials( env: EnvMap, stateDir: string, ): DaemonProviderCredentials { - const fingerprints = Object.fromEntries( - [...PROVIDER_CREDENTIAL_READERS.keys()].map((provider) => [ - provider, - providerCredentialFingerprint(provider, env), - ]), - ); - return { fingerprints, stateDir }; + const startupEnv = { ...env }; + return { + fingerprint: (provider, leaseBackend) => + providerCredentialFingerprint(provider, startupEnv, leaseBackend), + stateDir, + }; } function digest(values: CredentialValues): string | undefined { diff --git a/src/provider-limrun-credentials.ts b/src/provider-limrun-credentials.ts index 78c6c376f3..43d0a17efb 100644 --- a/src/provider-limrun-credentials.ts +++ b/src/provider-limrun-credentials.ts @@ -27,13 +27,34 @@ export const LIMRUN_CREDENTIAL_VARIABLES: readonly string[] = [ ...INSTANCE_VARS.android, ]; -/** Each credential variable's value, read by the same rule the credential reader uses. */ +// Mirrors platformForLimrunLeaseBackend in provider-limrun, which exposes only its root entry, and +// that entry loads the Limrun SDK; importing it here would load the SDK on every credential read. +const LEASE_BACKEND_PLATFORMS: ReadonlyMap = new Map([ + ['ios-instance', 'ios'], + ['android-instance', 'android'], +]); + +/** The platform whose instance a Limrun lease backend reaches. */ +export function limrunPlatformForLeaseBackend( + leaseBackend: string | undefined, +): 'ios' | 'android' | undefined { + return leaseBackend === undefined ? undefined : LEASE_BACKEND_PLATFORMS.get(leaseBackend); +} + +/** + * The value of each credential variable a lease on `leaseBackend` depends on, read by the same + * rule the credential reader uses: the account variables plus that platform's instance variables, + * or every variable when the backend names no platform. + */ export function readLimrunCredentialValues( env: EnvMap, + leaseBackend?: string, ): Readonly> { - return Object.fromEntries( - LIMRUN_CREDENTIAL_VARIABLES.map((name) => [name, readValue(env, name)]), - ); + const platform = limrunPlatformForLeaseBackend(leaseBackend); + const names = platform + ? [...Object.values(ACCOUNT_VARS), ...INSTANCE_VARS[platform]] + : LIMRUN_CREDENTIAL_VARIABLES; + return Object.fromEntries(names.map((name) => [name, readValue(env, name)])); } /** The variables that give access to an existing instance of a platform. */ diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 1f1a8b58c8..31ab07470a 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -43,7 +43,7 @@ agent-device disconnect On an attached Android instance, agent-device does not replace an existing port reverse mapping. If the owner already maps a device port, such as `tcp:8081` for their Metro server, a reverse to that port fails and the owner's mapping stays in place. The error has `details.reason: 'android_port_reverse_rebind_refused'` when `adb reverse --list` shows the mapping. Otherwise it is a plain ADB failure. -A running daemon keeps the Limrun variables it started with. If your shell holds different ones, the first command that allocates a lease, such as `install` or `open`, refuses before it creates or attaches to an instance; run `agent-device daemon stop` (with the same `--state-dir`) and rerun the command. A shell that sets none of these variables uses the daemon's. +A running daemon keeps the Limrun variables it started with. If your shell holds different account variables, or different instance variables for the platform being leased, the first command that allocates a lease, such as `install` or `open`, refuses before it creates or attaches to an instance; run `agent-device daemon stop` (with the same `--state-dir`) and rerun the command. A shell that sets none of the compared variables uses the daemon's. `install`, and `apps` before the first `open`, still need `LIMRUN_API_KEY`, because they use Limrun asset storage. After `open`, `apps` lists the apps installed on the instance without the key. Install the app before you hand over the instance. From the Node.js runtime, `getDeviceSession(device).installRemoteApp(url)` installs from a signed asset URL without the API key.