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
2 changes: 2 additions & 0 deletions packages/kernel/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export type DaemonRequestMeta = {
leaseProvider?: string;
deviceKey?: string;
clientId?: string;
/** A local caller's digest of its lease-provider credential variables, compared on allocation. */
providerCredentialFingerprint?: string;
sessionIsolation?: SessionIsolationMode;
uploadedArtifactId?: string;
clientArtifactPaths?: Record<string, string>;
Expand Down
1 change: 1 addition & 0 deletions packages/provider-webdriver/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { CloudWebDriverRuntime } from './runtime.ts';
export { CLOUD_WEBDRIVER_PROFILE_FIELDS, CLOUD_WEBDRIVER_PROVIDERS };
export { readAwsDeviceFarmRegionFromArn };
export { parseBrowserStackAppReference } from './browserstack.ts';
export { requireBrowserStackCredentials } from './provider-definitions.ts';
export type { CloudWebDriverKnownProviderName } from './providers.ts';
export type { ProviderWebDriverDependencies, RunHostCommand } from './dependencies.ts';
export type {
Expand Down
44 changes: 21 additions & 23 deletions packages/provider-webdriver/src/provider-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ import {
buildBrowserStackDeviceFeatureCapabilities,
readBrowserStackDeviceFeatureFields,
} from './browserstack-device-features.ts';
import { CLOUD_WEBDRIVER_PROVIDERS, type CloudWebDriverKnownProviderName } from './providers.ts';
import {
BROWSERSTACK_CREDENTIAL_VARIABLES,
CLOUD_WEBDRIVER_PROVIDERS,
readBrowserStackCredentials,
type CloudWebDriverKnownProviderName,
} from './providers.ts';
import { readAwsDeviceFarmRegionFromArn } from './connection-verification.ts';
import {
buildCloudWebDriverBaseCapabilities,
Expand Down Expand Up @@ -142,14 +147,8 @@ export function createCloudWebDriverProviderDefinitions(
endpoint: env.BROWSERSTACK_WEBDRIVER_ENDPOINT ?? BROWSERSTACK_APP_AUTOMATE_ENDPOINT,
capabilityOverrides: BROWSERSTACK_CAPABILITY_OVERRIDES,
listArtifacts: async ({ provider, providerSessionId }) => {
const username = requireEnv(
env,
'BROWSERSTACK_USERNAME',
'BrowserStack artifact lookup',
);
const accessKey = requireEnv(
const { username, accessKey } = requireBrowserStackCredentials(
env,
'BROWSERSTACK_ACCESS_KEY',
'BrowserStack artifact lookup',
);
return await listBrowserStackCloudArtifacts(provider, providerSessionId, {
Expand All @@ -161,8 +160,7 @@ export function createCloudWebDriverProviderDefinitions(
},
prepareSession: async ({ req, lease, base }) => {
const request = requireRequest(req, 'BrowserStack');
const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack');
const accessKey = requireEnv(env, 'BROWSERSTACK_ACCESS_KEY', 'BrowserStack');
const { username, accessKey } = requireBrowserStackCredentials(env, 'BrowserStack');
const platform = requireRequestPlatform(request, 'BrowserStack');
const deviceName = requireFlag(
request,
Expand Down Expand Up @@ -220,10 +218,8 @@ export function createCloudWebDriverProviderDefinitions(
},
}),
listArtifactsFromEnv: async (providerSessionId, env) => {
const username = requireEnv(env, 'BROWSERSTACK_USERNAME', 'BrowserStack artifact lookup');
const accessKey = requireEnv(
const { username, accessKey } = requireBrowserStackCredentials(
env,
'BROWSERSTACK_ACCESS_KEY',
'BrowserStack artifact lookup',
);
return await listBrowserStackCloudArtifacts(
Expand Down Expand Up @@ -343,16 +339,6 @@ function readFlag(req: LeaseLifecycleContext, key: string): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}

function requireEnv(
env: DefaultCloudWebDriverProviderRuntimeEnv,
key: keyof DefaultCloudWebDriverProviderRuntimeEnv,
providerLabel: string,
): string {
const value = env[key];
if (value) return value;
throw new AppError('INVALID_ARGS', `${providerLabel} requires ${key} in the environment.`);
}

function requireAwsValue(
req: LeaseLifecycleContext,
env: DefaultCloudWebDriverProviderRuntimeEnv,
Expand All @@ -379,3 +365,15 @@ function readAwsInteractionMode(
function dasherize(value: string): string {
return value.replaceAll(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
}

export function requireBrowserStackCredentials(
env: Readonly<Record<string, string | undefined>>,
consumer: string,
): { username: string; accessKey: string } {
const { username, accessKey } = readBrowserStackCredentials(env);
if (username && accessKey) return { username, accessKey };
const missing = username
? BROWSERSTACK_CREDENTIAL_VARIABLES.accessKey
: BROWSERSTACK_CREDENTIAL_VARIABLES.username;
throw new AppError('INVALID_ARGS', `${consumer} requires ${missing} in the environment.`);
}
16 changes: 16 additions & 0 deletions packages/provider-webdriver/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ export function isCloudWebDriverProviderName(
return provider !== undefined && CLOUD_WEBDRIVER_KNOWN_PROVIDERS.has(provider);
}

/** The environment variables that hold BrowserStack credentials. */
export const BROWSERSTACK_CREDENTIAL_VARIABLES = {
username: 'BROWSERSTACK_USERNAME',
accessKey: 'BROWSERSTACK_ACCESS_KEY',
} as const;

/** The BrowserStack credentials in the environment, exactly as every consumer and the fingerprint use them. */
export function readBrowserStackCredentials(
env: Readonly<Record<string, string | undefined>>,
): Readonly<{ username?: string; accessKey?: string }> {
return {
username: env[BROWSERSTACK_CREDENTIAL_VARIABLES.username] || undefined,
accessKey: env[BROWSERSTACK_CREDENTIAL_VARIABLES.accessKey] || undefined,
};
}

const BROWSERSTACK_APP_SCHEME = 'bs://';

/**
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/hermetic-env-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ import { afterEach } from 'vitest';
// daemonBaseUrl/daemonAuthToken keys, and daemon-client tests take the remote
// path ("Remote daemon is unavailable") instead of the local one they exercise.
//
// An HTTP auth hook likewise makes a local daemon treat every HTTP caller as remote.
//
// CI runs with these unset. Delete them here so a configured host matches CI.
// Tests that genuinely need them assign their own value or pass an explicit env
// object; that happens inside the test, after this module has loaded, so this
// scrub does not interfere.
const AMBIENT_DAEMON_ENV_VARS = [
'AGENT_DEVICE_DAEMON_BASE_URL',
'AGENT_DEVICE_DAEMON_AUTH_TOKEN',
'AGENT_DEVICE_HTTP_AUTH_HOOK',
'AGENT_DEVICE_HTTP_AUTH_EXPORT',
] as const;

for (const name of AMBIENT_DAEMON_ENV_VARS) {
Expand Down
10 changes: 2 additions & 8 deletions src/cli/connection/cloud-webdriver-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CLOUD_WEBDRIVER_PROVIDERS,
parseBrowserStackAppReference,
readAwsDeviceFarmRegionFromArn,
requireBrowserStackCredentials,
type CloudWebDriverKnownProviderName,
} from '@agent-device/provider-webdriver';
import { rejectRefusedProviderProfileFields } from '@agent-device/contracts/provider-profile-fields';
Expand Down Expand Up @@ -99,8 +100,7 @@ function browserStackProfileFields(options: {
env?: EnvMap;
cwd: string;
}): RemoteConfigProfile {
requireEnv(options.env, 'BROWSERSTACK_USERNAME', 'connect browserstack');
requireEnv(options.env, 'BROWSERSTACK_ACCESS_KEY', 'connect browserstack');
requireBrowserStackCredentials(options.env ?? {}, 'connect browserstack');
const platform = requireCloudWebDriverPlatform(
options.flags.platform,
'connect browserstack requires --platform ios|android.',
Expand Down Expand Up @@ -195,12 +195,6 @@ function requireFlag(value: string | undefined, message: string): string {
throw new AppError('INVALID_ARGS', message);
}

function requireEnv(env: EnvMap | undefined, name: string, command: string): string {
const value = env?.[name];
if (value) return value;
throw new AppError('INVALID_ARGS', `${command} requires ${name} in the environment.`);
}

function requireAwsProfileValue(
flagValue: string | undefined,
env: EnvMap | undefined,
Expand Down
6 changes: 4 additions & 2 deletions src/cli/connection/connect-provider-adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CliFlags } from '@agent-device/contracts/command';
import type { ProviderConnectionVerification } from '@agent-device/contracts/remote';
import { verifyLimrunConnection } from '@agent-device/provider-limrun';
import { AppError } from '@agent-device/kernel/errors';
import { readBrowserStackCredentials } from '@agent-device/provider-webdriver/providers';
import { providerWebDriver } from '../../provider-webdriver.ts';
import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts';
import { readVersion } from '@agent-device/host-kit/version';
Expand Down Expand Up @@ -134,14 +135,15 @@ async function verifyBrowserStack(
context: Pick<AdapterContext, 'flags' | 'env'>,
): Promise<ConnectVerification> {
const { flags, env } = context;
const credentials = readBrowserStackCredentials(env);
return await providerWebDriver.verifyConnection({
provider: 'browserstack',
username: requiredResolvedValue(
env.BROWSERSTACK_USERNAME,
credentials.username,
'BrowserStack profile missed BROWSERSTACK_USERNAME.',
),
accessKey: requiredResolvedValue(
env.BROWSERSTACK_ACCESS_KEY,
credentials.accessKey,
'BrowserStack profile missed BROWSERSTACK_ACCESS_KEY.',
),
platform: requiredResolvedPlatform(flags.platform, 'BrowserStack'),
Expand Down
1 change: 1 addition & 0 deletions src/commands/schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,7 @@ Rules:
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 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, or the LIM_*_INSTANCE_* variables for an existing instance. 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.
A local daemon keeps the Limrun and BrowserStack credentials it started with. When the shell holds different ones, the first command that allocates a lease refuses with reason provider-credentials-changed; run agent-device daemon stop with the same --state-dir, then rerun the command. A shell that sets none of them uses the daemon's. A daemon with an HTTP auth hook serves remote callers and does not compare.
Direct-provider connect performs read-only provider calls and saves active connection state only after verification succeeds. It never creates a device, instance, App Automate session, or AWS remote access session.
connect without --session always creates a fresh remote session and prints that session in its next-step commands. Concurrent callers must pass the returned --session on every command; the ambient active connection is only a single-workflow convenience.
To replace an existing connection, pass its returned session explicitly with --session <name> --force. --force without --session creates another fresh session and does not release or overwrite an unrelated active connection.
Expand Down
158 changes: 158 additions & 0 deletions src/daemon-client/__tests__/daemon-client-provider-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { afterEach, expect, test, vi } from 'vitest';
import fs from 'node:fs';
import net from 'node:net';
import { readProcessStartTime } from '@agent-device/host-kit/process';
import { readVersion } from '@agent-device/host-kit/version';
import { sendToDaemon } from '../daemon-client.ts';
import { resolveDaemonPaths } from '../../daemon-resolution.ts';
import {
currentDaemonCodeSignature,
startHttpDaemonFixture,
} from '../../__tests__/test-utils/daemon-http-fixture.ts';
import {
closeLoopbackServer,
listenOnLoopback,
supportsLoopbackBind,
} from '../../__tests__/test-utils/loopback.ts';
import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts';
import { providerCredentialFingerprint } from '../../provider-credential-fingerprint.ts';
import { LIMRUN_CREDENTIAL_VARIABLES } from '../../provider-limrun-credentials.ts';

const API_KEY = 'lim-secret-key';

afterEach(() => {
vi.unstubAllEnvs();
});

type CapturedDaemon = { bodies: string[]; close: () => Promise<void> };

test.sequential.for(['socket', 'http'] as const)(
'a local %s daemon gets a fingerprint, never the key, only on lease_allocate with credentials',
async (transport, t) => {
if (!(await supportsLoopbackBind())) {
t.skip('loopback listeners are not permitted in this environment');
return;
}
clearCredentialEnv();
vi.stubEnv('AGENT_DEVICE_DAEMON_BASE_URL', undefined);
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) =>
sendToDaemon({
session: 'default',
command,
positionals: [],
flags: { stateDir, daemonTransport: transport },
meta: { requestId: `req-${command}`, leaseProvider: 'limrun', tenantId: 'tenant-a' },
});

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');
} finally {
await daemon.close();
fs.rmSync(stateDir, { recursive: true, force: true });
}

expect(daemon.bodies.map(readFingerprint)).toEqual([
providerCredentialFingerprint('limrun', { LIMRUN_API_KEY: API_KEY }),
undefined,
undefined,
]);
for (const body of daemon.bodies) expect(body).not.toContain(API_KEY);
},
);

test.sequential('a remote daemon gets no provider credential fingerprint', async (t) => {
if (!(await supportsLoopbackBind())) {
t.skip('loopback listeners are not permitted in this environment');
return;
}
clearCredentialEnv();
vi.stubEnv('LIMRUN_API_KEY', API_KEY);
const remote = await startHttpDaemonFixture({});
vi.stubEnv('AGENT_DEVICE_DAEMON_BASE_URL', `http://127.0.0.1:${remote.port}`);
vi.stubEnv('AGENT_DEVICE_DAEMON_AUTH_TOKEN', 'remote-secret');

try {
await sendToDaemon({
session: 'default',
command: 'lease_allocate',
positionals: [],
meta: { requestId: 'req-remote-lease', leaseProvider: 'limrun', tenantId: 'tenant-a' },
});
} finally {
await closeLoopbackServer(remote.server);
}

expect(remote.rpcRequests.map((rpc) => rpc.method)).toEqual(['agent_device.lease.allocate']);
const body = JSON.stringify(remote.rpcRequests[0]);
expect(readFingerprint(body)).toBe(undefined);
expect(body).not.toContain(API_KEY);
});

function readFingerprint(body: string): unknown {
const parsed = JSON.parse(body) as {
meta?: { providerCredentialFingerprint?: unknown };
params?: { providerCredentialFingerprint?: unknown; meta?: Record<string, unknown> };
};
return (
parsed.meta?.providerCredentialFingerprint ??
parsed.params?.providerCredentialFingerprint ??
parsed.params?.meta?.providerCredentialFingerprint
);
}

function clearCredentialEnv(): void {
for (const name of LIMRUN_CREDENTIAL_VARIABLES) vi.stubEnv(name, undefined);
}

async function startLocalDaemon(
stateDir: string,
transport: 'socket' | 'http',
): Promise<CapturedDaemon> {
if (transport === 'http') {
const daemon = await startHttpDaemonFixture({});
writeDaemonInfo(stateDir, { httpPort: daemon.port, transport });
return {
get bodies() {
return daemon.rpcRequests.map((rpc) => JSON.stringify(rpc));
},
close: () => closeLoopbackServer(daemon.server),
};
}
const bodies: string[] = [];
const server = net.createServer((socket) => {
socket.setEncoding('utf8');
let body = '';
socket.on('data', (chunk) => {
body += chunk;
if (!body.includes('\n')) return;
bodies.push(body.trim());
socket.end(`${JSON.stringify({ ok: true, data: {} })}\n`);
});
});
writeDaemonInfo(stateDir, { port: await listenOnLoopback(server), transport });
return { bodies, close: () => closeLoopbackServer(server) };
}

function writeDaemonInfo(stateDir: string, endpoint: Record<string, unknown>): void {
const paths = resolveDaemonPaths(stateDir);
fs.mkdirSync(paths.baseDir, { recursive: true });
fs.writeFileSync(
paths.infoPath,
`${JSON.stringify({
...endpoint,
token: 'local-secret',
pid: process.pid,
version: readVersion(),
codeSignature: currentDaemonCodeSignature(),
processStartTime: readProcessStartTime(process.pid) ?? undefined,
})}\n`,
'utf8',
);
}
3 changes: 3 additions & 0 deletions src/daemon-client/daemon-client-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,9 @@ export function buildHttpRpcPayload(
params: {
...buildLeaseRpcParams(req, req.command, options),
...(req.command === 'lease_allocate' ? readLeaseAllocateProviderFlags(req.flags) : {}),
...(req.command === 'lease_allocate' && req.meta?.providerCredentialFingerprint
? { providerCredentialFingerprint: req.meta.providerCredentialFingerprint }
: {}),
},
};
}
Expand Down
Loading
Loading