Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/cli/connection/limrun-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -38,31 +43,44 @@ 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);
await send('lease_allocate');
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');
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} 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);
},
);
Expand Down
4 changes: 2 additions & 2 deletions src/daemon-client/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,10 @@ async function readLocalProviderCredentialFingerprint(
info: DaemonInfo,
): Promise<string | undefined> {
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.
Expand Down
28 changes: 27 additions & 1 deletion src/daemon/handlers/__tests__/lease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,14 +344,15 @@ 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 {
...request,
meta: {
...request.meta,
leaseProvider,
leaseBackend: 'ios-instance',
leaseBackend,
providerCredentialFingerprint,
},
};
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 6 additions & 3 deletions src/daemon/handlers/lease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<Daemo
providerRuntimeRequiredIds,
);
assertProviderCredentialsUnchanged(
leaseScope.leaseProvider,
leaseScope,
req.meta?.providerCredentialFingerprint,
providerCredentials,
);
Expand Down Expand Up @@ -296,12 +296,15 @@ function assertProviderRuntimeAvailable(
}

function assertProviderCredentialsUnchanged(
provider: string | undefined,
{
leaseProvider: provider,
leaseBackend,
}: Pick<ReturnType<typeof resolveLeaseScope>, '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',
Expand Down
48 changes: 40 additions & 8 deletions src/provider-credential-fingerprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);

Expand All @@ -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<string, string>) =>
providerCredentialFingerprint('limrun', env, 'ios-instance');
const android = (env: Record<string, string>) =>
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));
});
57 changes: 31 additions & 26 deletions src/provider-credential-fingerprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,49 +12,54 @@ type CredentialValues = Readonly<Record<string, string | undefined>>;

// 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<string, (env: EnvMap) => 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<Record<string, string | undefined>>;
/** Undefined when the daemon's environment holds none of the credentials such a lease reads. */
fingerprint(provider: string, leaseBackend?: string): string | undefined;
stateDir: string;
}>;

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 {
Expand Down
29 changes: 25 additions & 4 deletions src/provider-limrun-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, 'ios' | 'android'> = 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<Record<string, string | undefined>> {
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. */
Expand Down
2 changes: 1 addition & 1 deletion website/docs/docs/limrun.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading