diff --git a/packages/provider-revyl/README.md b/packages/provider-revyl/README.md index b046051c10..e59b94951e 100644 --- a/packages/provider-revyl/README.md +++ b/packages/provider-revyl/README.md @@ -8,6 +8,7 @@ customers; a partner integration gets one through Revyl's partner program. npm install -g agent-device agent-device plugins add @agent-device/revyl export REVYL_API_KEY=your-api-key +export AGENT_DEVICE_SESSION=checkout-ios agent-device connect revyl --platform ios --provider-app https://apps.example.com/MyApp-simulator.zip agent-device open com.example.app agent-device snapshot -i diff --git a/packages/provider-testmu/README.md b/packages/provider-testmu/README.md index acf954a3c7..b16825971a 100644 --- a/packages/provider-testmu/README.md +++ b/packages/provider-testmu/README.md @@ -8,7 +8,7 @@ npm install -g agent-device agent-device plugins add @agent-device/testmu export LT_USERNAME=your-username export LT_ACCESS_KEY=your-access-key -agent-device connect testmu --platform ios --device "iPhone 16" --provider-os-version 18.0 --provider-app ./MyApp.zip +agent-device connect testmu --session checkout-ios --platform ios --device "iPhone 16" --provider-os-version 18.0 --provider-app ./MyApp.zip ``` See the [TestMu AI guide](https://agent-device.dev/docs/testmu) for setup and supported operations. diff --git a/packages/proxy/README.md b/packages/proxy/README.md index 40f718fe89..eb6e088fd2 100644 --- a/packages/proxy/README.md +++ b/packages/proxy/README.md @@ -37,7 +37,8 @@ server.listen(8080); ``` Clients connect with `agent-device connect proxy --daemon-base-url https://gateway.example.com/agent-device` -and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` set to `clientToken`. +and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` set to `clientToken`. Each agent sets its own `AGENT_DEVICE_SESSION` +(or passes `--session`): a command that names no session on a proxy connection is refused. To mount the proxy in an existing `node:http` or `node:https` server, use `createDaemonProxyRequestListener(createDaemonProxy(options))`. diff --git a/packages/remote/src/remote-connection-state.ts b/packages/remote/src/remote-connection-state.ts index e2f07b232b..93b993914d 100644 --- a/packages/remote/src/remote-connection-state.ts +++ b/packages/remote/src/remote-connection-state.ts @@ -284,32 +284,25 @@ export function removeRemoteConnectionState(options: { stateDir: string; session } } +/** + * The stored connection a command runs on, and the flags it contributes. + * + * `session` is the session the caller named (`--session`, `AGENT_DEVICE_SESSION`, or config), or + * `undefined` when it named none. Only a caller that named none falls back to the machine-wide + * active-session pointer, which names whichever connection was saved last, and it is refused a + * leased connection there (#3931). + */ export function resolveRemoteConnectionDefaults(options: { stateDir: string; - session: string; + session: string | undefined; remoteConfig?: string; cwd: string; env: Record; - allowActiveFallback?: boolean; validateRemoteConfigHash?: boolean; }): RemoteConnectionDefaults | null { const validateRemoteConfigHash = options.validateRemoteConfigHash ?? true; - const expectedRemoteConfigPath = options.remoteConfig - ? resolveRemoteConfigPath({ - configPath: options.remoteConfig, - cwd: options.cwd, - env: options.env, - }) - : undefined; - const state = - readRemoteConnectionState(options) ?? - (options.allowActiveFallback - ? readActiveConnectionState({ stateDir: options.stateDir }) - : null); + const state = selectRemoteConnectionState(options); if (!state) return null; - if (expectedRemoteConfigPath && state.remoteConfigPath !== expectedRemoteConfigPath) { - return null; - } if ( validateRemoteConfigHash && hashRemoteConfigFile(state.remoteConfigPath) !== state.remoteConfigHash @@ -345,6 +338,98 @@ export function resolveRemoteConnectionDefaults(options: { }; } +function selectRemoteConnectionState(options: { + stateDir: string; + session: string | undefined; + remoteConfig?: string; + cwd: string; + env: Record; +}): RemoteConnectionState | null { + const state = + options.session === undefined + ? (readRemoteConnectionState({ stateDir: options.stateDir, session: 'default' }) ?? + readActiveConnectionState({ stateDir: options.stateDir })) + : readRemoteConnectionState({ stateDir: options.stateDir, session: options.session }); + if (!state || !matchesRequestedRemoteConfig(state, options)) return null; + requireLeasedConnectionSession(state, options.session); + return state; +} + +function matchesRequestedRemoteConfig( + state: RemoteConnectionState, + options: { remoteConfig?: string; cwd: string; env: Record }, +): boolean { + if (!options.remoteConfig) return true; + const expected = resolveRemoteConfigPath({ + configPath: options.remoteConfig, + cwd: options.cwd, + env: options.env, + }); + return state.remoteConfigPath === expected; +} + +// The daemon accepts run ids of up to 128 characters. +const MAX_RUN_ID_LENGTH = 128; + +/** + * The client and run a session allocates under on a leased remote-config profile. + * + * Every session that names one profile would otherwise carry the profile's client and run, and the + * daemon reuses a lease for a matching run binding, so one session could take over, and release, + * another's lease (#3931). Each session gets its own client and run, derived from its name, so the + * same session finds its own lease again. A profile that names a lease the host issued keeps that + * lease's scope. + */ +export function sessionLeaseScope( + profile: Pick & { + runId: string | undefined; + }, + session: string, +): { runId: string | undefined; clientId: string | undefined } { + if (!isLeasedConnection(profile) || profile.leaseId !== undefined) { + return { runId: profile.runId, clientId: profile.clientId }; + } + const suffix = crypto.createHash('sha256').update(session).digest('hex').slice(0, 16); + return { + runId: + profile.runId && `${profile.runId.slice(0, MAX_RUN_ID_LENGTH - suffix.length - 1)}.${suffix}`, + clientId: + profile.clientId === undefined ? suffix : `${profile.clientId.slice(0, 111)}.${suffix}`, + }; +} + +/** + * Refuses a caller that named no session the use of a leased connection: one whose device a lease + * provider (proxy, a device cloud, or a provider plugin) leases for a single agent. + * + * On a machine several agents share, an unnamed session resolves to whichever connection was saved + * last, which may be another agent's. + */ +export function requireLeasedConnectionSession( + connection: Pick, + session: string | undefined, +): void { + if (session !== undefined || !isLeasedConnection(connection)) return; + throw remoteSessionRequiredError(connection.leaseProvider); +} + +/** The refusal for a command on a leased connection that names no session. */ +export function remoteSessionRequiredError(leaseProvider: string | undefined): AppError { + return new AppError( + 'INVALID_ARGS', + 'A leased remote connection needs its session named: pass --session or set AGENT_DEVICE_SESSION.', + { + reason: 'REMOTE_SESSION_REQUIRED', + ...(leaseProvider === undefined ? {} : { leaseProvider }), + hint: "Without a name, a command could reach another agent's connection on this machine.", + }, + ); +} + +function isLeasedConnection(connection: Pick): boolean { + return connection.leaseProvider !== undefined; +} + export function buildRemoteConnectionRequestMetadata( state: RemoteConnectionRequestMetadata, ): RemoteConnectionRequestMetadata | undefined { diff --git a/src/__tests__/cli-config-limrun.test.ts b/src/__tests__/cli-config-limrun.test.ts index f3bb306d55..b957332d2b 100644 --- a/src/__tests__/cli-config-limrun.test.ts +++ b/src/__tests__/cli-config-limrun.test.ts @@ -33,11 +33,14 @@ test('Limrun apps lists uploaded assets without allocating an instance', async ( }, }); - const result = await runCliCapture(['apps', '--state-dir', stateDir, '--json'], { - cwd: project, - env: { HOME: home }, - defaultResponse: { ok: true, data: { apps: ['Example.apk'] } }, - }); + const result = await runCliCapture( + ['apps', '--session', 'limrun-apps', '--state-dir', stateDir, '--json'], + { + cwd: project, + env: { HOME: home }, + defaultResponse: { ok: true, data: { apps: ['Example.apk'] } }, + }, + ); assert.equal(result.code, null); assert.equal(result.calls.length, 1); @@ -125,7 +128,7 @@ test('Limrun open allocates with the exact uploaded asset name', async () => { }); const result = await runCliCapture( - ['open', 'Example.app.zip', '--state-dir', stateDir, '--json'], + ['open', 'Example.app.zip', '--session', 'limrun-open', '--state-dir', stateDir, '--json'], { cwd: project, env: { HOME: home }, diff --git a/src/__tests__/cli-leased-session.test.ts b/src/__tests__/cli-leased-session.test.ts new file mode 100644 index 0000000000..f0b8f22c43 --- /dev/null +++ b/src/__tests__/cli-leased-session.test.ts @@ -0,0 +1,446 @@ +import { test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { makeTempWorkspace } from '@agent-device/test-kit'; +import { createAgentDeviceClient } from '@agent-device/client'; +import type { DaemonResponse } from '@agent-device/daemon-client'; +import { + hashRemoteConfigFile, + readRemoteConnectionState, + writeRemoteConnectionState, + type RemoteConnectionState, +} from '@agent-device/remote/remote-connection-state'; +import { runCliCapture, type CapturedDaemonRequest } from './cli-capture.ts'; +import { startPublicDaemonRuntime } from './test-utils/public-daemon-runtime.ts'; + +const profileResolutions = vi.hoisted(() => ({ count: 0 })); + +vi.mock('@agent-device/remote/remote-config-core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveRemoteConfigProfile: (...args: Parameters) => { + profileResolutions.count += 1; + return actual.resolveRemoteConfigProfile(...args); + }, + }; +}); + +// Two agents on one machine share one state dir, and with it the machine-wide active-session pointer +// (#3931). Every command on a leased connection names its session explicitly. + +const PROXY_URL = 'http://proxy.example.test/agent-device'; + +function scopeOf(req: CapturedDaemonRequest, key: string): unknown { + return req.meta?.[key as keyof typeof req.meta] ?? req.flags?.[key as keyof typeof req.flags]; +} + +function leaseGateway(req: CapturedDaemonRequest): Promise { + if (req.command === 'lease_allocate' || req.command === 'lease_heartbeat') { + const leaseProvider = scopeOf(req, 'leaseProvider'); + return Promise.resolve({ + ok: true, + data: { + lease: { + leaseId: String(scopeOf(req, 'leaseId') ?? `lease-${String(scopeOf(req, 'runId'))}`), + tenantId: scopeOf(req, 'tenantId') ?? scopeOf(req, 'tenant'), + runId: scopeOf(req, 'runId'), + backend: scopeOf(req, 'leaseBackend'), + leaseProvider, + clientId: scopeOf(req, 'clientId'), + deviceKey: + scopeOf(req, 'deviceKey') ?? + (leaseProvider === 'proxy' ? 'android:mobile:emulator-5554' : undefined), + }, + }, + }); + } + if (req.command === 'open') { + return Promise.resolve({ ok: true, data: { session: req.session, appName: 'Example' } }); + } + if (req.command === 'snapshot') { + return Promise.resolve({ ok: true, data: { nodes: [], truncated: false } }); + } + if (req.command === 'close') return Promise.resolve({ ok: true, data: {} }); + if (req.command === 'lease_release') + return Promise.resolve({ ok: true, data: { released: true } }); + throw new Error(`unexpected daemon command: ${req.command}`); +} + +type Workspace = { root: string; home: string; project: string; stateDir: string }; + +function workspace(): Workspace { + const { root, home, project } = makeTempWorkspace(); + return { root, home, project, stateDir: path.join(root, 'state') }; +} + +async function runAgentCommand( + workspace: Workspace, + argv: string[], + env: Record = {}, +) { + return await runCliCapture([...argv, '--state-dir', workspace.stateDir, '--json'], { + cwd: workspace.project, + env: { HOME: workspace.home, ...env }, + sendToDaemon: leaseGateway, + }); +} + +async function connectProxy(workspace: Workspace, session: string): Promise { + const result = await runAgentCommand(workspace, [ + 'connect', + 'proxy', + '--daemon-base-url', + PROXY_URL, + '--platform', + 'android', + '--session', + session, + ]); + assert.equal(result.code, null, result.stdout); + assert.equal(JSON.parse(result.stdout).data.session, session); +} + +function assertSessionRequired(result: Awaited>): void { + assert.equal(result.code, 1); + assert.deepEqual(result.calls, []); + const error = JSON.parse(result.stdout).error; + assert.equal(error.code, 'INVALID_ARGS'); + assert.equal(error.details.reason, 'REMOTE_SESSION_REQUIRED'); + assert.match(error.message, /--session/); + assert.match(error.message, /AGENT_DEVICE_SESSION/); +} + +function leaseIdOf(call: CapturedDaemonRequest | undefined): unknown { + return call && scopeOf(call, 'leaseId'); +} + +function writeStoredConnection( + workspace: Workspace, + state: Pick & + Partial, +): void { + const remoteConfigPath = path.join(workspace.project, `${state.session}.remote.json`); + fs.writeFileSync(remoteConfigPath, JSON.stringify({})); + const now = new Date().toISOString(); + writeRemoteConnectionState({ + stateDir: workspace.stateDir, + state: { + version: 1, + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + connectedAt: now, + updatedAt: now, + ...state, + }, + }); +} + +// The daemon reuses a lease for a matching run binding (tenant, run, backend, provider, device), so +// two sessions whose allocations would bind the same run end up sharing one lease. +async function leaseIdsOnRealDaemon(allocations: CapturedDaemonRequest[]): Promise { + const daemon = await startPublicDaemonRuntime({ serverMode: 'http' }); + try { + const client = createAgentDeviceClient({ + daemonBaseUrl: daemon.baseUrl, + daemonAuthToken: daemon.runtime.token, + daemonTransport: 'http', + }); + const leaseIds = []; + for (const allocate of allocations) { + const lease = await client.leases.allocate({ + tenant: String(scopeOf(allocate, 'tenantId')), + runId: String(scopeOf(allocate, 'runId')), + leaseBackend: 'android-instance', + leaseProvider: 'proxy', + clientId: String(scopeOf(allocate, 'clientId')), + }); + leaseIds.push(lease.leaseId); + } + return leaseIds; + } finally { + await daemon.close(); + } +} + +function allocationOf(result: Awaited>): CapturedDaemonRequest { + const allocate = result.calls.find((call) => call.command === 'lease_allocate'); + assert.ok(allocate, result.stdout); + return allocate; +} + +test("a second agent on the same proxy never reaches the first agent's connection", async () => { + const shared = workspace(); + await connectProxy(shared, 'agent-a'); + const openA = await runAgentCommand(shared, ['open', 'com.example', '--session', 'agent-a']); + assert.equal(openA.code, null, openA.stdout); + const leaseA = leaseIdOf(openA.calls.find((call) => call.command === 'open')); + + await connectProxy(shared, 'agent-b'); + const openB = await runAgentCommand(shared, ['open', 'com.example', '--session', 'agent-b']); + assert.equal(openB.code, null, openB.stdout); + const [leaseOfA, leaseOfB] = await leaseIdsOnRealDaemon([ + allocationOf(openA), + allocationOf(openB), + ]); + assert.notEqual(leaseOfA, leaseOfB); + + // The pointer now names B's connection; a command that names no session must never reach it. + const batchSteps = '[{"command":"snapshot","input":{}}]'; + for (const command of [ + ['snapshot'], + ['batch', '--steps', batchSteps], + ['close'], + ['disconnect'], + ]) { + assertSessionRequired(await runAgentCommand(shared, command)); + } + + const byFlag = await runAgentCommand(shared, ['snapshot', '--session', 'agent-a']); + assert.equal(byFlag.code, null, byFlag.stdout); + assert.equal(leaseIdOf(byFlag.calls.at(-1)), leaseA); + + const byEnv = await runAgentCommand(shared, ['snapshot'], { AGENT_DEVICE_SESSION: 'agent-a' }); + assert.equal(byEnv.code, null, byEnv.stdout); + assert.equal(leaseIdOf(byEnv.calls.at(-1)), leaseA); + + const close = await runAgentCommand(shared, ['close', '--session', 'agent-a']); + assert.equal(close.code, null, close.stdout); + const closeCall = close.calls.find((call) => call.command === 'close'); + assert.equal(closeCall?.session, 'agent-a'); + assert.equal(leaseIdOf(closeCall), leaseA); + + const disconnect = await runAgentCommand(shared, ['disconnect', '--session', 'agent-a']); + assert.equal(disconnect.code, null, disconnect.stdout); + assert.equal(readRemoteConnectionState({ stateDir: shared.stateDir, session: 'agent-a' }), null); + assert.ok(readRemoteConnectionState({ stateDir: shared.stateDir, session: 'agent-b' })); + + fs.rmSync(shared.root, { recursive: true, force: true }); +}); + +test('a leased connect or remote config is refused without a session before anything is leased', async () => { + const solo = workspace(); + const remoteConfig = path.join(solo.project, 'proxy.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + daemonBaseUrl: PROXY_URL, + tenant: 'proxy', + runId: 'proxy-run', + leaseProvider: 'proxy', + clientId: 'client-1', + platform: 'android', + }), + ); + const batchSteps = '[{"command":"open","input":{"app":"com.example"}}]'; + + for (const command of [ + ['connect', 'proxy', '--daemon-base-url', PROXY_URL, '--platform', 'android'], + ['connect'], + ['connect', '--remote-config', remoteConfig], + ['open', 'com.example', '--remote-config', remoteConfig], + ['batch', '--steps', batchSteps, '--remote-config', remoteConfig], + ['close', '--remote-config', remoteConfig], + ['disconnect', '--remote-config', remoteConfig], + ['connection', 'status', '--remote-config', remoteConfig], + ]) { + assertSessionRequired(await runAgentCommand(solo, command)); + } + assert.equal(fs.existsSync(path.join(solo.stateDir, 'remote-connections')), false); + + fs.rmSync(solo.root, { recursive: true, force: true }); +}); + +test('a session-less command on a device-cloud connection is refused like one on a proxy', async () => { + const shared = workspace(); + for (const session of ['limrun-a', 'limrun-b']) { + writeStoredConnection(shared, { + session, + tenant: 'limrun', + runId: `run-${session}`, + leaseBackend: 'ios-instance', + leaseProvider: 'limrun', + platform: 'ios', + }); + } + + assertSessionRequired(await runAgentCommand(shared, ['snapshot'])); + + const named = await runAgentCommand(shared, ['snapshot', '--session', 'limrun-a']); + assert.equal(named.code, null, named.stdout); + assert.equal(named.calls.at(-1)?.command, 'snapshot'); + assert.equal(scopeOf(named.calls.at(-1)!, 'runId'), 'run-limrun-a'); + + fs.rmSync(shared.root, { recursive: true, force: true }); +}); + +test('a session named by flag, env or config never falls back onto the pointer', async () => { + const shared = workspace(); + await connectProxy(shared, 'agent-a'); + writeStoredConnection(shared, { + session: 'adc-generic', + tenant: 'acme', + runId: 'run-generic', + leaseId: 'lease-generic', + leaseBackend: 'android-instance', + platform: 'android', + }); + fs.writeFileSync( + path.join(shared.project, 'agent-device.json'), + JSON.stringify({ session: 'agent-config' }), + ); + + for (const [argv, env] of [ + [['snapshot', '--session', 'agent-flag'], {}], + [['snapshot'], { AGENT_DEVICE_SESSION: 'agent-env' }], + [['snapshot'], {}], + ] as const) { + const result = await runAgentCommand(shared, [...argv], env); + assert.equal(result.code, null, result.stdout); + assert.deepEqual( + result.calls.map((call) => call.command), + ['snapshot'], + ); + assert.equal(scopeOf(result.calls[0]!, 'tenantId'), undefined); + assert.equal(leaseIdOf(result.calls[0]), undefined); + } + + fs.rmSync(shared.root, { recursive: true, force: true }); +}); + +test('a macos-app remote config with a host-allocated lease needs a session and keeps its scope', async () => { + const solo = workspace(); + const remoteConfig = path.join(solo.project, 'macos-app.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + daemonBaseUrl: PROXY_URL, + tenant: 'host', + runId: 'host-run', + leaseId: 'abc123abc123abc1', + leaseBackend: 'macos-app', + leaseProvider: 'proxy', + clientId: 'client-1', + deviceKey: 'com.example@4242', + platform: 'macos', + }), + ); + assertSessionRequired( + await runAgentCommand(solo, ['open', 'com.example', '--remote-config', remoteConfig]), + ); + + const open = await runAgentCommand(solo, [ + 'open', + 'com.example', + '--session', + 'agent-a', + '--remote-config', + remoteConfig, + ]); + assert.equal(open.code, null, open.stdout); + const lease = open.calls.find((call) => call.command.startsWith('lease_')); + assert.ok(lease, open.stdout); + assert.equal(scopeOf(lease, 'runId'), 'host-run'); + assert.equal(scopeOf(lease, 'leaseId'), 'abc123abc123abc1'); + assert.equal(scopeOf(open.calls.at(-1)!, 'runId'), 'host-run'); + + fs.rmSync(solo.root, { recursive: true, force: true }); +}); + +function writeLeasedProfile(workspace: Workspace): string { + const remoteConfig = path.join(workspace.project, 'shared.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + daemonBaseUrl: PROXY_URL, + tenant: 'proxy', + runId: 'proxy-run', + leaseProvider: 'proxy', + clientId: 'client-1', + platform: 'android', + }), + ); + return remoteConfig; +} + +test('sessions sharing one leased profile get leases of their own, and one session keeps its own', async () => { + const shared = workspace(); + const remoteConfig = writeLeasedProfile(shared); + const openA = await runAgentCommand(shared, [ + 'open', + 'com.example', + '--session', + 'agent-a', + '--remote-config', + remoteConfig, + ]); + assert.equal(openA.code, null, openA.stdout); + const connectB = await runAgentCommand(shared, [ + 'connect', + '--remote-config', + remoteConfig, + '--session', + 'agent-b', + ]); + assert.equal(connectB.code, null, connectB.stdout); + const openB = await runAgentCommand(shared, ['open', 'com.example', '--session', 'agent-b']); + assert.equal(openB.code, null, openB.stdout); + + // The same session in a fresh state dir: its client and run come out the same. + const fresh = { ...shared, stateDir: path.join(shared.root, 'fresh-state') }; + const openAAgain = await runAgentCommand(fresh, [ + 'open', + 'com.example', + '--session', + 'agent-a', + '--remote-config', + remoteConfig, + ]); + assert.equal(openAAgain.code, null, openAAgain.stdout); + + const [leaseA, leaseB, leaseAAgain] = await leaseIdsOnRealDaemon([ + allocationOf(openA), + allocationOf(openB), + allocationOf(openAAgain), + ]); + assert.notEqual(leaseA, leaseB); + assert.equal(leaseAAgain, leaseA); + + fs.rmSync(shared.root, { recursive: true, force: true }); +}); + +test('a refused leased remote config is resolved only once, by the parser', async () => { + const solo = workspace(); + const remoteConfig = writeLeasedProfile(solo); + profileResolutions.count = 0; + + assertSessionRequired( + await runAgentCommand(solo, ['open', 'com.example', '--remote-config', remoteConfig]), + ); + assert.ok(profileResolutions.count <= 1, `resolved ${profileResolutions.count} times`); + + fs.rmSync(solo.root, { recursive: true, force: true }); +}); + +test('an unnamed disconnect on a non-leased remote config still uses the pointer', async () => { + const solo = workspace(); + writeStoredConnection(solo, { + session: 'adc-generic', + tenant: 'acme', + runId: 'run-generic', + leaseBackend: 'android-instance', + platform: 'android', + }); + const remoteConfig = path.join(solo.project, 'adc-generic.remote.json'); + + const disconnect = await runAgentCommand(solo, ['disconnect', '--remote-config', remoteConfig]); + assert.equal(disconnect.code, null, disconnect.stdout); + assert.equal(JSON.parse(disconnect.stdout).data.session, 'adc-generic'); + assert.equal( + readRemoteConnectionState({ stateDir: solo.stateDir, session: 'adc-generic' }), + null, + ); + + fs.rmSync(solo.root, { recursive: true, force: true }); +}); diff --git a/src/__tests__/cli-run-transport.test.ts b/src/__tests__/cli-run-transport.test.ts index 06c50f618e..37e229b3e2 100644 --- a/src/__tests__/cli-run-transport.test.ts +++ b/src/__tests__/cli-run-transport.test.ts @@ -69,7 +69,7 @@ test('a CLI run builds one transport, shared by the remote-materialization clien try { // A successful run returns without exiting; a failure that exits or throws fails the test. await runCli( - ['open', '--state-dir', stateDir, '--json'], + ['open', '--session', 'transport-run', '--state-dir', stateDir, '--json'], { sendToDaemon: vi.fn(), transportForHost }, host, ).catch((error: unknown) => { diff --git a/src/__tests__/cloud-connect-profile-fields.test.ts b/src/__tests__/cloud-connect-profile-fields.test.ts index 26992d84ff..9d99f07da9 100644 --- a/src/__tests__/cloud-connect-profile-fields.test.ts +++ b/src/__tests__/cloud-connect-profile-fields.test.ts @@ -13,6 +13,8 @@ test('connect limrun refuses profile fields Limrun does not read', async () => { [ 'connect', 'limrun', + '--session', + 'ci', '--platform', 'ios', '--provider-os-version', diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index ff833a8760..3f884a0638 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -146,13 +146,13 @@ test('cloud connect uses neutral public service wording in human and JSON output }; mockCloudConnectionProfile(profile); - const human = await runCliCapture(['connect'], { + const human = await runCliCapture(['connect', '--session=ci'], { stateDirPrefix: 'agent-device-connect-cloud-wording-human-', }); assert.match(human.stdout, /^Connected successfully with the configured cloud service\./); mockCloudConnectionProfile(profile); - const json = await runCliCapture(['connect', '--json'], { + const json = await runCliCapture(['connect', '--session=ci', '--json'], { stateDirPrefix: 'agent-device-connect-cloud-wording-json-', }); const data = (JSON.parse(json.stdout) as { data: { verification: { service: string } } }).data; @@ -320,7 +320,7 @@ test('connect limrun requires LIMRUN_API_KEY or access to an existing instance', }); test('connect limrun rejects unsupported public device and backend selections', async () => { - const environment = { LIMRUN_API_KEY: 'lim_test_key' }; + const environment = { LIMRUN_API_KEY: 'lim_test_key', AGENT_DEVICE_SESSION: 'ci' }; const device = await runCliCapture( ['connect', 'limrun', '--platform', 'ios', '--device', 'local-ios', '--json'], { env: environment, stateDirPrefix: 'agent-device-connect-limrun-scope-' }, @@ -615,7 +615,7 @@ test('connect aws-device-farm generates local provider profile from flags', asyn }); test('connect output makes verified configuration, deferred device allocation, and app recovery explicit', async () => { - const environment = { LIMRUN_API_KEY: 'lim_test_key' }; + const environment = { LIMRUN_API_KEY: 'lim_test_key', AGENT_DEVICE_SESSION: 'ci' }; const result = await runCliCapture(['connect', 'limrun', '--platform', 'android'], { env: environment, stateDirPrefix: 'agent-device-connect-limrun-output-', @@ -645,6 +645,7 @@ test('connect JSON exposes verification, device, app, live-session, and next-ste [ 'connect', 'aws-device-farm', + '--session=ci', '--platform', 'ios', '--aws-project-arn', @@ -677,6 +678,7 @@ test('connect JSON preserves BrowserStack workflow notes from human output', asy [ 'connect', 'browserstack', + '--session=ci', '--platform', 'android', '--device', @@ -729,8 +731,11 @@ test('every command BrowserStack connect emits resolves back to the connection t }; try { - const json = await runCliCapture([...browserstackArgs, '--json'], captureOptions); - const human = await runCliCapture(browserstackArgs, captureOptions); + const json = await runCliCapture( + [...browserstackArgs, '--session=bs-1', '--json'], + captureOptions, + ); + const human = await runCliCapture([...browserstackArgs, '--session=bs-2'], captureOptions); assert.equal(json.code, null); assert.equal(human.code, null); @@ -745,8 +750,8 @@ test('every command BrowserStack connect emits resolves back to the connection t } assert.deepEqual([...readEmittedSessions([...output.nextSteps, ...output.notes])], [session]); - // The human run is a second unscoped connect, so it mints its own identity; - // its commands must name THAT one, not the JSON run's. + // The human run is a second connect under its own session; its commands must name THAT one, + // not the JSON run's. const humanSessions = [...readEmittedSessions([human.stdout])]; assert.equal(humanSessions.length, 1, human.stdout); const humanSession = humanSessions[0] ?? ''; diff --git a/src/__tests__/cloud-connect-testmu.test.ts b/src/__tests__/cloud-connect-testmu.test.ts index 637fa11933..ddb3b18311 100644 --- a/src/__tests__/cloud-connect-testmu.test.ts +++ b/src/__tests__/cloud-connect-testmu.test.ts @@ -337,7 +337,17 @@ test('providers other than TestMu refuse --provider-device-type before saving a test('connect limrun refuses the device type', async () => { const result = await runCliCapture( - ['connect', 'limrun', '--platform', 'ios', '--provider-device-type', 'real', '--json'], + [ + 'connect', + 'limrun', + '--session', + 'ci', + '--platform', + 'ios', + '--provider-device-type', + 'real', + '--json', + ], { env: { LIMRUN_API_KEY: 'lim_test_key' }, stateDirPrefix: 'agent-device-connect-limrun-device-type-', diff --git a/src/__tests__/lease-provider-profile-fields.test.ts b/src/__tests__/lease-provider-profile-fields.test.ts index 04867c130c..7733211aef 100644 --- a/src/__tests__/lease-provider-profile-fields.test.ts +++ b/src/__tests__/lease-provider-profile-fields.test.ts @@ -87,15 +87,18 @@ test('a remote-config profile cannot carry a field Limrun refuses past lease all }); try { - const result = await runCliCapture(['open', '--state-dir', stateDir, '--json'], { - cwd: project, - env: { - HOME: home, - AGENT_DEVICE_DAEMON_BASE_URL: daemon.baseUrl, - AGENT_DEVICE_DAEMON_AUTH_TOKEN: daemon.runtime.token, + const result = await runCliCapture( + ['open', '--session', 'limrun-profile', '--state-dir', stateDir, '--json'], + { + cwd: project, + env: { + HOME: home, + AGENT_DEVICE_DAEMON_BASE_URL: daemon.baseUrl, + AGENT_DEVICE_DAEMON_AUTH_TOKEN: daemon.runtime.token, + }, + useRealDaemonClient: true, }, - useRealDaemonClient: true, - }); + ); assert.equal(result.code, 1); const payload = JSON.parse(result.stdout); diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 034982b929..b57ab6903f 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -2046,12 +2046,7 @@ test('disconnect after connect-only cleanup stays local when no session resource await withCapturedStdoutAsync(async () => { await disconnectCommand({ positionals: [], - flags: { - json: false, - help: false, - version: false, - stateDir, - }, + flags: { json: false, help: false, version: false, stateDir, session: 'adc-pending' }, client: createTestClient({ closeSession: async () => { closeCalls += 1; @@ -2215,10 +2210,10 @@ test('disconnect releases proxy lease with provider client and device metadata', help: false, version: false, stateDir, + session: 'adc-proxy', shutdown: true, - // Not persisted on connection state (ADR 0007): the caller supplies - // it per-command via flag/env/CLI-session, mirroring how the CLI - // dispatcher resolves it before invoking this handler. + // Not persisted on connection state (ADR 0007): the caller supplies it per-command via + // flag/env/CLI-session, mirroring how the CLI dispatcher resolves it before this handler. daemonAuthToken: 'test-not-a-real-token', }, client: createTestClient({ release: release.stub }), diff --git a/src/cli.ts b/src/cli.ts index fba593888a..b81c3a0e4a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,6 +48,7 @@ import { applyDefaultPlatformBinding, resolveBindingSettings } from './cli/sessi import { resolveCliOptions } from './cli/resolve-cli-options.ts'; import { maybeRunUpgradeNotifier } from './cli/update-check.ts'; import { + remoteSessionRequiredError, resolveRemoteConnectionDefaults, type RemoteConnectionRequestMetadata, } from '@agent-device/remote/remote-connection-state'; @@ -372,6 +373,12 @@ async function resolveRunContextOrExit( configuredPlatform: parsed.flags.platform, configuredSession: parsed.flags.session, }); + // A leased remote config, or a connect without one (it always leases through a provider), needs + // an explicit session before anything is resolved or leased (#3931). + const leasedConnect = base.command === 'connect' && !flags.remoteConfig; + if (flags.session === undefined && (parsed.remoteConfigLeaseProvider || leasedConnect)) { + throw remoteSessionRequiredError(parsed.remoteConfigLeaseProvider); + } const daemonPaths = resolveDaemonPaths(flags.stateDir, { env: base.host.env, cwd: base.host.cwd, @@ -380,11 +387,9 @@ async function resolveRunContextOrExit( const connectionDefaults = resolveActiveConnectionDefaults({ host: base.host, command: base.command, - explicitFlagKeys, stateDir: daemonPaths.baseDir, - session: sessionName, + session: flags.session, remoteConfig: flags.remoteConfig, - hasResolvedSession: flags.session !== undefined, }); const effectiveFlags = connectionDefaults ? mergeConnectionFlags(flags, connectionDefaults.flags, explicitFlagKeys) @@ -767,11 +772,9 @@ function isDaemonStartupFailure(error: AppError): boolean { function resolveActiveConnectionDefaults(options: { host: HostEnvironment; command: string; - explicitFlagKeys: Set; stateDir: string; - session: string; + session: string | undefined; remoteConfig?: string; - hasResolvedSession: boolean; }): { flags: Partial; runtime?: SessionRuntimeHints; @@ -792,9 +795,6 @@ function resolveActiveConnectionDefaults(options: { remoteConfig: options.remoteConfig, cwd: options.host.cwd, env: options.host.env, - allowActiveFallback: - !options.explicitFlagKeys.has('session') && - (!options.remoteConfig || options.command === 'disconnect' || !options.hasResolvedSession), validateRemoteConfigHash: options.command !== 'disconnect', }); return defaults; diff --git a/src/cli/commands/connection-plugin-guidance.test.ts b/src/cli/commands/connection-plugin-guidance.test.ts index 56ab9f67a9..1aac8399f3 100644 --- a/src/cli/commands/connection-plugin-guidance.test.ts +++ b/src/cli/commands/connection-plugin-guidance.test.ts @@ -28,7 +28,7 @@ test('connect names a missing provider plugin and returns installation guidance test('connect prints installation guidance without JSON', async () => { const { home, env } = pluginHome(); - const result = await runCliCapture(['connect', 'testmu'], { + const result = await runCliCapture(['connect', 'testmu', '--session', 'ci'], { cwd: home, env, stateDirPrefix: 'connect-plugin-text-', @@ -41,7 +41,7 @@ test('connect prints installation guidance without JSON', async () => { test('connect keeps an unknown provider separate from a known missing plugin', async () => { const { home, env } = pluginHome(); - const result = await runCliCapture(['connect', 'testmuu', '--json'], { + const result = await runCliCapture(['connect', 'testmuu', '--session', 'ci', '--json'], { cwd: home, env, stateDirPrefix: 'connect-unknown-provider-', @@ -62,7 +62,7 @@ test('connect reports a failed selected plugin even when its error text describe '@agent-device/testmu', 'throw new Error("TestMu AI requires the @agent-device/testmu plugin.");', ); - const result = await runCliCapture(['connect', 'testmu', '--json'], { + const result = await runCliCapture(['connect', 'testmu', '--session', 'ci', '--json'], { cwd: home, env, stateDirPrefix: 'connect-broken-plugin-', @@ -80,7 +80,7 @@ test('connect reports a failed selected plugin even when its error text describe test('connect does not call a selected plugin missing when it has no connection callbacks', async () => { const { home, env } = pluginHome(); selectV2Plugin(home, '@agent-device/testmu', v2RegistrationSource('testmu')); - const result = await runCliCapture(['connect', 'testmu', '--json'], { + const result = await runCliCapture(['connect', 'testmu', '--session', 'ci', '--json'], { cwd: home, env, stateDirPrefix: 'connect-plugin-no-connection-', @@ -93,7 +93,7 @@ test('connect does not call a selected plugin missing when it has no connection test('connect reports an unreadable plugin selection instead of prescribing a new installation', async () => { const { home, env } = pluginHome(); fs.writeFileSync(path.join(home, 'config.json'), JSON.stringify({ plugins: [] })); - const result = await runCliCapture(['connect', 'testmu', '--json'], { + const result = await runCliCapture(['connect', 'testmu', '--session', 'ci', '--json'], { cwd: home, env, stateDirPrefix: 'connect-plugin-config-', diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index ddc2de6f13..0c687dc05c 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -6,6 +6,7 @@ import { buildRemoteConnectionRequestMetadata, hashRemoteConfigFile, readRemoteConnectionState, + sessionLeaseScope, writeRemoteConnectionState, type RemoteConnectionRequestMetadata, type RemoteConnectionState, @@ -191,15 +192,25 @@ function providerOf( function createRemoteConnectionStateFromFlags( flags: CliFlags, remoteConfigPath: string, - profile: Pick = {}, + profile: Partial< + Pick + > = {}, ): RemoteConnectionState { + const session = flags.session ?? 'default'; + const scope = sessionLeaseScope( + { ...profile, leaseId: flags.leaseId, runId: profile.runId }, + session, + ); + // A run the caller passed with --run-id is theirs to share; only the profile's run is per session. + const runId = flags.runId === profile.runId ? scope.runId : flags.runId; + const clientId = scope.clientId; if (!flags.tenant) { throw new AppError( 'INVALID_ARGS', 'remote command requires tenant in remote config or via --tenant .', ); } - if (!flags.runId) { + if (!runId) { throw new AppError( 'INVALID_ARGS', 'remote command requires runId in remote config or via --run-id .', @@ -217,16 +228,16 @@ function createRemoteConnectionStateFromFlags( const now = new Date().toISOString(); return { version: 1, - session: flags.session ?? 'default', + session, remoteConfigPath, remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), daemon: buildRemoteConnectionDaemonState(flags), tenant: flags.tenant, - runId: flags.runId, + runId, leaseId: flags.leaseId, leaseBackend: flags.leaseBackend ?? resolveRequestedLeaseBackend(flags), leaseProvider: profile.leaseProvider, - clientId: profile.clientId, + clientId, deviceKey: profile.deviceKey, platform: flags.platform, target: flags.target, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index b83152ef6a..def82c84cb 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -4,6 +4,8 @@ import { resolveDaemonPaths } from '@agent-device/daemon-contracts/daemon-resolu import { resolveRemoteConfigProfile } from '@agent-device/remote/remote-config'; import { readActiveConnectionState, + requireLeasedConnectionSession, + sessionLeaseScope, buildRemoteConnectionRequestMetadata, mergeRemoteConnectionRequestMetadata, readRemoteConnectionState, @@ -72,8 +74,11 @@ export const connectCommand: ClientCommandHandler = async ({ cwd: host.cwd, env: host.env, }); - const connectFlags = resolved.flags; - const connectionMetadata = readRemoteConfigConnectionMetadata(resolved.remoteConfigPath, host); + const { flags: connectFlags, connection: connectionMetadata } = scopeConnectProfile( + flags, + resolved, + host, + ); const scope = readRequiredConnectScope(connectFlags, connectionMetadata); const context = resolveConnectContext({ stateDir, @@ -297,16 +302,35 @@ async function cleanupForcedPreviousConnection( }); } -function readRemoteConfigConnectionMetadata( - remoteConfigPath: string, +/** + * The flags and lease metadata `connect` binds a profile to. A leased profile the caller supplies is + * shared by every session that names it, so each session connects under a client and run of its own + * (#3931); a provider-generated profile already derives both from the session. + */ +function scopeConnectProfile( + flags: CliFlags, + resolved: { flags: CliFlags; remoteConfigPath: string }, host: HostEnvironment, -): RemoteConnectionRequestMetadata | undefined { +): { flags: CliFlags; connection: RemoteConnectionRequestMetadata | undefined } { const profile = resolveRemoteConfigProfile({ - configPath: remoteConfigPath, + configPath: resolved.remoteConfigPath, cwd: host.cwd, env: host.env, }).profile; - return buildRemoteConnectionRequestMetadata(profile); + if (!flags.remoteConfig || flags.session === undefined) { + return { flags: resolved.flags, connection: buildRemoteConnectionRequestMetadata(profile) }; + } + const scope = sessionLeaseScope( + { ...profile, leaseId: resolved.flags.leaseId, runId: profile.runId }, + flags.session, + ); + return { + flags: + resolved.flags.runId === profile.runId + ? { ...resolved.flags, runId: scope.runId } + : resolved.flags, + connection: buildRemoteConnectionRequestMetadata({ ...profile, clientId: scope.clientId }), + }; } export const disconnectCommand: ClientCommandHandler = async ({ flags, client, host }) => { @@ -441,13 +465,11 @@ function readRequestedConnectionState( cwd: host.cwd, env: host.env, }).baseDir; - return { - session, - stateDir, - state: - readRemoteConnectionState({ stateDir, session }) ?? - (flags.session ? null : readActiveConnectionState({ stateDir })), - }; + const state = + readRemoteConnectionState({ stateDir, session }) ?? + (flags.session ? null : readActiveConnectionState({ stateDir })); + if (state) requireLeasedConnectionSession(state, flags.session); + return { session, stateDir, state }; } async function writeNoRemoteConnectionOutput(flags: CliFlags, session: string): Promise { diff --git a/src/cli/remote-config-flags.ts b/src/cli/remote-config-flags.ts index 6127369bc9..84f2a17f9f 100644 --- a/src/cli/remote-config-flags.ts +++ b/src/cli/remote-config-flags.ts @@ -26,9 +26,9 @@ export function resolveRemoteConfigDefaults(options: { remoteConfig?: string; cwd: string; env: Record; -}): Partial { +}): { flags: Partial; leaseProvider?: string } { if (!options.remoteConfig) { - return {}; + return { flags: {} }; } const resolved = resolveRemoteConfigProfile({ @@ -37,7 +37,7 @@ export function resolveRemoteConfigDefaults(options: { env: options.env, }); return { - ...profileToCliFlags(resolved.profile), - remoteConfig: options.remoteConfig, + flags: { ...profileToCliFlags(resolved.profile), remoteConfig: options.remoteConfig }, + leaseProvider: resolved.profile.leaseProvider, }; } diff --git a/src/cli/resolve-cli-options.ts b/src/cli/resolve-cli-options.ts index c55411c2b6..6a360f2fbf 100644 --- a/src/cli/resolve-cli-options.ts +++ b/src/cli/resolve-cli-options.ts @@ -14,13 +14,13 @@ export function resolveCliOptions( ) { const rawParsed = parseRawArgs(argv); const { env, cwd } = options; - const remoteConfigDefaults = shouldApplyRemoteConfigDefaults(rawParsed.command) + const remoteConfig = shouldApplyRemoteConfigDefaults(rawParsed.command) ? resolveRemoteConfigDefaults({ remoteConfig: rawParsed.flags.remoteConfig, cwd, env, }) - : {}; + : { flags: {} }; const defaultFlags = mergeDefinedFlags( resolveConfigBackedFlagDefaults({ command: rawParsed.command, @@ -28,13 +28,18 @@ export function resolveCliOptions( cliFlags: rawParsed.flags as CliFlags, env, }), - remoteConfigDefaults, + remoteConfig.flags, ); const finalized = finalizeParsedArgs(rawParsed, { strictFlags: options?.strictFlags, defaultFlags, }); - return { ...finalized, providedFlags: rawParsed.providedFlags }; + // The flags drop the profile's lease provider, which decides whether a session is required (#3931). + return { + ...finalized, + providedFlags: rawParsed.providedFlags, + remoteConfigLeaseProvider: remoteConfig.leaseProvider, + }; } function shouldApplyRemoteConfigDefaults(command: string | null): boolean { diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index ba03014ab8..ea9fb0a279 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -557,6 +557,7 @@ Direct proxy flow for a remote Mac/simulator: agent-device proxy --port 4310 cloudflared tunnel --url http://127.0.0.1:4310 On the remote client: + export AGENT_DEVICE_SESSION=checkout-agent agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device --daemon-auth-token agent-device devices --platform ios agent-device open Maps --platform ios --device "iPhone 17 Pro" @@ -565,6 +566,8 @@ Direct proxy flow for a remote Mac/simulator: agent-device close agent-device disconnect +Leased sessions: a connection from connect proxy, connect (cloud), another connect provider, or a remote config that sets leaseProvider leases its device for one agent. Every command on it, from connect to disconnect, names its session with --session , AGENT_DEVICE_SESSION, or a config session. Without one, it fails with INVALID_ARGS, reason REMOTE_SESSION_REQUIRED, before anything is leased, and never falls back to the connection another agent on the machine saved last. Set AGENT_DEVICE_SESSION once per agent or CI job before connect. + Proxy lease by device spec: Without --udid or --serial, open, install, reinstall, and uninstall through connect proxy send --platform, --target, and --device on the lease allocate, and the backend in front of the daemon picks the device. --os , --image-tag , and --physical add a device spec to that request. A non-blank osVersion or imageTag and physical: true name a spec key, sent as given; the backend validates its value (for example, Android --os is an API level). A blank string and physical: false name nothing, from the CLI, the SDK, or MCP. A value of another type fails with INVALID_ARGS, reason device-spec-invalid. connect proxy stores the spec keys it names as defaults like --platform and --device. agent-device open MyApp --platform ios --device "iPhone 17 Pro" --os ">=26" @@ -579,6 +582,7 @@ Human takeover of a leased remote device: An HTTP-mode daemon also accepts authenticated GET/PUT/DELETE requests at /admin/human-control/holds on its loopback listener. Host administrators supply the exact lease backend/provider/device key and use the local daemon token, not a tenant credential. This host-admin route is intentionally not forwarded by agent-device proxy. Holds do not survive daemon restart; re-establish them after reconnecting. The same listener and token serve GET/PUT/DELETE /admin/leases, where a host allocates a macos-app lease confining a client to one app ( or @); tenants cannot allocate one. Cloud profile flow: + export AGENT_DEVICE_SESSION=checkout-ios agent-device connect agent-device open com.example.app agent-device snapshot @@ -586,7 +590,7 @@ Cloud profile flow: TestMu AI virtual-device flow (emulators and simulators): agent-device plugins add @agent-device/testmu - export LT_USERNAME=... LT_ACCESS_KEY=... + export LT_USERNAME=... LT_ACCESS_KEY=... AGENT_DEVICE_SESSION=checkout-ios agent-device connect testmu --platform ios --device "iPhone 16" --provider-os-version 18.0 --provider-app lt://APP-id agent-device open com.example.app agent-device snapshot -i @@ -595,11 +599,11 @@ TestMu AI virtual-device flow (emulators and simulators): agent-device disconnect TestMu AI real-device flow: - agent-device connect testmu --provider-device-type real --platform ios --device "iPhone 16" --provider-os-version 18 --provider-app ./MyApp.ipa + agent-device connect testmu --provider-device-type real --platform ios --device "iPhone 16" --provider-os-version 18 --provider-app ./MyApp.ipa --session checkout-real Real iOS devices are listed by major OS version (18, not 18.0) and install a signed .ipa. Real and virtual devices have separate upload APIs, so pass an lt:// id uploaded for the pool you connect to. BrowserStack hosted-device flow: - BROWSERSTACK_USERNAME=... BROWSERSTACK_ACCESS_KEY=... + export BROWSERSTACK_USERNAME=... BROWSERSTACK_ACCESS_KEY=... AGENT_DEVICE_SESSION=checkout-android agent-device connect browserstack --platform android --device "Google Pixel 8" --provider-os-version 14.0 --provider-app bs://app-id agent-device open com.example.app agent-device snapshot -i @@ -608,7 +612,7 @@ BrowserStack hosted-device flow: agent-device disconnect AWS Device Farm hosted-device flow: - AWS_REGION=us-west-2 AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... + export AWS_REGION=us-west-2 AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SESSION_TOKEN=... AGENT_DEVICE_SESSION=checkout-android agent-device connect aws-device-farm --platform android --aws-project-arn --aws-device-arn --aws-app-arn agent-device open com.example.app agent-device snapshot -i @@ -618,7 +622,7 @@ AWS Device Farm hosted-device flow: AWS Device Farm currently supports Android and iOS WebDriver sessions only; Vega OS and Vega Fire TV ARNs are not routed. Limrun direct-device flow: - LIMRUN_API_KEY=... + export LIMRUN_API_KEY=... AGENT_DEVICE_SESSION=checkout-android agent-device connect limrun --platform android Limrun creates remote iOS simulators and Android emulators only. Do not pass local device selectors such as --udid, --serial, or --device. @@ -633,7 +637,7 @@ Limrun direct-device flow: Revyl cloud-device flow (plugin, install separately): agent-device plugins add @agent-device/revyl - export REVYL_API_KEY=... + export REVYL_API_KEY=... AGENT_DEVICE_SESSION=checkout-ios agent-device connect revyl --platform ios --provider-app https://downloads.example.test/app.zip agent-device open com.example.app agent-device snapshot -i diff --git a/test/integration/smoke-provider-cli-disconnect.test.ts b/test/integration/smoke-provider-cli-disconnect.test.ts index de8e3daeca..7b666b1bbc 100644 --- a/test/integration/smoke-provider-cli-disconnect.test.ts +++ b/test/integration/smoke-provider-cli-disconnect.test.ts @@ -7,11 +7,13 @@ import { pathToFileURL } from 'node:url'; import { formatResultDebug, runBuiltCliJson } from './cli-json.ts'; import './support/provider-disconnect-fetch.mjs'; -test('built CLI provider flow closes active generated session before disconnect cleanup', async (t) => { +test('built CLI provider flow closes its named session before disconnect cleanup', async (t) => { const fixture = createProviderDaemonFixture(t); const env = createProviderEnv(fixture); + await assertUnnamedRefused(fixture, env, browserStackConnectArgs(fixture)); const activeSession = await connectBrowserStackProvider(fixture, env); - await openProviderApp(fixture, env); + await assertUnnamedRefused(fixture, env, ['open', 'Demo']); + await openProviderApp(fixture, env, activeSession); await disconnectProviderSession(fixture, env, activeSession); assertProviderDisconnectRpc(readRpcRequests(fixture), activeSession); await assertNoActiveConnection(fixture, env); @@ -71,7 +73,22 @@ async function connectBrowserStackProvider( fixture: ProviderDaemonFixture, env: NodeJS.ProcessEnv, ): Promise { - const connectArgs = [ + const connectArgs = [...browserStackConnectArgs(fixture), '--session', 'bs-smoke']; + const connectResult = await runBuiltCliJson(connectArgs, env); + + assert.equal(connectResult.status, 0, formatResultDebug('connect', connectArgs, connectResult)); + assert.equal( + connectResult.json?.success, + true, + formatResultDebug('connect', connectArgs, connectResult), + ); + const activeSession = connectResult.json?.data?.session; + assert.equal(activeSession, 'bs-smoke'); + return activeSession; +} + +function browserStackConnectArgs(fixture: ProviderDaemonFixture): string[] { + return [ 'connect', 'browserstack', '--platform', @@ -90,24 +107,40 @@ async function connectBrowserStackProvider( fixture.stateDir, '--json', ]; - const connectResult = await runBuiltCliJson(connectArgs, env); +} - assert.equal(connectResult.status, 0, formatResultDebug('connect', connectArgs, connectResult)); +// A leased provider flow is refused at every step that names no session. +async function assertUnnamedRefused( + fixture: ProviderDaemonFixture, + env: NodeJS.ProcessEnv, + command: string[], +): Promise { + const args = command.includes('--state-dir') + ? command + : [...command, '--state-dir', fixture.stateDir, '--json']; + const result = await runBuiltCliJson(args, env); + assert.equal(result.status, 1, formatResultDebug(command[0] ?? '', args, result)); assert.equal( - connectResult.json?.success, - true, - formatResultDebug('connect', connectArgs, connectResult), + result.json?.error?.details?.reason, + 'REMOTE_SESSION_REQUIRED', + formatResultDebug(command[0] ?? '', args, result), ); - const activeSession = connectResult.json?.data?.session; - assert.match(activeSession, /^adc-/); - return activeSession; } async function openProviderApp( fixture: ProviderDaemonFixture, env: NodeJS.ProcessEnv, + session: string, ): Promise { - const openArgs = ['open', 'Demo', '--state-dir', fixture.stateDir, '--json']; + const openArgs = [ + 'open', + 'Demo', + '--session', + session, + '--state-dir', + fixture.stateDir, + '--json', + ]; const openResult = await runBuiltCliJson(openArgs, env); assert.equal(openResult.status, 0, formatResultDebug('open', openArgs, openResult)); assert.equal(openResult.json?.success, true, formatResultDebug('open', openArgs, openResult)); @@ -118,7 +151,14 @@ async function disconnectProviderSession( env: NodeJS.ProcessEnv, activeSession: string, ): Promise { - const disconnectArgs = ['disconnect', '--state-dir', fixture.stateDir, '--json']; + const disconnectArgs = [ + 'disconnect', + '--session', + activeSession, + '--state-dir', + fixture.stateDir, + '--json', + ]; const disconnectResult = await runBuiltCliJson(disconnectArgs, env); assert.equal( disconnectResult.status, diff --git a/website/docs/docs/aws-device-farm.md b/website/docs/docs/aws-device-farm.md index 01942e65ed..36c6faa17c 100644 --- a/website/docs/docs/aws-device-farm.md +++ b/website/docs/docs/aws-device-farm.md @@ -31,6 +31,7 @@ export AWS_REGION=us-west-2 Connect with the Device Farm project, the device, and an optional app upload: ```bash +export AGENT_DEVICE_SESSION=checkout-android agent-device connect aws-device-farm \ --platform android \ --aws-project-arn arn:aws:devicefarm:us-west-2::project: \ @@ -52,13 +53,14 @@ agent-device also accepts `AGENT_DEVICE_AWS_DEVICE_FARM_PROJECT_ARN`, `AGENT_DEV ## Run a session from the CLI -Every `connect` without `--session` creates a new connection, and the printed next steps include its generated `--session`. Keep that flag on every command when several processes or CI jobs share a host; the active connection is safe only for one sequential workflow. To replace a named connection, run `connect ... --session --force`. `--force` without `--session` creates a new connection and leaves existing sessions untouched. +Name the session once with `AGENT_DEVICE_SESSION`, as below, or pass `--session ` to every command, including `connect`. A command that names no session fails with `REMOTE_SESSION_REQUIRED`. To replace a named connection, run `connect ... --session --force`. ```bash export AWS_REGION=us-west-2 export AWS_ACCESS_KEY_ID=... export AWS_SECRET_ACCESS_KEY=... export AWS_SESSION_TOKEN=... +export AGENT_DEVICE_SESSION=checkout-android agent-device connect aws-device-farm \ --platform android \ diff --git a/website/docs/docs/browserstack.md b/website/docs/docs/browserstack.md index bdfb5795ec..4e490741d8 100644 --- a/website/docs/docs/browserstack.md +++ b/website/docs/docs/browserstack.md @@ -17,6 +17,7 @@ export BROWSERSTACK_ACCESS_KEY=... Connect with the platform, exact device and OS version, and app to test: ```bash +export AGENT_DEVICE_SESSION=checkout-android agent-device connect browserstack \ --platform android \ --device "Google Pixel 8" \ @@ -64,6 +65,7 @@ agent-device sends these values to BrowserStack as `bstack:options` capabilities ```bash export BROWSERSTACK_USERNAME=... export BROWSERSTACK_ACCESS_KEY=... +export AGENT_DEVICE_SESSION=checkout-android agent-device connect browserstack \ --platform android \ diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 525e13870a..84de937bd5 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1118,9 +1118,10 @@ agent-device trace stop session.trace ## Remote Metro workflow -When the cloud control plane owns the connection profile, `connect` discovers it: +When the cloud control plane owns the connection profile, `connect` discovers it. Name the session once, because every command on a cloud connection must name it: ```bash +export AGENT_DEVICE_SESSION=checkout-ios agent-device connect agent-device open com.example.myapp --relaunch agent-device snapshot -i @@ -1163,7 +1164,7 @@ agent-device disconnect --remote-config ./agent-device.remote.json - `connect` fetches the cloud connection profile; `connect --remote-config ` uses a local profile with the host, tenant/run, and optional session, platform, lease backend, and Metro settings. Both save connection state and defer the device lease and Metro preparation until a command needs them (including a `batch` step that opens an app). Direct-provider profiles need their provider credentials when `connect` runs. Cloud profiles must be a JSON object at `connection.remoteConfigProfile`; the older `connection.remoteConfig` string is rejected. - Cloud auth: `connect` refreshes a stored CLI session into a short-lived `adc_agent_...` token. Without a session, interactive shells start login; CI and non-interactive shells fail with API-token setup instructions. `--no-login` disables implicit login. Use `adc_live_...` service tokens for CI. Manage the CLI session with `agent-device auth status`, `auth login`, and `auth logout`; login never stores an `adc_live_...` token. - `AGENT_DEVICE_CLOUD_BASE_URL` is the bridge/control-plane API origin, not necessarily the dashboard; its `/api-keys` route may redirect to the dashboard. -- `connect` without `--session` always creates a fresh remote session and prints the generated `--session`. Concurrent processes must pass that value on every command; the active-connection fallback is only for one sequential workflow. To replace a connection, pass `--session --force`; `--force` without a session adds a connection without releasing the previous one. +- Every command on a connection with a lease provider (the cloud, `connect proxy`, a device cloud, or a `leaseProvider` in the profile), including `connect`, must name its session with `--session`, `AGENT_DEVICE_SESSION`, or config, or it fails with `REMOTE_SESSION_REQUIRED`. Each session on such a profile leases under its own client and run, derived from the session name, unless the profile names a lease the host issued. For any other profile, `connect` without `--session` creates a fresh remote session and prints the generated `--session`, and commands without one fall back to the most recent connection on the machine, which is safe only for one sequential workflow. To replace a connection, pass `--session --force`. - After `connect`, normal commands (`install-from-source`, `open`, `snapshot`, `press`, `screenshot`, and others) reuse the connection state. `connection status` shows `leaseId=pending` until the first platform-bound command allocates the lease. Explicit flags override connected defaults; `open` with explicit remote flags but no saved runtime hints warns that React Native apps may launch without Metro hints. - End with `disconnect` (or `disconnect --remote-config `) to release the lease and stop the Metro companion owned by the connection. It does not stop your Metro server. - `metroProxyBaseUrl` is the bridge origin; do not add `/api/metro/...` paths. The bridge supplies iOS wildcard HTTPS hints and Android runtime routes. `metroPublicBaseUrl` is only needed for direct, non-bridge bundle hints. `metro prepare --remote-config ...` is an inspection/debug path that can write `--runtime-file `. diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index fe43a89e2a..e7eea18001 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -31,9 +31,12 @@ Every provider runs through the local `agent-device` daemon. `connect` checks yo Every provider follows the same steps: 1. Put provider credentials in CI secrets or another non-interactive credential source. -2. Run `agent-device connect ` with the provider selectors. -3. Follow the printed next command to install or open the app. -4. Run normal device commands, then `agent-device close` and `agent-device disconnect`. +2. Set `AGENT_DEVICE_SESSION` to a name for this agent or CI job. +3. Run `agent-device connect ` with the provider selectors. +4. Follow the printed next command to install or open the app. +5. Run normal device commands, then `agent-device close` and `agent-device disconnect`. + +Every command on a provider connection must name its session, with `AGENT_DEVICE_SESSION`, `--session `, or a `session` in your config. A command that names none fails with `INVALID_ARGS` and `details.reason: "REMOTE_SESSION_REQUIRED"`, before it leases anything, instead of using the connection that another agent or job on the same machine saved last. This includes `connect` itself. Each provider accepts only its own provider flags (`--provider-*` and `--aws-*`). A flag the provider does not use fails with `INVALID_ARGS` naming the flag, whether you pass it to `connect`, `client.leases.allocate()`, or a remote-config profile, so no setting is silently dropped. diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 76ccc9efbd..9fb9211e1e 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -11,6 +11,7 @@ Export your Limrun API key, for example from CI secrets. Set `LIMRUN_REGION` to ```bash export LIMRUN_API_KEY=... +export AGENT_DEVICE_SESSION=checkout-android agent-device connect limrun --platform android ``` @@ -32,6 +33,7 @@ The `lim` CLI reads the same variables, so one set works for both tools. Set all ```bash export LIM_IOS_INSTANCE_URL=... export LIM_IOS_INSTANCE_TOKEN=... +export AGENT_DEVICE_SESSION=preview-ios agent-device connect limrun --platform ios agent-device open com.example.app @@ -57,6 +59,7 @@ A new Limrun instance does not contain your app. Run `install +export AGENT_DEVICE_SESSION=checkout-agent agent-device connect proxy --daemon-base-url https://example.trycloudflare.com/agent-device agent-device devices --platform ios agent-device open MyApp --platform ios @@ -41,6 +42,8 @@ agent-device close agent-device disconnect ``` +Every command on a proxy connection must name its session, with `AGENT_DEVICE_SESSION`, `--session `, or a `session` in your config. The same applies to every device-cloud connection. A command that names none fails with `INVALID_ARGS` and `details.reason: "REMOTE_SESSION_REQUIRED"`, and sends nothing to the proxy. It never falls back to the connection that another agent on the same machine saved last. This includes `connect proxy` and `open --remote-config ` with a proxy profile. The client and run that the proxy leases under come from the session name, so two agents with different names never share a lease. + `--daemon-auth-token ` works instead of the environment variable, but it authenticates only the command you pass it to. Later commands need the token again through the environment variable, a `daemonAuthToken` entry in your remote config profile, or the flag on each command. `connect proxy` stores the proxy profile and client identity. `open` leases the device for you, and the lease expires after five minutes without commands. A lease allocated directly over the RPC without `ttlMs` gets the daemon's one-minute inactivity default instead. Either window starts when allocation completes, not when it was requested. `close` releases the active session and its device lease, unless the lease was allocated with `retainOnClose`; only `leases.release`, expiry, or daemon shutdown ends that one. The reply to any request that closed a session lists the leases it ended in `endedLeases`, as `{ leaseId, outcome: 'released' }`. This covers `close`, a `batch` with a close step, and a `replay` or `test` script that closes, as well as the session cleanup `test` runs after each script. A successful reply has the field in `data`, and a failed one has it in `error.details`. A gateway in front of the daemon reads the lease end from that reply. When the reply names the stored lease, the CLI forgets it, so the next `open` leases a device again. `disconnect` clears local connection state. @@ -49,7 +52,7 @@ Without `--udid` or `--serial`, `open` does not pick the device on the agent mac `open` names each new lease with an id it makes (32 lowercase hex characters) and sends it with the allocation, so a host in front of the daemon knows the lease id before the daemon allocates. A direct `agent_device.lease.allocate` RPC can pass `leaseId` (16 to 128 hex characters) the same way; repeating the id of a live lease returns that lease when the retry carries the same tenant, run, client, lease backend, and provider, and either names no device or the device the lease binds, so a retry after a lost reply does not allocate a second one. A retry without `ttlMs` keeps the lease's window. A retry that changes any of those fields is refused like any other reused id. The id identifies the lease: one run can hold several leases. Any other id this daemon has already given a lease, active or ended, is refused with `INVALID_ARGS` and reason `LEASE_ID_IN_USE`, and without one the daemon makes the id. Use the id the reply carries: a run that already holds a lease gets that lease back, and an older daemon ignores the requested id. -Several agents can share one proxy when each follows the same `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent holds the device until it closes its session or its lease expires. +Several agents can share one proxy, and one machine, when each names its own session and follows the same `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent holds the device until it closes its session or its lease expires. Do not put the proxy endpoint, token, tenant, or provider fields in `./agent-device.json`: repository configuration accepts only project-safe automation defaults. Set the endpoint and token with `connect proxy`, user diff --git a/website/docs/docs/revyl.md b/website/docs/docs/revyl.md index 89c4ce0d44..f5c1ed6c14 100644 --- a/website/docs/docs/revyl.md +++ b/website/docs/docs/revyl.md @@ -25,6 +25,7 @@ next device command loads the plugin. ```bash export REVYL_API_KEY=... +export AGENT_DEVICE_SESSION=checkout-ios agent-device connect revyl \ --platform ios \ --provider-app https://apps.example.com/MyApp-simulator.zip @@ -46,6 +47,7 @@ when it starts the device, so a local path or a provider-specific app reference there. Pass the zipped `.app` for iOS the way Revyl expects it, and the `.apk` URL for Android. `open` takes the app's installed bundle identifier or package name, not the file name in that URL. +Run these commands in the same shell, so they use the `AGENT_DEVICE_SESSION` you exported: ```bash agent-device open com.example.app @@ -92,9 +94,12 @@ report a refused command as a success: ## Run it in CI -The plugin reads only `REVYL_API_KEY` and the optional `REVYL_API_URL`, so one secret is enough: +The plugin reads only `REVYL_API_KEY` and the optional `REVYL_API_URL`, so one secret is enough. Name the session once for the job, so that every step reaches the same connection: ```yaml +env: + AGENT_DEVICE_SESSION: revyl-${{ github.run_id }} +steps: - run: npm install -g agent-device && agent-device plugins add @agent-device/revyl - run: agent-device connect revyl --platform android --provider-app "$APP_URL" - run: agent-device open com.example.app && agent-device snapshot -i diff --git a/website/docs/docs/testmu.md b/website/docs/docs/testmu.md index 683769fa9a..1aa95a632b 100644 --- a/website/docs/docs/testmu.md +++ b/website/docs/docs/testmu.md @@ -31,6 +31,7 @@ export LT_ACCESS_KEY=... Connect with the platform, exact device name and OS version, and the app to test: ```bash +export AGENT_DEVICE_SESSION=checkout-android agent-device connect testmu \ --platform android \ --device "Galaxy S22 Ultra 5G" \ @@ -91,6 +92,7 @@ for virtual devices: `connect` checks the device/OS pair against the real-device agent-device uploads a local path or URL through the real-device upload API. ```bash +export AGENT_DEVICE_SESSION=checkout-real agent-device connect testmu \ --provider-device-type real \ --platform ios \ @@ -123,6 +125,7 @@ agent-device connect testmu \ ```bash export LT_USERNAME=... export LT_ACCESS_KEY=... +export AGENT_DEVICE_SESSION=checkout-ios agent-device connect testmu \ --platform ios \