diff --git a/packages/contracts/src/platform-runtime.ts b/packages/contracts/src/platform-runtime.ts index c46fd84b92..15c0ef20f7 100644 --- a/packages/contracts/src/platform-runtime.ts +++ b/packages/contracts/src/platform-runtime.ts @@ -108,7 +108,13 @@ export type RuntimeOperationUnavailability = Readonly<{ | 'unsupported-device-backend' | 'unsupported-device-scope' | 'unsupported-provider-mode' - | 'owner-capability-missing'; + | 'owner-capability-missing' + /** + * A hosted provider owns the network path for its session, so a live `network dump` has no + * source to read. Where the provider records traffic at all, it publishes that report only + * after its own session ends, on the provider's side. + */ + | 'network-capture-post-session'; hint?: string; }>; @@ -346,7 +352,8 @@ function isRuntimeOperationUnavailabilityReason( value === 'unsupported-device-backend' || value === 'unsupported-device-scope' || value === 'unsupported-provider-mode' || - value === 'owner-capability-missing' + value === 'owner-capability-missing' || + value === 'network-capture-post-session' ); } diff --git a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts index f49cb291d5..a7fcbc9ac7 100644 --- a/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts +++ b/packages/daemon/src/sessions/session-observability/internal/__tests__/session-network-runtime-parity.test.ts @@ -144,6 +144,44 @@ test('network admission remains fail-closed before parsing invalid input', async code: 'UNSUPPORTED_OPERATION', message: 'network is not supported on this device', hint: 'Network capture is unavailable for this platform leaf.', + details: { reason: 'unsupported-platform-leaf' }, + }, + }); + expect(runtime.uses).toEqual([{ required: [], preferred: ['networkDump'] }]); + expect(runtime.networkDump).not.toHaveBeenCalled(); +}); + +test('a provider whose capture is post-session reports that typed reason on the wire', async () => { + const sessionStore = makeSessionStore(); + const session = makeSession('provider-post-session', { + platform: 'android', + id: 'browserstack-device', + name: 'BrowserStack device', + kind: 'device', + }); + sessionStore.publish(session.name, session); + const runtime = createNetworkRuntime( + session.device, + // The answer the removed code produced: an empty dump that reads as "no traffic". The refusal + // must win before this is reached, so an ok response here fails the assertions below. + async () => emptyAppLogResult('android'), + { + available: false, + reason: 'network-capture-post-session', + hint: 'The provider publishes any network capture only after its session ends, on the provider dashboard.', + }, + providerRuntimeOwner('browserstack', 'default'), + ); + + const response = await runNetwork(session.name, sessionStore, runtime.bindDevice); + + expect(response).toEqual({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'network is not supported on this device', + hint: 'The provider publishes any network capture only after its session ends, on the provider dashboard.', + details: { reason: 'network-capture-post-session' }, }, }); expect(runtime.uses).toEqual([{ required: [], preferred: ['networkDump'] }]); diff --git a/packages/daemon/src/sessions/session-observability/internal/session-network.ts b/packages/daemon/src/sessions/session-observability/internal/session-network.ts index 95e1f73328..b7bc332fb5 100644 --- a/packages/daemon/src/sessions/session-observability/internal/session-network.ts +++ b/packages/daemon/src/sessions/session-observability/internal/session-network.ts @@ -12,6 +12,7 @@ import { type DaemonResponse, } from '@agent-device/kernel/contracts'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { unsupportedOperationResponse } from '../../../core/runtime-admission.ts'; import type { BindDeviceRuntime } from '../../../core/request-runtime-binding.ts'; import type { SessionStore } from '../../../core/session-store.ts'; import type { DaemonRequest } from '../../../core/daemon-request.ts'; @@ -46,7 +47,7 @@ export async function handleNetworkCommand(params: NetworkHandlerParams): Promis const bindDevice = requireNetworkBindDevice(params.bindDevice); const admission = await bindDevice(session.device, networkAdmissionUse); const networkFact = admission.facts.networkDump; - if (!networkFact.available) return unsupportedNetworkResponse(networkFact.hint); + if (!networkFact.available) return unsupportedOperationResponse('network', networkFact); const request = resolveNetworkRequest(params.req); if (!request.ok) return request; @@ -133,17 +134,6 @@ function resolveNetworkIncludeMode( return { ok: true, include: requestedInclude as NetworkIncludeMode }; } -function unsupportedNetworkResponse(hint: string | undefined): DaemonFailureResponse { - return { - ok: false, - error: { - code: 'UNSUPPORTED_OPERATION', - message: 'network is not supported on this device', - hint, - }, - }; -} - function networkDumpResponse( result: NetworkDumpResult, request: Pick, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index b2cb2b86d9..0bf08f14a4 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -10,14 +10,16 @@ import { type CloudWebDriverCapabilityOverrides, } from './capabilities.ts'; import type { CloudWebDriverPlatform } from './runtime.ts'; +import { CLOUD_WEBDRIVER_PROVIDERS } from './providers.ts'; /** The declared map fact generation now reads. Defaults to a provider with no overrides. */ function capabilities( platform: CloudWebDriverPlatform = 'android', overrides?: CloudWebDriverCapabilityOverrides, + provider: string = CLOUD_WEBDRIVER_PROVIDERS.browserStack, ) { return createCloudWebDriverCapabilities({ - provider: 'browserstack', + provider, platform, ...(overrides ? { overrides } : {}), }); @@ -32,7 +34,7 @@ const device: DeviceInfo = { booted: true, }; -test('direct WebDriver network uses only the canonical session log and preserves empty success', async () => { +test('direct WebDriver refuses a live network dump instead of answering from another source', async () => { const run = vi.fn(async () => ({ stdout: 'must not execute', stderr: '', exitCode: 0 })); const owner = createWebDriverPlatformRuntimeOwner({ host: host(run), @@ -50,20 +52,12 @@ test('direct WebDriver network uses only the canonical session log and preserves }, }); - await expect( - binding.operations.networkDump?.({ - sessionId: 'one', - maxEntries: 25, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 4000, - }), - ).resolves.toMatchObject({ - source: 'app-log', - backend: 'android', - dump: { exists: false, entries: [] }, + expect(binding.operations.networkDump).toBeUndefined(); + expect(binding.facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'network-capture-post-session', + hint: 'The provider publishes any network capture only after its session ends, on the provider dashboard.', }); - expect(binding.facts.operations.networkDump).toEqual({ available: true }); expect(binding.facts.operations.appLogInspect).toMatchObject({ available: false }); for (const operation of [ 'deployApp', @@ -80,8 +74,53 @@ test('direct WebDriver network uses only the canonical session log and preserves expect(run).not.toHaveBeenCalled(); }); +test('a provider with no capture of its own states the same refusal for a live dump', async () => { + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner(CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm, 'android'), + ownsDevice: () => true, + capabilities: capabilities('android', undefined, CLOUD_WEBDRIVER_PROVIDERS.awsDeviceFarm), + }); + + expect(await owner.inspectFacts({ ...device, id: 'aws-device-farm:lease-one' })).toMatchObject({ + operations: { + networkDump: { + available: false, + reason: 'network-capture-post-session', + hint: 'The provider publishes any network capture only after its session ends, on the provider dashboard.', + }, + }, + }); +}); + +/** + * The `network` cell is also the fallback the shared hub gives every optional cell this owner leaves + * unclassified, `snapshot` among them, and `findText`/`querySelector` start from the snapshot cell. + * So the post-session reason must be stated on `networkDump` alone, or an unrelated read reports it. + */ +test('the post-session refusal stays on networkDump and does not leak into the text reads', async () => { + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('browserstack', 'android'), + ownsDevice: () => true, + capabilities: capabilities(), + }); + + const facts = await owner.inspectFacts(device); + + expect(facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'network-capture-post-session', + }); + for (const operation of ['findText', 'querySelector'] as const) { + expect(facts.operations[operation].available).toBe(false); + if (facts.operations[operation].available) throw new Error(`${operation} admitted a live read`); + expect(facts.operations[operation].reason).toBe('unsupported-provider-mode'); + } +}); + test.each([ - ['Android', device, 'android'], + ['Android', device], [ 'iOS', { @@ -91,9 +130,8 @@ test.each([ id: 'browserstack:ios:lease-one', name: 'Remote iPhone', }, - 'ios-device', ], -])('classifies the direct WebDriver %s network cell', async (_name, runtimeDevice, backend) => { +])('classifies the direct WebDriver %s device binding', async (_name, runtimeDevice) => { const owner = createWebDriverPlatformRuntimeOwner({ host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), owner: providerRuntimeOwner('browserstack', String(_name).toLowerCase()), @@ -114,7 +152,10 @@ test.each([ available: false, reason: 'unsupported-provider-mode', }); - expect(binding.facts.operations.networkDump).toEqual({ available: true }); + expect(binding.facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'network-capture-post-session', + }); expect(binding.facts.operations.ensureReady).toEqual({ available: true }); expect(binding.facts.operations.bootTarget).toEqual({ available: true }); expect(binding.facts.operations.bootTargetHeadless).toMatchObject({ available: false }); @@ -130,15 +171,6 @@ test.each([ id: runtimeDevice.id, booted: true, }); - await expect( - binding.operations.networkDump?.({ - sessionId: 'one', - maxEntries: 25, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 4000, - }), - ).resolves.toMatchObject({ source: 'app-log', backend }); }); test.each([ @@ -703,56 +735,6 @@ test('Android TV hardware buttons are refused before a WebDriver interactor is a expect(getInteractor).not.toHaveBeenCalled(); }); -test('an Apple WebDriver dump bounded to one entry reports unnamed traffic without its identities', async () => { - // Five keep-alive requests CFNetwork logged no URL for, and no connection - // line to resolve them against: many more unnamed tasks than maxEntries. - const summaries = Array.from( - { length: 5 }, - (_, index) => - `2026-09-09 18:22:28.167 Df app[1:2] [com.apple.CFNetwork:Summary] Task <2FAEF670>.<${index + 10}> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1}`, - ); - const base = host(vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))); - const owner = createWebDriverPlatformRuntimeOwner({ - host: { - ...base, - appLogs: { - ...base.appLogs, - readRecent: async () => ({ - path: '/sessions/one/app.log', - exists: true, - text: `${summaries.join('\n')}\n`, - skippedLines: 0, - }), - }, - }, - owner: providerRuntimeOwner('browserstack', 'apple'), - ownsDevice: () => true, - capabilities: capabilities(), - }); - const binding = await owner.bind({ - device: { ...device, platform: 'apple', appleOs: 'ios', id: 'browserstack:lease-ios' }, - intent: { kind: 'ordinary' }, - scope: { - signal: new AbortController().signal, - diagnostics: { emit: () => {} }, - progress: { report: () => {} }, - }, - }); - - const result = await binding.operations.networkDump?.({ - sessionId: 'one', - maxEntries: 1, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 4000, - }); - - if (result?.source !== 'app-log') throw new Error('expected app-log result'); - expect(result.backend).toBe('ios-device'); - expect(result.dump.unnamedRequests).toBe(5); - expect(result.dump).not.toHaveProperty('unnamedRequestIds'); -}); - test('an iOS WebDriver device serves keyboard dismiss, and neither the unproven enter nor the Android IME probe', async () => { const iosDevice: DeviceInfo = { ...device, diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 2c3df40124..b4ebe45a58 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -67,7 +67,6 @@ import { } from '@agent-device/contracts/touch-runtime'; import { viewportRuntimeOperationFacts } from '@agent-device/contracts/viewport-runtime'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; -import { readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { type WebDriverDeploymentRuntime, webDriverInactiveSession } from './runtime-deployment.ts'; @@ -86,6 +85,16 @@ type WebDriverPlatformDeploymentRuntime = Pick< const available = Object.freeze({ available: true } as const); const appLogUnavailable = unavailableFact('unsupported-provider-mode'); +/** + * A live `network dump` needs a source the session can read. A hosted provider keeps traffic on its + * own side: whatever the provider captures at all, it publishes only once its session ends, through + * the provider's own dashboard or session records. Nothing is readable live, and the runtime must + * not answer with an empty dump that looks like "no traffic". + */ +const networkCaptureUnavailable = unavailableFact( + 'network-capture-post-session', + 'The provider publishes any network capture only after its session ends, on the provider dashboard.', +); const recordingUnavailable = unavailableFact( 'unsupported-provider-mode', 'WebDriver provider runtimes do not expose screen recording.', @@ -405,7 +414,6 @@ function bindWebDriverPlatformRuntime( device: DeviceInfo, signal: AbortSignal, ): DeviceBinding { - const backend = device.platform === 'apple' ? 'ios-device' : 'android'; const facts = webDriverFacts(options, device); const deploy = facts.operations.deployApp; const operations: DeviceBinding['operations'] = Object.freeze({ @@ -420,26 +428,6 @@ function bindWebDriverPlatformRuntime( facts.operations, ), ...webDriverInteractionOperations(options, device, signal, facts), - networkDump: async (input) => { - const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); - const { dump } = readRecentNetworkTrafficFromText(recent.text, { - ...input, - path: recent.path, - exists: recent.exists, - lineNumberOffset: recent.skippedLines, - backend, - }); - return Object.freeze({ - source: 'app-log' as const, - backend, - dump, - notes: Object.freeze( - dump.entries.length === 0 - ? ['No HTTP(s) entries were found in recent session app logs.'] - : [], - ), - }); - }, ...(deploy.available && options.deployment ? { deployApp: async (input: AppDeploymentInput) => @@ -564,7 +552,6 @@ function webDriverFacts( deployMaterializedApp: deployment, sendPushNotification: pushUnavailable, appState: appStateUnavailable, - networkDump: available, ...snapshotRuntimeOperationFacts({ capture: snapshotCell, customActions: snapshotCustomActionsUnavailable, @@ -677,6 +664,10 @@ function webDriverFacts( bootTarget: available, bootTargetHeadless: headlessUnavailable, listApps: appsUnavailable, + // Stated here rather than through the `network` cell: `network` is the fallback the shared + // hub gives every optional cell this owner leaves unclassified, so putting the network-capture + // reason there would report it for unrelated reads. Only this cell refuses post-session. + networkDump: networkCaptureUnavailable, shutdownTarget: unavailableFact( 'unsupported-provider-mode', 'WebDriver owns the target lifecycle for provider-owned devices.', diff --git a/website/docs/docs/debugging-profiling.md b/website/docs/docs/debugging-profiling.md index ee1131cf1b..b8769b25c4 100644 --- a/website/docs/docs/debugging-profiling.md +++ b/website/docs/docs/debugging-profiling.md @@ -223,6 +223,7 @@ agent-device network dump 25 --include all - On iOS, `network dump` can return zero entries for real traffic when the app does not log request metadata to Unified Logging. The response notes tell an empty repro window apart from a log window with no network lines. - On iOS, the system logs a request URL only when it opens a connection. Requests that reuse a keep-alive connection are reported against that connection's origin with `pathUnavailable: true`, their status, and timing. Requests whose connection opened before the scanned window are only counted in `unnamedRequests`. Treat an endpoint missing from an iOS dump as unproven, not as never called. - On web, request and response bodies are not available; use `agent-browser` HAR capture directly when you need them. +- On BrowserStack, AWS Device Farm, and TestMu AI sessions, `network dump` refuses with `UNSUPPORTED_OPERATION` and `details.reason: "network-capture-post-session"`. Those providers own the network path, so the session has no live source to read. Where the provider records traffic at all, it publishes the report on its own dashboard after its session ends. Limrun keeps live `network dump`. See [Device clouds](/docs/device-clouds). - For the alias and scan limits, see [Media and logs](/docs/commands#media-and-logs). ### Audio probes diff --git a/website/docs/docs/device-clouds.md b/website/docs/docs/device-clouds.md index 42f16761ff..a795940ac6 100644 --- a/website/docs/docs/device-clouds.md +++ b/website/docs/docs/device-clouds.md @@ -23,6 +23,7 @@ Use a device cloud or farm when an agent needs to drive a hosted mobile device w | App source | `--provider-app`: `bs://` reference, HTTP(S) URL, or local path | `--aws-app-arn` upload ARN; no install after allocation | `--provider-app`: `lt://` reference, HTTP(S) URL, or local path | `install `, or `open ` for an uploaded asset | | Artifacts after `close` | Video, Appium and device logs, dashboard and public links | Video and log files, once AWS finalizes them | Video, Appium, device, network, and command logs, screenshots, dashboard link | None | | Port reverse | No; use BrowserStack Local | No | No | Android over ADB; not on iOS | +| Live `network dump` | No; the provider owns the network path | No; the provider owns the network path | No; the provider owns the network path | Yes | ## Shared workflow