Skip to content

Commit 83cf66c

Browse files
committed
fix(limrun): compare only the leased platform's credentials
A Limrun lease fingerprint now covers the account variables plus the instance variables of the platform its lease backend names, on both the CLI and the daemon, so a changed Android instance no longer refuses an iOS allocation. The daemon keeps a snapshot of its startup environment and fingerprints per lease. The lease backend to platform rule has one home in the Limrun credentials module, which the connect profile now uses too.
1 parent 9b3c7ca commit 83cf66c

8 files changed

Lines changed: 132 additions & 46 deletions

File tree

‎src/cli/connection/limrun-profile.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { persistAndResolveGeneratedProfile } from './generated-config.ts';
88
import { resolveRequestedLeaseBackend } from '../commands/connection-runtime.ts';
99
import {
1010
limrunInstanceVariables,
11+
limrunPlatformForLeaseBackend,
1112
readLimrunCredentials,
1213
} from '../../provider-limrun-credentials.ts';
1314

@@ -21,7 +22,7 @@ export function resolveLimrunConnectProfile(options: {
2122
const env = options.env ?? process.env;
2223
const profile = buildLimrunRemoteProfile({ flags: options.flags });
2324
const credentials = readLimrunCredentials(env);
24-
const platform = profile.leaseBackend === 'ios-instance' ? 'ios' : 'android';
25+
const platform = limrunPlatformForLeaseBackend(profile.leaseBackend) ?? 'android';
2526
if (!credentials?.apiKey && !credentials?.instances?.[platform]) {
2627
throw new AppError(
2728
'INVALID_ARGS',

‎src/daemon-client/daemon-client.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,10 @@ async function readLocalProviderCredentialFingerprint(
142142
info: DaemonInfo,
143143
): Promise<string | undefined> {
144144
if (isRemoteDaemon(info) || request.command !== 'lease_allocate') return undefined;
145-
const provider = leaseScopeFromRequest(request).leaseProvider;
145+
const { leaseProvider: provider, leaseBackend } = leaseScopeFromRequest(request);
146146
if (!provider) return undefined;
147147
const { providerCredentialFingerprint } = await import('../provider-credential-fingerprint.ts');
148-
return providerCredentialFingerprint(provider, process.env);
148+
return providerCredentialFingerprint(provider, process.env, leaseBackend);
149149
}
150150

151151
// A developer dir is a path on the client's host, so only a local daemon can use it.

‎src/daemon/handlers/__tests__/lease.test.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,15 @@ const BROWSERSTACK_ENV = { BROWSERSTACK_USERNAME: 'user', BROWSERSTACK_ACCESS_KE
190190
function providerAllocateRequest(
191191
leaseProvider: string,
192192
providerCredentialFingerprint: string | undefined,
193+
leaseBackend: 'ios-instance' | 'android-instance' = 'ios-instance',
193194
): DaemonRequest {
194195
const request = allocateRequest();
195196
return {
196197
...request,
197198
meta: {
198199
...request.meta,
199200
leaseProvider,
200-
leaseBackend: 'ios-instance',
201+
leaseBackend,
201202
providerCredentialFingerprint,
202203
},
203204
};
@@ -246,6 +247,31 @@ test('a daemon started with only LIMRUN_API_KEY refuses a shell with instance va
246247
);
247248
});
248249

250+
test('a Limrun lease compares only the leased platform instance variables', async () => {
251+
const daemonEnv = {
252+
...LIMRUN_ATTACH_ENV,
253+
LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api',
254+
LIM_ANDROID_INSTANCE_TOKEN: 'android-token',
255+
LIM_ANDROID_INSTANCE_ADB_URL: 'wss://region.limrun.example/v1/android_x/adb',
256+
};
257+
const shellEnv = { ...daemonEnv, LIM_ANDROID_INSTANCE_TOKEN: 'android-token-2' };
258+
const allocate = async (leaseBackend: 'ios-instance' | 'android-instance') =>
259+
await allocateWithDaemonEnv(
260+
providerAllocateRequest(
261+
'limrun',
262+
providerCredentialFingerprint('limrun', shellEnv, leaseBackend),
263+
leaseBackend,
264+
),
265+
daemonEnv,
266+
);
267+
268+
assert.equal((await allocate('ios-instance')).error, undefined);
269+
assert.equal(
270+
(await allocate('android-instance')).error?.details?.reason,
271+
'provider-credentials-changed',
272+
);
273+
});
274+
249275
test('a daemon holding rotated BrowserStack keys refuses before allocation', async () => {
250276
const outcome = await allocateWithDaemonEnv(
251277
providerAllocateRequest(

‎src/daemon/handlers/lease.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
7575
providerRuntimeRequiredIds,
7676
);
7777
assertProviderCredentialsUnchanged(
78-
leaseScope.leaseProvider,
78+
leaseScope,
7979
req.meta?.providerCredentialFingerprint,
8080
providerCredentials,
8181
);
@@ -279,12 +279,15 @@ function assertProviderRuntimeAvailable(
279279
}
280280

281281
function assertProviderCredentialsUnchanged(
282-
provider: string | undefined,
282+
{
283+
leaseProvider: provider,
284+
leaseBackend,
285+
}: Pick<ReturnType<typeof resolveLeaseScope>, 'leaseProvider' | 'leaseBackend'>,
283286
requested: string | undefined,
284287
daemon: DaemonProviderCredentials | undefined,
285288
): void {
286289
if (!daemon || !provider || !requested) return;
287-
const current = daemon.fingerprints[provider];
290+
const current = daemon.fingerprint(provider, leaseBackend);
288291
if (requested === current) return;
289292
throw new AppError(
290293
'INVALID_ARGS',

‎src/provider-credential-fingerprint.test.ts‎

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ test.for(['limrun', 'browserstack'])(
4141
(provider) => {
4242
expect(providerCredentialFingerprint(provider, {})).toBe(undefined);
4343
expect(providerCredentialFingerprint(provider, { LIMRUN_REGION: ' ' })).toBe(undefined);
44-
expect(readDaemonProviderCredentials({}, '/state').fingerprints[provider]).toBe(undefined);
44+
expect(readDaemonProviderCredentials({}, '/state').fingerprint(provider)).toBe(undefined);
4545
},
4646
);
4747

@@ -62,18 +62,50 @@ test('a provider name that only matches an inherited object key has no fingerpri
6262
});
6363

6464
test('AWS Device Farm has no environment fingerprint', () => {
65-
expect(providerCredentialFingerprint('aws-device-farm', { AWS_ACCESS_KEY_ID: 'id' })).toBe(
65+
const env = { AWS_ACCESS_KEY_ID: 'id' };
66+
expect(providerCredentialFingerprint('aws-device-farm', env)).toBe(undefined);
67+
expect(readDaemonProviderCredentials(env, '/state').fingerprint('aws-device-farm')).toBe(
6668
undefined,
6769
);
68-
expect(Object.keys(readDaemonProviderCredentials({}, '/state').fingerprints).sort()).toEqual([
69-
'browserstack',
70-
'limrun',
71-
]);
7270
});
7371

7472
test('a fingerprint never contains a credential value', () => {
75-
const fingerprints = JSON.stringify(
76-
readDaemonProviderCredentials({ ...BROWSERSTACK_ENV, LIMRUN_API_KEY: 'lim-key' }, '/state'),
73+
const daemon = readDaemonProviderCredentials(
74+
{ ...BROWSERSTACK_ENV, LIMRUN_API_KEY: 'lim-key' },
75+
'/state',
7776
);
77+
const fingerprints = JSON.stringify([
78+
daemon.fingerprint('browserstack'),
79+
daemon.fingerprint('limrun'),
80+
]);
7881
for (const value of ['user', 'key-1', 'lim-key']) expect(fingerprints).not.toContain(value);
7982
});
83+
84+
const IOS_ATTACH = {
85+
LIM_IOS_INSTANCE_URL: 'https://region.limrun.example/v1/ios_x/api',
86+
LIM_IOS_INSTANCE_TOKEN: 'ios-token',
87+
};
88+
const ANDROID_ATTACH = {
89+
LIM_ANDROID_INSTANCE_URL: 'https://region.limrun.example/v1/android_x/api',
90+
LIM_ANDROID_INSTANCE_TOKEN: 'android-token',
91+
LIM_ANDROID_INSTANCE_ADB_URL: 'wss://region.limrun.example/v1/android_x/adb',
92+
};
93+
94+
test('a Limrun lease fingerprint covers only the leased platform and the account', () => {
95+
const before = { LIMRUN_API_KEY: 'lim-key', ...IOS_ATTACH, ...ANDROID_ATTACH };
96+
const rotatedAndroid = { ...before, LIM_ANDROID_INSTANCE_TOKEN: 'android-token-2' };
97+
const ios = (env: Record<string, string>) =>
98+
providerCredentialFingerprint('limrun', env, 'ios-instance');
99+
const android = (env: Record<string, string>) =>
100+
providerCredentialFingerprint('limrun', env, 'android-instance');
101+
102+
expect(ios(rotatedAndroid)).toBe(ios(before));
103+
expect(android(rotatedAndroid)).not.toBe(android(before));
104+
expect(ios({ ...before, LIMRUN_API_KEY: 'lim-rotated' })).not.toBe(ios(before));
105+
expect(providerCredentialFingerprint('limrun', rotatedAndroid)).not.toBe(
106+
providerCredentialFingerprint('limrun', before),
107+
);
108+
expect(
109+
readDaemonProviderCredentials(before, '/state').fingerprint('limrun', 'ios-instance'),
110+
).toBe(ios(rotatedAndroid));
111+
});

‎src/provider-credential-fingerprint.ts‎

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,49 +12,54 @@ type CredentialValues = Readonly<Record<string, string | undefined>>;
1212

1313
// Each provider's own reader, so the fingerprint sees exactly the values the provider uses. AWS
1414
// Device Farm is absent: it reads the AWS CLI credential chain, which no env hash identifies.
15-
const PROVIDER_CREDENTIAL_READERS: ReadonlyMap<string, (env: EnvMap) => CredentialValues> = new Map(
15+
const PROVIDER_CREDENTIAL_READERS: ReadonlyMap<
16+
string,
17+
(env: EnvMap, leaseBackend?: string) => CredentialValues
18+
> = new Map([
19+
['limrun' satisfies typeof LIMRUN_PROVIDER, readLimrunCredentialValues],
1620
[
17-
['limrun' satisfies typeof LIMRUN_PROVIDER, readLimrunCredentialValues],
18-
[
19-
CLOUD_WEBDRIVER_PROVIDERS.browserStack,
20-
(env: EnvMap): CredentialValues => {
21-
const { username, accessKey } = readBrowserStackCredentials(env);
22-
return {
23-
[BROWSERSTACK_CREDENTIAL_VARIABLES.username]: username,
24-
[BROWSERSTACK_CREDENTIAL_VARIABLES.accessKey]: accessKey,
25-
};
26-
},
27-
],
21+
CLOUD_WEBDRIVER_PROVIDERS.browserStack,
22+
(env: EnvMap): CredentialValues => {
23+
const { username, accessKey } = readBrowserStackCredentials(env);
24+
return {
25+
[BROWSERSTACK_CREDENTIAL_VARIABLES.username]: username,
26+
[BROWSERSTACK_CREDENTIAL_VARIABLES.accessKey]: accessKey,
27+
};
28+
},
2829
],
29-
);
30+
]);
3031

3132
/**
32-
* A versioned, non-reversible digest of the credentials a provider reads from `env`, or undefined
33-
* when `env` holds none of them or the provider's credentials do not come from the environment.
33+
* A versioned, non-reversible digest of the credentials a lease on `leaseBackend` reads from `env`,
34+
* or undefined when `env` holds none of them or the provider's credentials do not come from the
35+
* environment.
3436
*/
35-
export function providerCredentialFingerprint(provider: string, env: EnvMap): string | undefined {
37+
export function providerCredentialFingerprint(
38+
provider: string,
39+
env: EnvMap,
40+
leaseBackend?: string,
41+
): string | undefined {
3642
const read = PROVIDER_CREDENTIAL_READERS.get(provider);
37-
return read ? digest(read(env)) : undefined;
43+
return read ? digest(read(env, leaseBackend)) : undefined;
3844
}
3945

4046
/** The provider credentials a daemon started with, and the state dir that names that daemon. */
4147
export type DaemonProviderCredentials = Readonly<{
42-
/** Undefined for a provider whose credentials the daemon's environment does not hold. */
43-
fingerprints: Readonly<Record<string, string | undefined>>;
48+
/** Undefined when the daemon's environment holds none of the credentials such a lease reads. */
49+
fingerprint(provider: string, leaseBackend?: string): string | undefined;
4450
stateDir: string;
4551
}>;
4652

4753
export function readDaemonProviderCredentials(
4854
env: EnvMap,
4955
stateDir: string,
5056
): DaemonProviderCredentials {
51-
const fingerprints = Object.fromEntries(
52-
[...PROVIDER_CREDENTIAL_READERS.keys()].map((provider) => [
53-
provider,
54-
providerCredentialFingerprint(provider, env),
55-
]),
56-
);
57-
return { fingerprints, stateDir };
57+
const startupEnv = { ...env };
58+
return {
59+
fingerprint: (provider, leaseBackend) =>
60+
providerCredentialFingerprint(provider, startupEnv, leaseBackend),
61+
stateDir,
62+
};
5863
}
5964

6065
function digest(values: CredentialValues): string | undefined {

‎src/provider-limrun-credentials.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,32 @@ export const LIMRUN_CREDENTIAL_VARIABLES: readonly string[] = [
2727
...INSTANCE_VARS.android,
2828
];
2929

30-
/** Each credential variable's value, read by the same rule the credential reader uses. */
30+
const LEASE_BACKEND_PLATFORMS: ReadonlyMap<string, 'ios' | 'android'> = new Map([
31+
['ios-instance', 'ios'],
32+
['android-instance', 'android'],
33+
]);
34+
35+
/** The platform whose instance a Limrun lease backend reaches. */
36+
export function limrunPlatformForLeaseBackend(
37+
leaseBackend: string | undefined,
38+
): 'ios' | 'android' | undefined {
39+
return leaseBackend === undefined ? undefined : LEASE_BACKEND_PLATFORMS.get(leaseBackend);
40+
}
41+
42+
/**
43+
* The value of each credential variable a lease on `leaseBackend` depends on, read by the same
44+
* rule the credential reader uses: the account variables plus that platform's instance variables,
45+
* or every variable when the backend names no platform.
46+
*/
3147
export function readLimrunCredentialValues(
3248
env: EnvMap,
49+
leaseBackend?: string,
3350
): Readonly<Record<string, string | undefined>> {
34-
return Object.fromEntries(
35-
LIMRUN_CREDENTIAL_VARIABLES.map((name) => [name, readValue(env, name)]),
36-
);
51+
const platform = limrunPlatformForLeaseBackend(leaseBackend);
52+
const names = platform
53+
? [...Object.values(ACCOUNT_VARS), ...INSTANCE_VARS[platform]]
54+
: LIMRUN_CREDENTIAL_VARIABLES;
55+
return Object.fromEntries(names.map((name) => [name, readValue(env, name)]));
3756
}
3857

3958
/** The variables that give access to an existing instance of a platform. */

‎website/docs/docs/limrun.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ agent-device disconnect
4343

4444
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.
4545

46-
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.
46+
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. Only the account variables and the leased platform's instance variables are compared. A shell that sets none of these variables uses the daemon's.
4747

4848
`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.
4949

0 commit comments

Comments
 (0)