diff --git a/packages/contracts/src/__tests__/lease-scope.test.ts b/packages/contracts/src/__tests__/lease-scope.test.ts index 7d081e984d..daad373eb2 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, + readLeaseAllocateProviderFlags, } from '../lease-scope.ts'; test('leaseScopeFromOptions normalizes public aliases and projects request meta', () => { @@ -166,3 +167,51 @@ test('findMissingProxyLeaseFields enforces complete proxy ownership scope', () = [], ); }); + +test('readLeaseAllocateProviderFlags carries the provider-allocation flags and drops the rest', () => { + assert.deepEqual( + readLeaseAllocateProviderFlags({ + session: 'default', + token: 'secret', + runId: 'run-a', + deviceKey: 'dk-1', + provider: 'browserstack', + ttlMs: 1000, + 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', + }), + { + 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( + readLeaseAllocateProviderFlags({ providerApp: 'bs://abc', providerBuild: undefined }), + { providerApp: 'bs://abc' }, + ); + assert.deepEqual(readLeaseAllocateProviderFlags(undefined), {}); +}); diff --git a/packages/contracts/src/lease-scope.ts b/packages/contracts/src/lease-scope.ts index 530fa97c10..19646632f9 100644 --- a/packages/contracts/src/lease-scope.ts +++ b/packages/contracts/src/lease-scope.ts @@ -1,5 +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; @@ -197,6 +199,63 @@ export function leaseScopeToLeaseRpcParams( } } +/** + * 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_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', + 'providerDeviceOrientation', + 'providerGeoLocation', + 'providerTimezone', + 'providerLanguage', + 'providerLocale', + 'providerNetworkProfile', + 'providerCustomNetwork', + 'providerNoResignApp', + 'awsProjectArn', + 'awsDeviceArn', + 'awsAppArn', + 'awsRegion', + 'awsInteractionMode', +] as const satisfies readonly (keyof CommandFlags)[]; + +type LeaseAllocateProviderFlagKey = (typeof LEASE_ALLOCATE_PROVIDER_FLAG_KEYS)[number]; + +// 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, +): LeaseAllocateProviderFlags { + const flags: Record = {}; + for (const key of LEASE_ALLOCATE_PROVIDER_FLAG_KEYS) { + const value = source?.[key]; + if (typeof value === 'string' || typeof value === 'boolean') flags[key] = value; + } + return flags as LeaseAllocateProviderFlags; +} + 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..faa8c6e835 100644 --- a/src/daemon-client/daemon-client-rpc.test.ts +++ b/src/daemon-client/daemon-client-rpc.test.ts @@ -33,6 +33,53 @@ test('lease allocation transports an optional initial provider app', () => { }); }); +test('lease allocation transports the provider configuration the session needs (#2494)', () => { + const payload = buildHttpRpcPayload( + { + token: 'daemon-token', + session: 'qa-ios', + command: 'lease_allocate', + positionals: [], + flags: { + 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, + }, + 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', + 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, + }); +}); + 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..4cdf6d54fb 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, + readLeaseAllocateProviderFlags, 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' ? readLeaseAllocateProviderFlags(req.flags) : {}), }, }; } diff --git a/src/daemon/server/http-server.ts b/src/daemon/server/http-server.ts index f218d2271e..c212173897 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 { 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'; @@ -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' ? 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 new file mode 100644 index 0000000000..fd615dd7fc --- /dev/null +++ b/test/integration/provider-scenarios/daemon-http-lease-allocate.test.ts @@ -0,0 +1,77 @@ +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 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[] = []; + 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', + 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', + }, + }), + }); + assert.equal(response.status, 200); + + 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); + } +}); diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 4e355b9c01..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:efa9a5da4c7288cceae5656b468946ad3a2af0f0f0fb940f8a37987f922aa4b8", + "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:719b5b8c29a95e9d2be01850bb97c8120f5a04aecc09c265bebfc6631e41ffa3", + "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: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: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: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: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",