From a9eeee49208c24db045341bebf8ca80506faf7f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 11 Sep 2026 15:21:51 +0200 Subject: [PATCH 1/5] fix(browserstack): forward provider session metadata over the lease envelope `--provider-project`, `--provider-build`, and `--provider-session-name` are stored in the connection profile and reach the daemon on the line transport (which forwards the whole request), but the compact JSON-RPC lease envelope carried only `providerApp`. Over HTTP/remote daemons the daemon's lease-lifecycle provider therefore saw no session-naming metadata and created BrowserStack sessions as "Untitled Project" / "Untitled Build" with an empty name. Read the four provider session-metadata flags through one shared projection (`readLeaseAllocateProviderMetadata`) used by both the client's `buildHttpRpcPayload` and the daemon's `toLeaseDaemonRequest`, so the transports agree and the producer and consumer cannot drop a sibling again. Closes #2494 --- .../src/__tests__/lease-scope.test.ts | 26 +++++++++++++ packages/contracts/src/lease-scope.ts | 30 ++++++++++++++ src/daemon-client/daemon-client-rpc.test.ts | 39 +++++++++++++++++++ src/daemon-client/daemon-client-rpc.ts | 5 +-- src/daemon/server/http-server.ts | 6 +-- .../daemon-http-server.test.ts | 26 +++++++++++++ 6 files changed, 125 insertions(+), 7 deletions(-) diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index 7d081e984d..dd083d8134 100644 --- a/packages/contracts/src/__tests__/lease-scope.test.ts +++ b/packages/contracts/src/__tests__/lease-scope.test.ts @@ -8,6 +8,7 @@ import { leaseScopeToConnectionMetadata, leaseScopeToLeaseRpcParams, leaseScopeToRequestMeta, + readLeaseAllocateProviderMetadata, } from '../lease-scope.ts'; test('leaseScopeFromOptions normalizes public aliases and projects request meta', () => { @@ -166,3 +167,28 @@ test('findMissingProxyLeaseFields enforces complete proxy ownership scope', () = [], ); }); + +test('readLeaseAllocateProviderMetadata carries the session-naming flags and drops the rest', () => { + assert.deepEqual( + readLeaseAllocateProviderMetadata({ + platform: 'ios', + device: 'iPhone 15', + providerApp: 'bs://abc', + providerOsVersion: '17', + providerProject: 'MyProject', + providerBuild: 'Build-1', + providerSessionName: 'smoke', + }), + { + providerApp: 'bs://abc', + providerProject: 'MyProject', + providerBuild: 'Build-1', + providerSessionName: 'smoke', + }, + ); + assert.deepEqual( + readLeaseAllocateProviderMetadata({ providerApp: 'bs://abc', providerBuild: undefined }), + { providerApp: 'bs://abc' }, + ); + assert.deepEqual(readLeaseAllocateProviderMetadata(undefined), {}); +}); diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 530fa97c10..1dfd951361 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -1,5 +1,6 @@ import type { LeaseBackend } from '@agent-device/kernel/contracts'; import { stripUndefined } from '@agent-device/kernel/record'; +import type { CloudProviderProfileFields } from './remote-config-fields.ts'; const PROXY_LEASE_PROVIDER = 'proxy'; export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; @@ -197,6 +198,35 @@ export function leaseScopeToLeaseRpcParams( } } +/** + * Provider session metadata that must travel with `lease_allocate` so the daemon's lease-lifecycle + * provider can name the session it creates. The line transport forwards the whole request and these + * flags arrive for free; the compact lease envelope is the only projection that has to name them, so + * the client's producer and the daemon's consumer read the SAME list and cannot drop a sibling. + */ +const LEASE_ALLOCATE_PROVIDER_METADATA_FLAG_KEYS = [ + 'providerApp', + 'providerProject', + 'providerBuild', + 'providerSessionName', +] as const satisfies readonly (keyof CloudProviderProfileFields)[]; + +type LeaseAllocateProviderMetadata = Partial< + Pick +>; + +/** Reads the provider session metadata out of a flags bag or a lease-envelope param bag. */ +export function readLeaseAllocateProviderMetadata( + source: Record | undefined, +): LeaseAllocateProviderMetadata { + const metadata: Record = {}; + for (const key of LEASE_ALLOCATE_PROVIDER_METADATA_FLAG_KEYS) { + const value = source?.[key]; + if (typeof value === 'string') metadata[key] = value; + } + return metadata; +} + export function leaseScopeToConnectionMetadata( scope: LeaseScope, ): Pick | undefined { diff --git a/src/daemon-client/daemon-client-rpc.test.ts b/src/daemon-client/daemon-client-rpc.test.ts index c4f6ed3f5f..b0df3da858 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -33,6 +33,45 @@ test('lease allocation transports an optional initial provider app', () => { }); }); +test('lease allocation transports provider project, build, and session name (#2494)', () => { + const payload = buildHttpRpcPayload( + { + token: 'daemon-token', + session: 'qa-ios', + command: 'lease_allocate', + positionals: [], + flags: { + providerApp: 'bs://app-id', + providerOsVersion: '17', + device: 'iPhone 15', + providerProject: 'MyProject', + providerBuild: 'Build-2026-09-11', + providerSessionName: 'smoke — iOS', + }, + meta: { + requestId: 'lease-req', + tenantId: 'acme', + runId: 'run-123', + leaseBackend: 'ios-instance', + leaseProvider: 'browserstack', + }, + }, + { includeTokenParam: false }, + ); + + assert.deepEqual(payload.params, { + session: 'qa-ios', + tenantId: 'acme', + runId: 'run-123', + backend: 'ios-instance', + leaseProvider: 'browserstack', + providerApp: 'bs://app-id', + providerProject: 'MyProject', + providerBuild: 'Build-2026-09-11', + providerSessionName: 'smoke — iOS', + }); +}); + test('HTTP RPC errors sanitize an untrusted cause before rehydration', () => { const secret = 'adc_agent_remote-secret'; const oversizedCode = `apiKey=${secret} ${'c'.repeat(500)}`; diff --git a/src/daemon-client/daemon-client-rpc.ts b/src/daemon-client/daemon-client-rpc.ts index d58f48ebec..84e144ffd6 100644 --- a/src/daemon-client/daemon-client-rpc.ts +++ b/src/daemon-client/daemon-client-rpc.ts @@ -13,6 +13,7 @@ import type { DaemonInfo } from './daemon-client-metadata.ts'; import { leaseScopeFromRequest, leaseScopeToLeaseRpcParams, + readLeaseAllocateProviderMetadata, type LeaseRpcCommand, } from '@agent-device/contracts/lease-scope'; @@ -183,9 +184,7 @@ export function buildHttpRpcPayload( method: leaseRpcMethodForCommand(req.command), params: { ...buildLeaseRpcParams(req, req.command, options), - ...(req.command === 'lease_allocate' && typeof req.flags?.providerApp === 'string' - ? { providerApp: req.flags.providerApp } - : {}), + ...(req.command === 'lease_allocate' ? readLeaseAllocateProviderMetadata(req.flags) : {}), }, }; } diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index f218d2271e..044e62f121 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -40,6 +40,7 @@ import { DAEMON_HTTP_TENANT_HEADER, } from '@agent-device/contracts/daemon-http'; import { readVersion } from '@agent-device/host-kit/version'; +import { readLeaseAllocateProviderMetadata } from '@agent-device/contracts/lease-scope'; import { sendRestJsonError, statusCodeForNormalizedError } from '../http-errors.ts'; import { tryHandleUploadHttpRoute } from '../upload-http.ts'; import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts'; @@ -316,10 +317,7 @@ function toLeaseDaemonRequest( session: readStringParam(params, 'session') ?? 'default', command, positionals: [], - flags: - command === 'lease_allocate' - ? { providerApp: readStringParam(params, 'providerApp') } - : undefined, + flags: command === 'lease_allocate' ? readLeaseAllocateProviderMetadata(params) : undefined, meta: { tenantId: readStringParam(params, 'tenantId') ?? readStringParam(params, 'tenant'), runId: readStringParam(params, 'runId'), diff --git a/test/integration/provider-scenarios/daemon-http-server.test.ts b/test/integration/provider-scenarios/daemon-http-server.test.ts index c984a6d64e..5ed853bfed 100644 --- a/test/integration/provider-scenarios/daemon-http-server.test.ts +++ b/test/integration/provider-scenarios/daemon-http-server.test.ts @@ -163,6 +163,32 @@ test('Provider-backed integration daemon HTTP server maps RPC methods, auth, and }); assert.equal(lease.status, 200); + const providerLease = await callRpc(port, { + jsonrpc: '2.0', + id: 'rpc-lease-provider', + method: 'agent_device.lease.allocate', + params: { + tenantId: 'Tenant A', + runId: 'run-1', + backend: 'ios-instance', + leaseProvider: 'browserstack', + providerApp: 'bs://app-id', + providerProject: 'MyProject', + providerBuild: 'Build-1', + providerSessionName: 'smoke', + }, + }); + assert.equal(providerLease.status, 200); + const providerLeaseRequest = observedRequests.find( + (req) => req.command === 'lease_allocate' && req.flags?.providerProject === 'MyProject', + ); + assert.deepEqual(providerLeaseRequest?.flags, { + providerApp: 'bs://app-id', + providerProject: 'MyProject', + providerBuild: 'Build-1', + providerSessionName: 'smoke', + }); + const release = await callRpc(port, { jsonrpc: '2.0', id: 'rpc-release', From 30089ff436a9918804d01cc0f7865743a0671702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 11 Sep 2026 15:21:51 +0200 Subject: [PATCH 2/5] chore(gates): pin lease_allocate wire digests for the forwarded provider metadata (#2494) --- test/wire-compat/ledger.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 4e355b9c01..9d7171db0a 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -54,7 +54,7 @@ "src/daemon-client/daemon-client-progress.ts#createInvalidDaemonResponseError": "sha256:0af812346b667a23fdfef641b242cd86d07edaa05eb928c4c531628c09a4031b", "src/daemon-client/daemon-client-progress.ts#shouldReadDaemonProgressStream": "sha256:6811404f41d8db3fa6fc751332c7186a2399ec7ecbc4e9c547d9cb9f14dbb350", "src/daemon-client/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", - "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:efa9a5da4c7288cceae5656b468946ad3a2af0f0f0fb940f8a37987f922aa4b8", + "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:c2bbf1c9ae20abc5fdc49ca540ab5a4213d764375e41740705a8a1cce11e9dc0", "src/daemon-client/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:1755f46be8c62e7a8eb409e9a94d61781c96ccbef3306702121aa94264d63267", "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:8f1c4bba1918545db6296ad29b95f0baa9f949e7c23bc04d59e261130f14611e", "src/daemon-client/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", @@ -119,7 +119,7 @@ "src/daemon/server/http-server.ts#sendJson": "sha256:a1de00da3f1db8c98e69a85dfe2e11b626a4d2ce472840e3b8f519aa8a207a8f", "src/daemon/server/http-server.ts#toDaemonRequest": "sha256:f3861d2d4f67f66e83800616926f6bbb0595de4ecbb0072b5b78971ebeb0705b", "src/daemon/server/http-server.ts#toInstallFromSourceDaemonRequest": "sha256:dff093758b433043816dfc1e2e9658078875e46b887fdc530fb9380158d629f5", - "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:719b5b8c29a95e9d2be01850bb97c8120f5a04aecc09c265bebfc6631e41ffa3", + "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:c38554b76f480b5c445b1e7226f4e630c264f0c69e25a1662483d3caf8796b3e", "src/daemon/server/http-server.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:708736ca03d6a3fc449bb408382dbd509e490de1f84ecc8771fd77d4adda0154", "src/daemon/server/http-server.ts#writeProgressEnvelope": "sha256:ca661f6dd2de6e517c520dbd1e20b59b24b58d925157ed06123149ed2bc0fa3b", "src/daemon/server/http-server.ts#writeRpcResponseEnvelope": "sha256:7a8155e9fcbb53485489728250a85109b5dab0d96cdd4aa8b7e7eb87a4898ddb", @@ -208,13 +208,13 @@ }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload", - "digest": "sha256:efa9a5da4c7288cceae5656b468946ad3a2af0f0f0fb940f8a37987f922aa4b8", - "rationale": "#2110 adds the optional providerApp parameter to lease allocation. A released protocol-2 daemon ignores unknown JSON-RPC parameters, while requests without an initial app retain the previous payload exactly." + "digest": "sha256:c2bbf1c9ae20abc5fdc49ca540ab5a4213d764375e41740705a8a1cce11e9dc0", + "rationale": "#2110 added the optional providerApp parameter to lease allocation; #2494 extends the same additive projection with providerProject, providerBuild, and providerSessionName so the daemon's lease-lifecycle provider can name the BrowserStack/AWS session instead of leaving it Untitled. Each is optional and forwarded only when the caller supplied it: a released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request carrying none of these retains the previous payload byte-for-byte." }, { "declaration": "src/daemon/server/http-server.ts#toLeaseDaemonRequest", - "digest": "sha256:719b5b8c29a95e9d2be01850bb97c8120f5a04aecc09c265bebfc6631e41ffa3", - "rationale": "#2110 reads the optional providerApp lease-allocation parameter into the existing flags bag. Released protocol-2 clients omit it and follow the unchanged allocation path; no existing field is required or reinterpreted." + "digest": "sha256:c38554b76f480b5c445b1e7226f4e630c264f0c69e25a1662483d3caf8796b3e", + "rationale": "#2110 read the optional providerApp lease-allocation parameter into the existing flags bag; #2494 reads providerProject, providerBuild, and providerSessionName into the same bag from the shared provider-metadata projection. Released protocol-2 clients omit these and follow the unchanged allocation path; no existing field is required or reinterpreted." }, { "declaration": "packages/kernel/src/errors.ts#DaemonError", From 7e3609460bb82c6e3126c0745441910aedd5d0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 11 Sep 2026 15:45:24 +0200 Subject: [PATCH 3/5] test(daemon-http): move the lease provider-metadata check into its own file The provider-scenario daemon-http-server test is over the 1000-line tripwire and may not grow; the lease_allocate metadata assertion now lives in daemon-http-lease-allocate.test.ts, mirroring the lease projection in http-server.ts. --- .../daemon-http-lease-allocate.test.ts | 61 +++++++++++++++++++ .../daemon-http-server.test.ts | 26 -------- 2 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts diff --git a/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts b/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts new file mode 100644 index 0000000000..e39e0c8c76 --- /dev/null +++ b/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts'; +import type { DaemonRequest, DaemonResponse } from '../../../src/daemon/daemon-request.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../../src/__tests__/test-utils/loopback.ts'; + +// The compact lease envelope is the only projection that has to name provider session metadata by +// key; the daemon's lease request must carry every one of those keys, and only those. +test('Provider-backed integration daemon HTTP lease allocate forwards provider session metadata as request flags', async (t) => { + if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP lease allocate coverage')) return; + + const observedRequests: DaemonRequest[] = []; + const server = await createDaemonHttpServer({ + token: 'provider-scenario-token', + handleRequest: async (req): Promise => { + observedRequests.push(req); + return { ok: true, data: { command: req.command } }; + }, + }); + + try { + const port = await listenOnLoopback(server); + const response = await fetch(`http://127.0.0.1:${port}/rpc`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer provider-scenario-token', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'rpc-lease-provider', + method: 'agent_device.lease.allocate', + params: { + tenantId: 'Tenant A', + runId: 'run-1', + backend: 'ios-instance', + leaseProvider: 'browserstack', + providerApp: 'bs://app-id', + providerProject: 'MyProject', + providerBuild: 'Build-1', + providerSessionName: 'smoke', + }, + }), + }); + assert.equal(response.status, 200); + + const leaseRequest = observedRequests.find((req) => req.command === 'lease_allocate'); + assert.deepEqual(leaseRequest?.flags, { + providerApp: 'bs://app-id', + providerProject: 'MyProject', + providerBuild: 'Build-1', + providerSessionName: 'smoke', + }); + } finally { + await closeLoopbackServer(server); + } +}); diff --git a/test/integration/provider-scenarios/daemon-http-server.test.ts b/test/integration/provider-scenarios/daemon-http-server.test.ts index 5ed853bfed..c984a6d64e 100644 --- a/test/integration/provider-scenarios/daemon-http-server.test.ts +++ b/test/integration/provider-scenarios/daemon-http-server.test.ts @@ -163,32 +163,6 @@ test('Provider-backed integration daemon HTTP server maps RPC methods, auth, and }); assert.equal(lease.status, 200); - const providerLease = await callRpc(port, { - jsonrpc: '2.0', - id: 'rpc-lease-provider', - method: 'agent_device.lease.allocate', - params: { - tenantId: 'Tenant A', - runId: 'run-1', - backend: 'ios-instance', - leaseProvider: 'browserstack', - providerApp: 'bs://app-id', - providerProject: 'MyProject', - providerBuild: 'Build-1', - providerSessionName: 'smoke', - }, - }); - assert.equal(providerLease.status, 200); - const providerLeaseRequest = observedRequests.find( - (req) => req.command === 'lease_allocate' && req.flags?.providerProject === 'MyProject', - ); - assert.deepEqual(providerLeaseRequest?.flags, { - providerApp: 'bs://app-id', - providerProject: 'MyProject', - providerBuild: 'Build-1', - providerSessionName: 'smoke', - }); - const release = await callRpc(port, { jsonrpc: '2.0', id: 'rpc-release', From e7ff8d6fd1c2884d7bc307d9265639a87cc2eea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 11 Sep 2026 17:26:18 +0200 Subject: [PATCH 4/5] fix(browserstack): carry the full provider-allocation config over the lease envelope The lease envelope named only the session-label fields, so a fresh remote allocation still failed in prepareSession before the names could take effect: device selection (platform, device), providerOsVersion, and the configured device-feature/AWS knobs were dropped on the HTTP transport, while the line transport forwards the whole request for free. readLeaseAllocateProviderMetadata becomes readLeaseAllocateProviderFlags and projects the full set the lease-lifecycle provider reads, pinned exhaustive against CloudProviderProfileFields so a new field cannot silently miss it. The producer and consumer already share this reader, so both transports agree. Covered end to end in cloud-webdriver-lease-http.test.ts: the exact client envelope is driven through a real daemon HTTP server into the real BrowserStack prepareSession, asserting the capabilities that reach the hub. --- .../src/__tests__/lease-scope.test.ts | 33 ++- packages/contracts/src/lease-scope.ts | 61 ++++-- src/daemon-client/daemon-client-rpc.test.ts | 12 +- src/daemon-client/daemon-client-rpc.ts | 4 +- src/daemon/server/http-server.ts | 4 +- .../cloud-webdriver-lease-http.test.ts | 190 ++++++++++++++++++ .../daemon-http-lease-allocate.test.ts | 22 +- 7 files changed, 296 insertions(+), 30 deletions(-) create mode 100644 test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index dd083d8134..daad373eb2 100644 --- a/packages/contracts/src/__tests__/lease-scope.test.ts +++ b/packages/contracts/src/__tests__/lease-scope.test.ts @@ -8,7 +8,7 @@ import { leaseScopeToConnectionMetadata, leaseScopeToLeaseRpcParams, leaseScopeToRequestMeta, - readLeaseAllocateProviderMetadata, + readLeaseAllocateProviderFlags, } from '../lease-scope.ts'; test('leaseScopeFromOptions normalizes public aliases and projects request meta', () => { @@ -168,9 +168,15 @@ test('findMissingProxyLeaseFields enforces complete proxy ownership scope', () = ); }); -test('readLeaseAllocateProviderMetadata carries the session-naming flags and drops the rest', () => { +test('readLeaseAllocateProviderFlags carries the provider-allocation flags and drops the rest', () => { assert.deepEqual( - readLeaseAllocateProviderMetadata({ + readLeaseAllocateProviderFlags({ + session: 'default', + token: 'secret', + runId: 'run-a', + deviceKey: 'dk-1', + provider: 'browserstack', + ttlMs: 1000, platform: 'ios', device: 'iPhone 15', providerApp: 'bs://abc', @@ -178,17 +184,34 @@ test('readLeaseAllocateProviderMetadata carries the session-naming flags and dro providerProject: 'MyProject', providerBuild: 'Build-1', providerSessionName: 'smoke', + providerDeviceOrientation: 'landscape', + providerGeoLocation: '52.5,13.4', + providerLanguage: 'en', + providerNoResignApp: true, + awsRegion: 'us-west-2', + awsProjectArn: 'arn:aws:devicefarm:0', + awsInteractionMode: 'NO_VIDEO', }), { + platform: 'ios', + device: 'iPhone 15', providerApp: 'bs://abc', + providerOsVersion: '17', providerProject: 'MyProject', providerBuild: 'Build-1', providerSessionName: 'smoke', + providerDeviceOrientation: 'landscape', + providerGeoLocation: '52.5,13.4', + providerLanguage: 'en', + providerNoResignApp: true, + awsRegion: 'us-west-2', + awsProjectArn: 'arn:aws:devicefarm:0', + awsInteractionMode: 'NO_VIDEO', }, ); assert.deepEqual( - readLeaseAllocateProviderMetadata({ providerApp: 'bs://abc', providerBuild: undefined }), + readLeaseAllocateProviderFlags({ providerApp: 'bs://abc', providerBuild: undefined }), { providerApp: 'bs://abc' }, ); - assert.deepEqual(readLeaseAllocateProviderMetadata(undefined), {}); + assert.deepEqual(readLeaseAllocateProviderFlags(undefined), {}); }); diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 1dfd951361..19646632f9 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -1,6 +1,7 @@ import type { LeaseBackend } from '@agent-device/kernel/contracts'; import { stripUndefined } from '@agent-device/kernel/record'; import type { CloudProviderProfileFields } from './remote-config-fields.ts'; +import type { CommandFlags } from './command-flags.ts'; const PROXY_LEASE_PROVIDER = 'proxy'; export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; @@ -199,32 +200,60 @@ export function leaseScopeToLeaseRpcParams( } /** - * Provider session metadata that must travel with `lease_allocate` so the daemon's lease-lifecycle - * provider can name the session it creates. The line transport forwards the whole request and these - * flags arrive for free; the compact lease envelope is the only projection that has to name them, so - * the client's producer and the daemon's consumer read the SAME list and cannot drop a sibling. + * Request flags that must travel with `lease_allocate` so the daemon's lease-lifecycle provider can + * prepare the session — device selection, the app/os, the session-naming fields, and the configured + * device-feature and AWS knobs. The line transport forwards the whole request and these arrive for + * free; the compact lease envelope is the only projection that has to name them, so the client's + * producer and the daemon's consumer read the SAME list and cannot drop a sibling the provider needs. */ -const LEASE_ALLOCATE_PROVIDER_METADATA_FLAG_KEYS = [ +const LEASE_ALLOCATE_PROVIDER_FLAG_KEYS = [ + // Device selection the lease-lifecycle provider needs to create the session. + 'platform', + 'device', + // The Cloud provider profile fields; pinned exhaustive against that vocabulary below. 'providerApp', + 'providerOsVersion', 'providerProject', 'providerBuild', 'providerSessionName', -] as const satisfies readonly (keyof CloudProviderProfileFields)[]; + 'providerDeviceOrientation', + 'providerGeoLocation', + 'providerTimezone', + 'providerLanguage', + 'providerLocale', + 'providerNetworkProfile', + 'providerCustomNetwork', + 'providerNoResignApp', + 'awsProjectArn', + 'awsDeviceArn', + 'awsAppArn', + 'awsRegion', + 'awsInteractionMode', +] as const satisfies readonly (keyof CommandFlags)[]; -type LeaseAllocateProviderMetadata = Partial< - Pick ->; +type LeaseAllocateProviderFlagKey = (typeof LEASE_ALLOCATE_PROVIDER_FLAG_KEYS)[number]; -/** Reads the provider session metadata out of a flags bag or a lease-envelope param bag. */ -export function readLeaseAllocateProviderMetadata( +// A Cloud provider profile field added without joining the projection would be dropped from the +// envelope and fail `prepareSession` on the remote daemon — this makes that omission fail to build. +type LeaseAllocateProfileKeysAreExhaustive = + Exclude extends never + ? true + : Exclude; +const leaseAllocateProfileKeysExhaustive: LeaseAllocateProfileKeysAreExhaustive = true; +void leaseAllocateProfileKeysExhaustive; + +type LeaseAllocateProviderFlags = Partial>; + +/** Reads the provider-allocation flags out of a request flags bag or a lease-envelope param bag. */ +export function readLeaseAllocateProviderFlags( source: Record | undefined, -): LeaseAllocateProviderMetadata { - const metadata: Record = {}; - for (const key of LEASE_ALLOCATE_PROVIDER_METADATA_FLAG_KEYS) { +): LeaseAllocateProviderFlags { + const flags: Record = {}; + for (const key of LEASE_ALLOCATE_PROVIDER_FLAG_KEYS) { const value = source?.[key]; - if (typeof value === 'string') metadata[key] = value; + if (typeof value === 'string' || typeof value === 'boolean') flags[key] = value; } - return metadata; + return flags as LeaseAllocateProviderFlags; } export function leaseScopeToConnectionMetadata( diff --git a/src/daemon-client/daemon-client-rpc.test.ts b/src/daemon-client/daemon-client-rpc.test.ts index b0df3da858..faa8c6e835 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -33,7 +33,7 @@ test('lease allocation transports an optional initial provider app', () => { }); }); -test('lease allocation transports provider project, build, and session name (#2494)', () => { +test('lease allocation transports the provider configuration the session needs (#2494)', () => { const payload = buildHttpRpcPayload( { token: 'daemon-token', @@ -41,12 +41,15 @@ test('lease allocation transports provider project, build, and session name (#24 command: 'lease_allocate', positionals: [], flags: { + platform: 'ios', + device: 'iPhone 15', providerApp: 'bs://app-id', providerOsVersion: '17', - device: 'iPhone 15', providerProject: 'MyProject', providerBuild: 'Build-2026-09-11', providerSessionName: 'smoke — iOS', + providerDeviceOrientation: 'portrait', + providerNoResignApp: true, }, meta: { requestId: 'lease-req', @@ -65,10 +68,15 @@ test('lease allocation transports provider project, build, and session name (#24 runId: 'run-123', backend: 'ios-instance', leaseProvider: 'browserstack', + platform: 'ios', + device: 'iPhone 15', providerApp: 'bs://app-id', + providerOsVersion: '17', providerProject: 'MyProject', providerBuild: 'Build-2026-09-11', providerSessionName: 'smoke — iOS', + providerDeviceOrientation: 'portrait', + providerNoResignApp: true, }); }); diff --git a/src/daemon-client/daemon-client-rpc.ts b/src/daemon-client/daemon-client-rpc.ts index 84e144ffd6..4cdf6d54fb 100644 --- a/src/daemon-client/daemon-client-rpc.ts +++ b/src/daemon-client/daemon-client-rpc.ts @@ -13,7 +13,7 @@ import type { DaemonInfo } from './daemon-client-metadata.ts'; import { leaseScopeFromRequest, leaseScopeToLeaseRpcParams, - readLeaseAllocateProviderMetadata, + readLeaseAllocateProviderFlags, type LeaseRpcCommand, } from '@agent-device/contracts/lease-scope'; @@ -184,7 +184,7 @@ export function buildHttpRpcPayload( method: leaseRpcMethodForCommand(req.command), params: { ...buildLeaseRpcParams(req, req.command, options), - ...(req.command === 'lease_allocate' ? readLeaseAllocateProviderMetadata(req.flags) : {}), + ...(req.command === 'lease_allocate' ? readLeaseAllocateProviderFlags(req.flags) : {}), }, }; } diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index 044e62f121..c212173897 100644 --- a/src/daemon/server/http-server.ts +++ b/src/daemon/server/http-server.ts @@ -40,7 +40,7 @@ import { DAEMON_HTTP_TENANT_HEADER, } from '@agent-device/contracts/daemon-http'; import { readVersion } from '@agent-device/host-kit/version'; -import { readLeaseAllocateProviderMetadata } from '@agent-device/contracts/lease-scope'; +import { readLeaseAllocateProviderFlags } from '@agent-device/contracts/lease-scope'; import { sendRestJsonError, statusCodeForNormalizedError } from '../http-errors.ts'; import { tryHandleUploadHttpRoute } from '../upload-http.ts'; import { tryHandleDownloadableArtifactHttpRoute } from '../downloadable-artifact-http.ts'; @@ -317,7 +317,7 @@ function toLeaseDaemonRequest( session: readStringParam(params, 'session') ?? 'default', command, positionals: [], - flags: command === 'lease_allocate' ? readLeaseAllocateProviderMetadata(params) : undefined, + flags: command === 'lease_allocate' ? readLeaseAllocateProviderFlags(params) : undefined, meta: { tenantId: readStringParam(params, 'tenantId') ?? readStringParam(params, 'tenant'), runId: readStringParam(params, 'runId'), diff --git a/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts new file mode 100644 index 0000000000..7dc5168d00 --- /dev/null +++ b/test/integration/provider-scenarios/cloud-webdriver-lease-http.test.ts @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { test } from 'vitest'; +import { + CLOUD_WEBDRIVER_PROVIDERS, + createProviderWebDriver, +} from '@agent-device/provider-webdriver'; +import { createProviderDeviceRuntimeRequestProviders } from '../../../src/provider-device-runtime.ts'; +import { createDaemonHttpServer } from '../../../src/daemon/server/http-server.ts'; +import { buildHttpRpcPayload } from '../../../src/daemon-client/daemon-client-rpc.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from '../../../src/__tests__/test-utils/loopback.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; +import { + CloudWebDriverTestServer, + type CloudWebDriverHttpCall, + cloudWebDriverTestJson, + startCloudWebDriverTestServer, + type StartedCloudWebDriverTestServer, +} from './cloud-webdriver-test-server.ts'; + +const CLIENT_VERSION = '0.20.3-test'; + +/** + * The lease envelope is a hand-written projection, so a field the provider needs can be dropped + * silently on the HTTP transport while the line transport forwards it for free. This drives the + * exact client envelope through a real daemon HTTP server into the real BrowserStack + * `prepareSession`, and asserts the capabilities that reach the hub — the only seam that proves a + * fresh remote allocation actually names and configures the session. + */ +test('lease_allocate over HTTP prepares the BrowserStack session end to end', async (t) => { + if (await skipWhenLoopbackUnavailable(t, 'BrowserStack lease HTTP coverage')) { + return; + } + + await withProviderScenarioResource(FakeBrowserStackServer.start, async (server) => { + const providerWebDriver = createProviderWebDriver({ + clientVersion: CLIENT_VERSION, + runHostCommand: async () => { + throw new Error('BrowserStack scenario must not run host commands'); + }, + }); + const runtimes = providerWebDriver.createDefaultRuntimes({ + BROWSERSTACK_USERNAME: 'browser-user', + BROWSERSTACK_ACCESS_KEY: 'browser-key', + BROWSERSTACK_WEBDRIVER_ENDPOINT: `${server.url}/wd/hub/`, + BROWSERSTACK_APP_UPLOAD_ENDPOINT: `${server.url}/app-automate/upload`, + BROWSERSTACK_SESSION_DETAILS_ENDPOINT: `${server.url}/app-automate/sessions`, + }); + const providers = createProviderDeviceRuntimeRequestProviders(runtimes); + const providerModules = runtimes.map((runtime) => + Object.freeze({ runtime, module: runtime.platformRuntimeModule }), + ); + const harness = await createProviderScenarioHarness({ + ...providers, + deviceInventorySource: providers.deviceInventorySource!, + platformRuntime: { providerRuntimes: runtimes, providerModules }, + }); + const httpServer = await createDaemonHttpServer({ + token: harness.token, + handleRequest: harness.handleRequest, + }); + + try { + const port = await listenOnLoopback(httpServer); + const envelope = buildHttpRpcPayload( + { + token: harness.token, + session: 'browserstack-e2e', + command: 'lease_allocate', + positionals: [], + flags: { + platform: 'android', + device: 'Google Pixel 8', + providerApp: 'bs://preuploaded', + providerOsVersion: '14.0', + providerProject: 'MyProject', + providerBuild: 'Build-2026-09-11', + providerSessionName: 'smoke', + providerDeviceOrientation: 'portrait', + }, + meta: { + tenantId: 'team-a', + runId: 'run-a', + leaseBackend: 'android-instance', + leaseProvider: CLOUD_WEBDRIVER_PROVIDERS.browserStack, + deviceKey: 'webdriver-android-a', + clientId: 'client-a', + }, + }, + { includeTokenParam: false }, + ); + + const body = await postRpc(port, harness.token, envelope); + assert.equal(body.status, 200); + assert.equal(body.rpc.error, undefined, `rpc error: ${JSON.stringify(body.rpc.error)}`); + assert.ok( + body.rpc.result?.ok === true, + `daemon rejected allocate: ${JSON.stringify(body.rpc.result ?? body.rpc)}`, + ); + + const create = server.calls.find((call) => call.path === '/wd/hub/session'); + assert.ok(create, 'provider never received a session-create call'); + assert.deepEqual(alwaysMatch(create), { + platformName: 'Android', + 'appium:deviceName': 'Google Pixel 8', + device: 'Google Pixel 8', + os_version: '14.0', + app: 'bs://preuploaded', + 'bstack:options': { + projectName: 'MyProject', + buildName: 'Build-2026-09-11', + sessionName: 'smoke', + deviceOrientation: 'portrait', + }, + }); + } finally { + await closeLoopbackServer(httpServer); + await harness.close(); + await Promise.allSettled(runtimes.map(async (runtime) => await runtime.shutdown())); + } + }); +}, 15_000); + +/** + * The provider fake hijacks `globalThis.fetch`, so the daemon RPC is spoken over a real + * `node:http` request instead — the same wire a remote client uses, without colliding with the + * intercepted provider transport. + */ +function postRpc( + port: number, + token: string, + payload: Record, +): Promise<{ status: number; rpc: RpcEnvelope }> { + return new Promise((resolve, reject) => { + const request = http.request( + { + host: '127.0.0.1', + port, + path: '/rpc', + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + resolve({ + status: response.statusCode ?? 0, + rpc: JSON.parse(text) as RpcEnvelope, + }); + }); + }, + ); + request.on('error', reject); + request.end(JSON.stringify(payload)); + }); +} + +type RpcEnvelope = { + result?: { ok?: boolean; error?: { message?: string } }; + error?: { message?: string }; +}; + +function alwaysMatch(call: CloudWebDriverHttpCall): Record | undefined { + return (call.body as { capabilities?: { alwaysMatch?: Record } } | undefined) + ?.capabilities?.alwaysMatch; +} + +class FakeBrowserStackServer extends CloudWebDriverTestServer { + static async start(): Promise> { + return await startCloudWebDriverTestServer(new FakeBrowserStackServer()); + } + + protected respond(call: CloudWebDriverHttpCall) { + if (call.method === 'POST' && call.path === '/wd/hub/session') { + return cloudWebDriverTestJson({ + value: { sessionId: 'wd-1', capabilities: { platformName: 'Android' } }, + }); + } + return cloudWebDriverTestJson({ value: null }); + } +} diff --git a/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts b/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts index e39e0c8c76..fd615dd7fc 100644 --- a/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts +++ b/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts @@ -8,9 +8,11 @@ import { skipWhenLoopbackUnavailable, } from '../../../src/__tests__/test-utils/loopback.ts'; -// The compact lease envelope is the only projection that has to name provider session metadata by -// key; the daemon's lease request must carry every one of those keys, and only those. -test('Provider-backed integration daemon HTTP lease allocate forwards provider session metadata as request flags', async (t) => { +// The compact lease envelope is the only projection that has to name the provider-allocation flags +// by key; the daemon's lease request must carry every one the lease-lifecycle provider reads, and +// only those. The end-to-end proof that these reach a real provider session lives in +// cloud-webdriver-lease-http.test.ts. +test('Provider-backed integration daemon HTTP lease allocate forwards provider allocation flags as request flags', async (t) => { if (await skipWhenLoopbackUnavailable(t, 'daemon HTTP lease allocate coverage')) return; const observedRequests: DaemonRequest[] = []; @@ -39,10 +41,18 @@ test('Provider-backed integration daemon HTTP lease allocate forwards provider s runId: 'run-1', backend: 'ios-instance', leaseProvider: 'browserstack', + clientId: 'client-a', + deviceKey: 'dk-1', + platform: 'ios', + device: 'iPhone 15', providerApp: 'bs://app-id', + providerOsVersion: '17', providerProject: 'MyProject', providerBuild: 'Build-1', providerSessionName: 'smoke', + providerDeviceOrientation: 'portrait', + providerNoResignApp: true, + awsRegion: 'us-west-2', }, }), }); @@ -50,10 +60,16 @@ test('Provider-backed integration daemon HTTP lease allocate forwards provider s const leaseRequest = observedRequests.find((req) => req.command === 'lease_allocate'); assert.deepEqual(leaseRequest?.flags, { + platform: 'ios', + device: 'iPhone 15', providerApp: 'bs://app-id', + providerOsVersion: '17', providerProject: 'MyProject', providerBuild: 'Build-1', providerSessionName: 'smoke', + providerDeviceOrientation: 'portrait', + providerNoResignApp: true, + awsRegion: 'us-west-2', }); } finally { await closeLoopbackServer(server); From d99226b167f3417aa8d77e0dcd7e1c02a983855d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 11 Sep 2026 17:26:18 +0200 Subject: [PATCH 5/5] chore(gates): re-pin lease_allocate wire digests for the broadened projection (#2494) --- test/wire-compat/ledger.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 9d7171db0a..f049408dca 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -54,7 +54,7 @@ "src/daemon-client/daemon-client-progress.ts#createInvalidDaemonResponseError": "sha256:0af812346b667a23fdfef641b242cd86d07edaa05eb928c4c531628c09a4031b", "src/daemon-client/daemon-client-progress.ts#shouldReadDaemonProgressStream": "sha256:6811404f41d8db3fa6fc751332c7186a2399ec7ecbc4e9c547d9cb9f14dbb350", "src/daemon-client/daemon-client-rpc.ts#appErrorFromDaemonError": "sha256:ac4761006c71d93ccba9f8e37cbd4cf48283dbb22737c163dc9c1a878e154eea", - "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:c2bbf1c9ae20abc5fdc49ca540ab5a4213d764375e41740705a8a1cce11e9dc0", + "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload": "sha256:caddf184cd57a41d9910c414bba72ce5a0503e22a2a4ef3a47f95fed67037127", "src/daemon-client/daemon-client-rpc.ts#buildLeaseRpcParams": "sha256:1755f46be8c62e7a8eb409e9a94d61781c96ccbef3306702121aa94264d63267", "src/daemon-client/daemon-client-rpc.ts#handleDaemonHttpResponseBody": "sha256:8f1c4bba1918545db6296ad29b95f0baa9f949e7c23bc04d59e261130f14611e", "src/daemon-client/daemon-client-rpc.ts#isLeaseRpcCommand": "sha256:955dc1b73462d40593e5728dbcbaeec1307944b08262a22627e84cc5dc507e8f", @@ -119,7 +119,7 @@ "src/daemon/server/http-server.ts#sendJson": "sha256:a1de00da3f1db8c98e69a85dfe2e11b626a4d2ce472840e3b8f519aa8a207a8f", "src/daemon/server/http-server.ts#toDaemonRequest": "sha256:f3861d2d4f67f66e83800616926f6bbb0595de4ecbb0072b5b78971ebeb0705b", "src/daemon/server/http-server.ts#toInstallFromSourceDaemonRequest": "sha256:dff093758b433043816dfc1e2e9658078875e46b887fdc530fb9380158d629f5", - "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:c38554b76f480b5c445b1e7226f4e630c264f0c69e25a1662483d3caf8796b3e", + "src/daemon/server/http-server.ts#toLeaseDaemonRequest": "sha256:bee83566f9c5da4ede12b4844fae0930d09439ba7586123609637107b61f8644", "src/daemon/server/http-server.ts#toReleaseMaterializedPathsDaemonRequest": "sha256:708736ca03d6a3fc449bb408382dbd509e490de1f84ecc8771fd77d4adda0154", "src/daemon/server/http-server.ts#writeProgressEnvelope": "sha256:ca661f6dd2de6e517c520dbd1e20b59b24b58d925157ed06123149ed2bc0fa3b", "src/daemon/server/http-server.ts#writeRpcResponseEnvelope": "sha256:7a8155e9fcbb53485489728250a85109b5dab0d96cdd4aa8b7e7eb87a4898ddb", @@ -208,13 +208,13 @@ }, { "declaration": "src/daemon-client/daemon-client-rpc.ts#buildHttpRpcPayload", - "digest": "sha256:c2bbf1c9ae20abc5fdc49ca540ab5a4213d764375e41740705a8a1cce11e9dc0", - "rationale": "#2110 added the optional providerApp parameter to lease allocation; #2494 extends the same additive projection with providerProject, providerBuild, and providerSessionName so the daemon's lease-lifecycle provider can name the BrowserStack/AWS session instead of leaving it Untitled. Each is optional and forwarded only when the caller supplied it: a released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request carrying none of these retains the previous payload byte-for-byte." + "digest": "sha256:caddf184cd57a41d9910c414bba72ce5a0503e22a2a4ef3a47f95fed67037127", + "rationale": "#2110 added the optional providerApp parameter to lease allocation; #2494 extends the same additive projection to the full provider-allocation flag set the lease-lifecycle provider reads in prepareSession — device selection (platform, device), the app/os, the session-naming fields, and the configured device-feature and AWS knobs — so a fresh remote allocation reaches BrowserStack/AWS with a named, configured session instead of failing on a missing --device/--platform or landing in Untitled Project/Build. Producer and consumer both read one shared projection (readLeaseAllocateProviderFlags). Every field is optional and forwarded only when the caller supplied it: a released protocol-2 daemon ignores unknown JSON-RPC parameters, and a request carrying none of these retains the previous payload byte-for-byte." }, { "declaration": "src/daemon/server/http-server.ts#toLeaseDaemonRequest", - "digest": "sha256:c38554b76f480b5c445b1e7226f4e630c264f0c69e25a1662483d3caf8796b3e", - "rationale": "#2110 read the optional providerApp lease-allocation parameter into the existing flags bag; #2494 reads providerProject, providerBuild, and providerSessionName into the same bag from the shared provider-metadata projection. Released protocol-2 clients omit these and follow the unchanged allocation path; no existing field is required or reinterpreted." + "digest": "sha256:bee83566f9c5da4ede12b4844fae0930d09439ba7586123609637107b61f8644", + "rationale": "#2110 read the optional providerApp lease-allocation parameter into the existing flags bag; #2494 reads the full provider-allocation flag set into that same bag through the shared readLeaseAllocateProviderFlags projection, so the reconstructed req.flags handed to prepareSession match what the line transport forwards for free. Released protocol-2 clients omit these and follow the unchanged allocation path; no existing field is required or reinterpreted." }, { "declaration": "packages/kernel/src/errors.ts#DaemonError",