From ca905c8b6f4d30a65c8bf902f7cb844cfdd58466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 10 Aug 2026 02:12:24 +0200 Subject: [PATCH 1/3] refactor: extract platform network runtime --- fallow-baselines/health.json | 9 - package.json | 2 +- .../capture-kit/src/app-log-runtime.test.ts | 32 - .../src/app-log-unavailable-runtime.test.ts | 83 --- .../src/app-log-unavailable-runtime.ts | 70 -- packages/capture-kit/src/index.ts | 8 +- .../src/network-traffic-android.test.ts | 37 ++ .../src/network-traffic-android.ts | 52 ++ .../src/network-traffic-value.test.ts | 28 + .../capture-kit/src/network-traffic-value.ts | 92 +++ .../capture-kit/src/network-traffic.test.ts | 139 ++++ packages/capture-kit/src/network-traffic.ts | 240 +++++++ .../src/platform-runtime-unavailable.test.ts | 72 +++ .../src/platform-runtime-unavailable.ts | 80 +++ packages/contracts/src/app-log-runtime.ts | 14 - packages/contracts/src/facades/platform.ts | 32 +- packages/contracts/src/logs-runtime-plan.ts | 4 +- packages/contracts/src/network-log.ts | 5 +- .../src/network-runtime-plan.test.ts | 54 ++ .../contracts/src/network-runtime-plan.ts | 50 ++ .../contracts/src/network-runtime.test.ts | 48 ++ packages/contracts/src/network-runtime.ts | 85 +++ packages/contracts/src/network-traffic.ts | 25 + .../src/platform-runtime-operations.test.ts | 33 + .../src/platform-runtime-operations.ts | 24 + packages/platform-android/src/index.ts | 8 +- .../src/network/runtime.test.ts | 153 +++++ .../platform-android/src/network/runtime.ts | 166 +++++ packages/platform-android/src/runtime.test.ts | 34 + packages/platform-android/src/runtime.ts | 39 ++ packages/platform-apple/src/index.ts | 8 +- .../src/network/runtime.test.ts | 156 +++++ .../platform-apple/src/network/runtime.ts | 142 +++++ packages/platform-apple/src/runtime.test.ts | 65 ++ packages/platform-apple/src/runtime.ts | 39 ++ packages/platform-harmonyos/src/index.ts | 8 +- .../platform-harmonyos/src/runtime.test.ts | 35 + packages/platform-harmonyos/src/runtime.ts | 36 ++ packages/platform-linux/src/index.ts | 8 +- packages/platform-linux/src/logs/runtime.ts | 11 - packages/platform-linux/src/runtime.test.ts | 25 + packages/platform-linux/src/runtime.ts | 14 + packages/platform-vega/src/index.ts | 8 +- packages/platform-vega/src/logs/runtime.ts | 11 - packages/platform-vega/src/runtime.test.ts | 25 + packages/platform-vega/src/runtime.ts | 14 + packages/platform-web/package.json | 3 +- packages/platform-web/src/index.ts | 10 +- packages/platform-web/src/logs/runtime.ts | 11 - packages/platform-web/src/runtime.test.ts | 116 ++++ packages/platform-web/src/runtime.ts | 85 +++ .../src/app-log-runtime.test.ts | 87 ++- .../provider-limrun/src/app-log-runtime.ts | 44 +- packages/provider-limrun/src/runtime.ts | 31 +- packages/provider-webdriver/package.json | 1 + .../src/platform-runtime.test.ts | 134 ++++ .../src/platform-runtime.ts | 102 +++ packages/provider-webdriver/src/runtime.ts | 17 + pnpm-lock.yaml | 6 + scripts/layering/check.ts | 26 + .../network-runtime-cutover-policy.test.ts | 107 ++++ .../network-runtime-cutover-policy.ts | 257 ++++++++ .../layering/platform-composition-policy.ts | 4 +- .../layering/platform-package-policy.test.ts | 8 +- .../provider-device-runtimes.test.ts | 10 +- src/core/__tests__/capabilities.test.ts | 5 +- .../capability-plugin-routing-parity.test.ts | 2 + src/core/capabilities.ts | 1 - .../network-runtime-execution.test.ts | 18 + .../__tests__/parity.test.ts | 5 +- src/core/command-descriptor/registry.ts | 3 +- .../app-log-resource-recovery.test.ts | 25 +- src/daemon/__tests__/network-log.test.ts | 162 ----- .../request-runtime-binding-router.test.ts | 27 +- .../__tests__/request-runtime-binding.test.ts | 28 +- .../__tests__/test-device-runtime-gateway.ts | 5 +- src/daemon/app-log-network-recovery.ts | 242 ------- src/daemon/app-log-resource-recovery.ts | 18 +- .../__tests__/network-runtime-harness.ts | 83 +++ .../__tests__/session-capabilities.test.ts | 171 +++-- .../handlers/__tests__/session-logs.test.ts | 24 +- ...session-network-runtime-parity-fixtures.ts | 9 + .../session-network-runtime-parity.test.ts | 225 +++++++ .../__tests__/session-network.test.ts | 602 +----------------- .../__tests__/session-observability.test.ts | 14 + src/daemon/handlers/session-inventory.ts | 21 +- src/daemon/handlers/session-network.ts | 177 +++++ src/daemon/handlers/session-observability.ts | 133 +--- src/daemon/network-log-android-recovery.ts | 148 ----- .../network-log-ios-simulator-recovery.ts | 76 --- src/daemon/network-log.ts | 372 ----------- src/daemon/request-execution-scope.ts | 4 +- src/daemon/request-router.ts | 4 +- src/daemon/request-runtime-binding.ts | 14 +- src/daemon/server/daemon-runtime.ts | 8 +- ...st.ts => platform-runtime-gateway.test.ts} | 43 +- ...app-log.ts => platform-runtime-gateway.ts} | 103 +-- src/platform-runtime-network-host.test.ts | 75 +++ src/platform-runtime-network-host.ts | 70 ++ src/platform-runtime-network-web-transport.ts | 22 + ...> platform-runtime-operation-host.test.ts} | 6 +- ....ts => platform-runtime-operation-host.ts} | 45 +- src/platform-runtime.ts | 30 +- src/platforms/web/provider.ts | 5 + src/provider-device-runtimes.ts | 12 +- .../ios-simulator-e2e/coverage-manifest.ts | 4 +- .../provider-scenarios/android-world.ts | 2 +- .../apple-app-log-runtime-provider.test.ts | 2 +- .../integration/provider-scenarios/harness.ts | 10 +- .../ios-alert-settings.test.ts | 12 +- .../provider-scenarios/macos-world.ts | 2 +- .../provider-scenarios/web-provider.test.ts | 1 + 112 files changed, 4209 insertions(+), 2243 deletions(-) delete mode 100644 packages/capture-kit/src/app-log-unavailable-runtime.test.ts delete mode 100644 packages/capture-kit/src/app-log-unavailable-runtime.ts create mode 100644 packages/capture-kit/src/network-traffic-android.test.ts create mode 100644 packages/capture-kit/src/network-traffic-android.ts create mode 100644 packages/capture-kit/src/network-traffic-value.test.ts create mode 100644 packages/capture-kit/src/network-traffic-value.ts create mode 100644 packages/capture-kit/src/network-traffic.test.ts create mode 100644 packages/capture-kit/src/network-traffic.ts create mode 100644 packages/capture-kit/src/platform-runtime-unavailable.test.ts create mode 100644 packages/capture-kit/src/platform-runtime-unavailable.ts create mode 100644 packages/contracts/src/network-runtime-plan.test.ts create mode 100644 packages/contracts/src/network-runtime-plan.ts create mode 100644 packages/contracts/src/network-runtime.test.ts create mode 100644 packages/contracts/src/network-runtime.ts create mode 100644 packages/contracts/src/network-traffic.ts create mode 100644 packages/contracts/src/platform-runtime-operations.test.ts create mode 100644 packages/contracts/src/platform-runtime-operations.ts create mode 100644 packages/platform-android/src/network/runtime.test.ts create mode 100644 packages/platform-android/src/network/runtime.ts create mode 100644 packages/platform-android/src/runtime.test.ts create mode 100644 packages/platform-android/src/runtime.ts create mode 100644 packages/platform-apple/src/network/runtime.test.ts create mode 100644 packages/platform-apple/src/network/runtime.ts create mode 100644 packages/platform-apple/src/runtime.test.ts create mode 100644 packages/platform-apple/src/runtime.ts create mode 100644 packages/platform-harmonyos/src/runtime.test.ts create mode 100644 packages/platform-harmonyos/src/runtime.ts delete mode 100644 packages/platform-linux/src/logs/runtime.ts create mode 100644 packages/platform-linux/src/runtime.test.ts create mode 100644 packages/platform-linux/src/runtime.ts delete mode 100644 packages/platform-vega/src/logs/runtime.ts create mode 100644 packages/platform-vega/src/runtime.test.ts create mode 100644 packages/platform-vega/src/runtime.ts delete mode 100644 packages/platform-web/src/logs/runtime.ts create mode 100644 packages/platform-web/src/runtime.test.ts create mode 100644 packages/platform-web/src/runtime.ts create mode 100644 packages/provider-webdriver/src/platform-runtime.test.ts create mode 100644 packages/provider-webdriver/src/platform-runtime.ts create mode 100644 scripts/layering/network-runtime-cutover-policy.test.ts create mode 100644 scripts/layering/network-runtime-cutover-policy.ts create mode 100644 src/core/command-descriptor/__tests__/network-runtime-execution.test.ts delete mode 100644 src/daemon/__tests__/network-log.test.ts delete mode 100644 src/daemon/app-log-network-recovery.ts create mode 100644 src/daemon/handlers/__tests__/network-runtime-harness.ts create mode 100644 src/daemon/handlers/__tests__/session-network-runtime-parity-fixtures.ts create mode 100644 src/daemon/handlers/__tests__/session-network-runtime-parity.test.ts create mode 100644 src/daemon/handlers/session-network.ts delete mode 100644 src/daemon/network-log-android-recovery.ts delete mode 100644 src/daemon/network-log-ios-simulator-recovery.ts delete mode 100644 src/daemon/network-log.ts rename src/{platform-runtime-app-log.test.ts => platform-runtime-gateway.test.ts} (88%) rename src/{platform-runtime-app-log.ts => platform-runtime-gateway.ts} (66%) create mode 100644 src/platform-runtime-network-host.test.ts create mode 100644 src/platform-runtime-network-host.ts create mode 100644 src/platform-runtime-network-web-transport.ts rename src/{platform-runtime-app-log-host.test.ts => platform-runtime-operation-host.test.ts} (78%) rename src/{platform-runtime-app-log-host.ts => platform-runtime-operation-host.ts} (63%) diff --git a/fallow-baselines/health.json b/fallow-baselines/health.json index 1213fc7ced..58d5fb8faf 100644 --- a/fallow-baselines/health.json +++ b/fallow-baselines/health.json @@ -283,14 +283,6 @@ "count": 1 } }, - "src/daemon/network-log.ts": { - "complexity_critical": { - "count": 1 - }, - "crap_critical": { - "count": 1 - } - }, "src/daemon/recording-gestures.ts": { "complexity_moderate": { "count": 1 @@ -579,7 +571,6 @@ "src/daemon/session-routing.ts:high impact", "src/daemon/handlers/session-state.ts:complexity", "src/snapshot/snapshot-processing.ts:high impact", - "src/daemon/network-log.ts:high impact", "src/commands/cli-grammar/common.ts:high impact", "src/daemon/snapshot-presentation/tree.ts:high impact", "src/utils/success-text.ts:high impact", diff --git a/package.json b/package.json index 7ff606db5e..affa227311 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,7 @@ "check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/run.test.ts", "check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts", "check:coverage-changed:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts", - "check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/logs-runtime-cutover-policy.test.ts scripts/layering/contracts-implementation-policy.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts", + "check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/logs-runtime-cutover-policy.test.ts scripts/layering/network-runtime-cutover-policy.test.ts scripts/layering/contracts-implementation-policy.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts", "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts", "depgraph:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", diff --git a/packages/capture-kit/src/app-log-runtime.test.ts b/packages/capture-kit/src/app-log-runtime.test.ts index 9fdb56b841..0c57dc4e56 100644 --- a/packages/capture-kit/src/app-log-runtime.test.ts +++ b/packages/capture-kit/src/app-log-runtime.test.ts @@ -2,14 +2,11 @@ import assert from 'node:assert/strict'; import { test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { - localRuntimeOwner, - providerRuntimeOwner, type AppLogCompletion, type AppLogBackgroundProcessRequest, type AppLogProcessOwnership, type AppLogProcessTransport, type AppLogRuntimeHost, - type AppLogRuntimeProviderModule, type DurableDescriptorCodec, } from '@agent-device/contracts/platform'; import { APP_LOG_ENVELOPE_FIXTURE } from './durable-resource-envelope.fixtures.ts'; @@ -26,18 +23,6 @@ const completion: AppLogCompletion = { completedAt: 42, }; -function compileTimeProviderModuleProof(): void { - const invalid: AppLogRuntimeProviderModule = { - // @ts-expect-error Provider modules cannot advertise a local-family owner. - owner: localRuntimeOwner('apple'), - loadRuntime: async () => { - throw new Error('not loaded'); - }, - }; - void invalid; -} -void compileTimeProviderModuleProof; - function compileTimeProcessTransportProof(): void { const invalid: AppLogProcessTransport = { // @ts-expect-error A direct provider runtime is an owner, not a narrow process transport. @@ -230,20 +215,3 @@ test('app-log artifact authority resolves canonical paths from durable session i pidPath: '/sessions/session-a/app-log.pid', }); }); - -test('app-log provider module exposes inert exact-owner metadata without loading mechanics', () => { - const loadRuntime = vi.fn(async () => { - throw new Error('not loaded'); - }); - const module: AppLogRuntimeProviderModule = { - owner: providerRuntimeOwner('limrun', 'tenant-a'), - loadRuntime, - }; - - assert.deepEqual(module.owner, { - kind: 'provider-runtime', - provider: 'limrun', - instance: 'tenant-a', - }); - assert.equal(loadRuntime.mock.calls.length, 0); -}); diff --git a/packages/capture-kit/src/app-log-unavailable-runtime.test.ts b/packages/capture-kit/src/app-log-unavailable-runtime.test.ts deleted file mode 100644 index 5f4c6688fa..0000000000 --- a/packages/capture-kit/src/app-log-unavailable-runtime.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; -import { localRuntimeOwner, providerRuntimeOwner } from '@agent-device/contracts/platform'; -import { - createUnavailableAppLogBinding, - createUnavailableAppLogRuntimeOwner, -} from './app-log-unavailable-runtime.ts'; - -const scope = { - signal: new AbortController().signal, - diagnostics: { emit: () => undefined }, - progress: { report: () => undefined }, -}; - -test('unavailable app-log owner binds only its exact family and publishes complete facts', async () => { - const runtime = createUnavailableAppLogRuntimeOwner('vega', { - available: false, - reason: 'unsupported-platform-leaf', - hint: 'No app-log transport.', - }); - const device = { platform: 'vega', id: 'vega', name: 'Vega', kind: 'device' } as const; - const binding = await runtime.bind({ device, intent: { kind: 'ordinary' }, scope }); - - assert.equal(runtime.ownsDevice(device), true); - assert.deepEqual(binding.owner, localRuntimeOwner('vega')); - assert.deepEqual(Object.keys(binding.operations), []); - assert.deepEqual( - new Set(Object.values(binding.facts.operations)), - new Set([ - { available: false, reason: 'unsupported-platform-leaf', hint: 'No app-log transport.' }, - ]), - ); - await assert.doesNotReject(() => - runtime.bind({ - device, - intent: { - kind: 'exact-owner', - owner: localRuntimeOwner('vega'), - fence: { token: 'f', generation: 1 }, - }, - scope, - }), - ); - await assert.rejects( - () => - runtime.bind({ - device, - intent: { - kind: 'exact-owner', - owner: localRuntimeOwner('linux'), - fence: { token: 'f', generation: 1 }, - }, - scope, - }), - /identity does not match/, - ); - await assert.rejects( - () => - runtime.bind({ - device: { ...device, platform: 'linux' }, - intent: { kind: 'ordinary' }, - scope, - }), - /cannot bind linux/, - ); -}); - -test('unavailable app-log binding derives provider facts without exposing operations', async () => { - const device = { platform: 'android', id: 'remote', name: 'Remote', kind: 'emulator' } as const; - const owner = providerRuntimeOwner('remote', 'default'); - const binding = createUnavailableAppLogBinding(device, owner, { - available: false, - reason: 'unsupported-provider-mode', - }); - - assert.equal(binding.facts.device.providerMode, 'provider-runtime'); - assert.deepEqual(binding.facts.operations.appLogStart, { - available: false, - reason: 'unsupported-provider-mode', - }); - assert.deepEqual(binding.operations, {}); - await binding[Symbol.asyncDispose](); -}); diff --git a/packages/capture-kit/src/app-log-unavailable-runtime.ts b/packages/capture-kit/src/app-log-unavailable-runtime.ts deleted file mode 100644 index bef9a914a1..0000000000 --- a/packages/capture-kit/src/app-log-unavailable-runtime.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { deviceShape, type DeviceInfo, type Platform } from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; -import { - localRuntimeOwner, - sameRuntimeOwner, - type AppLogRuntimeOperations, - type DeviceBinding, - type DeviceRuntimeOwner, - type RuntimeFacts, - type RuntimeOperationUnavailability, - type RuntimeOwnerRef, -} from '@agent-device/contracts/platform'; - -/** Builds an honest family owner for platforms with no app-log mechanics. */ -export function createUnavailableAppLogRuntimeOwner( - family: Platform, - fact: RuntimeOperationUnavailability, -): DeviceRuntimeOwner { - const owner = localRuntimeOwner(family); - const unavailable = Object.freeze({ ...fact }); - return Object.freeze({ - owner, - ownsDevice: (device) => device.platform === family, - bind: async (request) => { - if (request.intent.kind === 'exact-owner' && !sameRuntimeOwner(request.intent.owner, owner)) { - throw new AppError( - 'UNSUPPORTED_OPERATION', - `${family} app-log owner identity does not match`, - ); - } - if (request.device.platform !== family) { - throw new AppError( - 'UNSUPPORTED_PLATFORM', - `${family} app-log owner cannot bind ${request.device.platform}`, - ); - } - return createUnavailableAppLogBinding(request.device, owner, unavailable); - }, - shutdown: async () => undefined, - }); -} - -/** Builds the complete fact-only binding shared by unavailable local and provider owners. */ -export function createUnavailableAppLogBinding( - device: DeviceInfo, - owner: RuntimeOwnerRef, - fact: RuntimeOperationUnavailability, -): DeviceBinding { - const unavailable = Object.freeze({ ...fact }); - const facts: RuntimeFacts = Object.freeze({ - device: { - ...deviceShape(device), - providerMode: owner.kind === 'local-family' ? 'local' : 'provider-runtime', - }, - operations: { - appLogInspect: unavailable, - appLogDoctor: unavailable, - appLogStart: unavailable, - appLogReattach: unavailable, - appLogCleanup: unavailable, - }, - }); - return Object.freeze({ - device, - owner, - facts, - operations: Object.freeze({}), - [Symbol.asyncDispose]: async () => undefined, - }); -} diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts index c9ed7ba1be..f35c2334c4 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -23,6 +23,8 @@ export { assertAppLogSessionArtifacts, } from './app-log-session-artifacts.ts'; export { - createUnavailableAppLogBinding, - createUnavailableAppLogRuntimeOwner, -} from './app-log-unavailable-runtime.ts'; + createUnavailablePlatformRuntimeBinding, + createUnavailablePlatformRuntimeOwner, +} from './platform-runtime-unavailable.ts'; +export type { UnavailablePlatformRuntimeFacts } from './platform-runtime-unavailable.ts'; +export { mergeNetworkDumps, readRecentNetworkTrafficFromText } from './network-traffic.ts'; diff --git a/packages/capture-kit/src/network-traffic-android.test.ts b/packages/capture-kit/src/network-traffic-android.test.ts new file mode 100644 index 0000000000..79617c4292 --- /dev/null +++ b/packages/capture-kit/src/network-traffic-android.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { readRecentNetworkTrafficFromText } from './network-traffic.ts'; + +test('preserves Android adjacent packet enrichment', () => { + const dump = readRecentNetworkTrafficFromText( + [ + '03-31 17:43:32.564 V/GIBSDK (17434): [NetworkAgent]: packet id 23911610 added, queue size: 1', + '03-31 17:43:32.700 V/OtherTag (17434): unrelated line 1', + '03-31 17:43:32.800 V/OtherTag (17434): unrelated line 2', + '03-31 17:43:32.900 V/OtherTag (17434): unrelated line 3', + '03-31 17:43:33.000 V/OtherTag (17434): unrelated line 4', + '03-31 17:43:33.031 D/GIBSDK (17434): [NetworkAgent] packet id 23911610 total elapsed request/response time, ms: 377; response code: 200;', + '03-31 17:43:33.032 D/GIBSDK (17434): URL: https://api.example.com/fixture', + ].join('\n'), + { + path: '/sessions/one/app.log', + exists: true, + backend: 'android', + maxEntries: 5, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 100, + }, + ); + + assert.deepEqual(dump.entries[0], { + method: undefined, + url: 'https://api.example.com/fixture', + status: 200, + timestamp: '03-31 17:43:33.032', + packetId: '23911610', + durationMs: 377, + raw: '03-31 17:43:33.032 D/GIBSDK (17434): URL: https://api.example.com/fixture', + line: 7, + }); +}); diff --git a/packages/capture-kit/src/network-traffic-android.ts b/packages/capture-kit/src/network-traffic-android.ts new file mode 100644 index 0000000000..08bd1222b2 --- /dev/null +++ b/packages/capture-kit/src/network-traffic-android.ts @@ -0,0 +1,52 @@ +import type { NetworkEntry } from '@agent-device/contracts/observability'; +import { parseNetworkStatusCode, parseNetworkTimestamp } from './network-traffic-value.ts'; + +const NEARBY_LINE_RADIUS = 5; +const PACKET_SCAN_RADIUS = 12; + +export function enrichNetworkEntryFromAndroidLines( + result: NetworkEntry, + lines: string[], + lineIndex: number, +): void { + const nearby = collectNearbyLines(lines, lineIndex, NEARBY_LINE_RADIUS); + const packetId = + result.packetId ?? + nearby + .map(parseAndroidPacketId) + .find((value): value is string => typeof value === 'string' && value.length > 0); + if (packetId) result.packetId = packetId; + + const related = packetId + ? collectNearbyLines(lines, lineIndex, PACKET_SCAN_RADIUS).filter( + (line) => parseAndroidPacketId(line) === packetId, + ) + : nearby; + result.timestamp ??= related + .map(parseNetworkTimestamp) + .find((value): value is string => typeof value === 'string' && value.length > 0); + result.status ??= + related + .map(parseNetworkStatusCode) + .find((value): value is number => typeof value === 'number') ?? undefined; + result.durationMs ??= + related + .map(parseAndroidDurationMs) + .find((value): value is number => typeof value === 'number') ?? undefined; +} + +export function parseAndroidPacketId(line: string): string | null { + return /\bpacket id (\d+)\b/i.exec(line)?.[1] ?? null; +} + +export function parseAndroidDurationMs(line: string): number | null { + const match = /\b(?:duration|elapsed request\/response time, ms)[:= ]+(\d+)\b/i.exec(line); + return match ? Number.parseInt(match[1] ?? '', 10) : null; +} + +function collectNearbyLines(lines: string[], index: number, radius: number): string[] { + return lines + .slice(Math.max(0, index - radius), Math.min(lines.length, index + radius + 1)) + .map((line) => line.trim()) + .filter(Boolean); +} diff --git a/packages/capture-kit/src/network-traffic-value.test.ts b/packages/capture-kit/src/network-traffic-value.test.ts new file mode 100644 index 0000000000..146c94d3a7 --- /dev/null +++ b/packages/capture-kit/src/network-traffic-value.test.ts @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + parseEmbeddedNetworkJson, + parseNetworkStatusCode, + parseNetworkTimestamp, + readNetworkBody, + readNetworkHeaders, + readNetworkJsonNumber, + readNetworkJsonString, +} from './network-traffic-value.ts'; + +test('decodes embedded network fields without widening parsed JSON', () => { + const line = + 'prefix {"method":"POST","statusCode":"201","headers":{"x-id":"abc"},"requestBody":{"ok":true}}'; + const decoded = parseEmbeddedNetworkJson(line); + + assert.equal(readNetworkJsonString(decoded, ['method']), 'POST'); + assert.equal(readNetworkJsonNumber(decoded, ['statusCode']), 201); + assert.equal(readNetworkHeaders(line, decoded), '{"x-id":"abc"}'); + assert.equal(readNetworkBody(line, decoded, ['requestBody']), '{"ok":true}'); + assert.equal(parseNetworkStatusCode('response code: 204'), 204); + assert.equal(parseNetworkTimestamp('2026-04-02T08:14:44Z GET'), '2026-04-02T08:14:44Z'); +}); + +test('treats malformed embedded JSON as absent', () => { + assert.equal(parseEmbeddedNetworkJson('prefix {bad json}'), null); +}); diff --git a/packages/capture-kit/src/network-traffic-value.ts b/packages/capture-kit/src/network-traffic-value.ts new file mode 100644 index 0000000000..84e17ab998 --- /dev/null +++ b/packages/capture-kit/src/network-traffic-value.ts @@ -0,0 +1,92 @@ +export function parseNetworkStatusCode(line: string): number | null { + for (const pattern of [ + /\bstatus(?:Code)?["'=: ]+([1-5]\d{2})\b/i, + /\bresponse(?:\s+code)?["'=: ]+([1-5]\d{2})\b/i, + /\bHTTP\/[0-9.]+\s+([1-5]\d{2})\b/i, + ]) { + const match = pattern.exec(line); + if (!match) continue; + const value = Number.parseInt(match[1] ?? '', 10); + if (Number.isInteger(value)) return value; + } + return null; +} + +export function parseNetworkTimestamp(line: string): string | undefined { + return ( + /\b\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z)?\b/.exec(line)?.[0] ?? + /\b\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+\b/.exec(line)?.[0] + ); +} + +export function parseEmbeddedNetworkJson(line: string): Record | null { + const start = line.indexOf('{'); + const end = line.lastIndexOf('}'); + if (start < 0 || end <= start) return null; + try { + const parsed: unknown = JSON.parse(line.slice(start, end + 1)); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +export function readNetworkJsonString( + value: Record | null, + keys: readonly string[], +): string | undefined { + for (const key of keys) { + const next = value?.[key]; + if (typeof next === 'string' && next.trim()) return next.trim(); + } + return undefined; +} + +export function readNetworkJsonNumber( + value: Record | null, + keys: readonly string[], +): number | null { + for (const key of keys) { + const next = value?.[key]; + if (typeof next === 'number' && Number.isInteger(next)) return next; + if (typeof next === 'string' && /^\d{3}$/.test(next.trim())) { + return Number.parseInt(next, 10); + } + } + return null; +} + +export function readNetworkHeaders( + line: string, + json: Record | null, +): string | undefined { + const value = json?.headers ?? json?.requestHeaders ?? json?.responseHeaders; + return value === undefined + ? /\bheaders?["'=: ]+(\{.*\})/i.exec(line)?.[1]?.trim() + : stringifyNetworkValue(value); +} + +export function readNetworkBody( + line: string, + json: Record | null, + keys: readonly string[], +): string | undefined { + for (const key of keys) { + if (json?.[key] !== undefined) return stringifyNetworkValue(json[key]); + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`\\b${escaped}["'=: ]+(.+)$`, 'i').exec(line); + if (match?.[1]) return match[1].trim(); + } + return undefined; +} + +function stringifyNetworkValue(value: unknown): string { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/packages/capture-kit/src/network-traffic.test.ts b/packages/capture-kit/src/network-traffic.test.ts new file mode 100644 index 0000000000..5a349d886f --- /dev/null +++ b/packages/capture-kit/src/network-traffic.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from './network-traffic.ts'; + +test('parses the existing include projections and newest-first order', () => { + const dump = readRecentNetworkTrafficFromText( + [ + '2026-02-24T10:00:00Z GET https://api.example.com/profile status=200', + '2026-02-24T10:00:02Z {"method":"POST","url":"https://api.example.com/login","statusCode":401,"headers":{"x-id":"abc"},"requestBody":{"email":"u@example.com"},"responseBody":{"error":"denied"}}', + 'non-network-line', + ].join('\n'), + { + path: '/sessions/one/app.log', + exists: true, + backend: 'ios-simulator', + maxEntries: 5, + include: 'all', + maxPayloadChars: 2048, + maxScanLines: 100, + }, + ); + + assert.equal(dump.exists, true); + assert.equal(dump.entries.length, 2); + assert.deepEqual( + dump.entries.map(({ method, status }) => ({ method, status })), + [ + { method: 'POST', status: 401 }, + { method: 'GET', status: 200 }, + ], + ); + assert.equal(typeof dump.entries[0]?.headers, 'string'); + assert.equal(typeof dump.entries[0]?.requestBody, 'string'); + assert.equal(typeof dump.entries[0]?.responseBody, 'string'); +}); + +test('keeps missing canonical app-log text distinct and merges recovery first', () => { + const missing = readRecentNetworkTrafficFromText('', { + path: '/sessions/one/app.log', + exists: false, + backend: 'android', + maxEntries: 2, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 100, + }); + const stale = readRecentNetworkTrafficFromText('GET https://stale.example.test status=200', { + ...missing.limits, + path: missing.path, + exists: true, + backend: 'android', + include: 'summary', + }); + const recovered = readRecentNetworkTrafficFromText('GET https://fresh.example.test status=201', { + ...missing.limits, + path: `${missing.path} (recovery)`, + exists: true, + backend: 'android', + include: 'summary', + }); + + assert.deepEqual(missing, { + path: '/sessions/one/app.log', + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: [], + include: 'summary', + limits: { maxEntries: 2, maxPayloadChars: 2048, maxScanLines: 100 }, + }); + assert.deepEqual( + mergeNetworkDumps(recovered, stale, 2).entries.map(({ url }) => url), + ['https://fresh.example.test', 'https://stale.example.test'], + ); +}); + +test('keeps Android adjacent enrichment disabled for Apple backends', () => { + const dump = readRecentNetworkTrafficFromText( + [ + '2026-03-31 17:43:33.031 response code: 200', + '2026-03-31 17:43:33.032 URL: https://api.example.com/fixture', + ].join('\n'), + { + path: '/sessions/one/app.log', + exists: true, + backend: 'macos', + maxEntries: 5, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 100, + }, + ); + + assert.equal(dump.entries[0]?.status, undefined); + assert.equal(dump.entries[0]?.durationMs, undefined); +}); + +test('ignores documentation URLs without an explicit network signal', () => { + const dump = readRecentNetworkTrafficFromText( + '2026-04-02 08:14:44Z config warning. See https://docs.example.test/setup for help.\n', + { + path: '/sessions/one/app.log', + exists: true, + backend: 'ios-simulator', + maxEntries: 5, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 100, + }, + ); + + assert.deepEqual(dump.entries, []); +}); + +test('applies a validated absolute line offset to host-selected text', () => { + const options = { + path: '/sessions/one/app.log', + exists: true, + backend: 'android' as const, + maxEntries: 5, + include: 'summary' as const, + maxPayloadChars: 2048, + maxScanLines: 100, + }; + + const dump = readRecentNetworkTrafficFromText('GET https://example.test status=200', { + ...options, + lineNumberOffset: 5000, + }); + assert.equal(dump.entries[0]?.line, 5001); + assert.throws( + () => + readRecentNetworkTrafficFromText('GET https://example.test status=200', { + ...options, + lineNumberOffset: -1, + }), + /non-negative integer/, + ); +}); diff --git a/packages/capture-kit/src/network-traffic.ts b/packages/capture-kit/src/network-traffic.ts new file mode 100644 index 0000000000..f3fc3a34f8 --- /dev/null +++ b/packages/capture-kit/src/network-traffic.ts @@ -0,0 +1,240 @@ +import type { NetworkDump, NetworkDumpParserOptions } from '@agent-device/contracts/platform'; +import type { LogBackend, NetworkEntry } from '@agent-device/contracts/observability'; +import { + enrichNetworkEntryFromAndroidLines, + parseAndroidDurationMs, + parseAndroidPacketId, +} from './network-traffic-android.ts'; +import { + parseEmbeddedNetworkJson, + parseNetworkStatusCode, + parseNetworkTimestamp, + readNetworkBody, + readNetworkHeaders, + readNetworkJsonNumber, + readNetworkJsonString, +} from './network-traffic-value.ts'; + +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const; +const METHOD_WITH_URL_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b\\s+https?:\\/\\/`, 'i'); +const URL_REGEX = /https?:\/\/[^\s"'<>\])]+/i; + +export function mergeNetworkDumps( + primary: NetworkDump, + secondary: NetworkDump, + maxEntries = primary.limits.maxEntries, +): NetworkDump { + const entries = [...primary.entries]; + const seen = new Set(entries.map(networkEntryKey)); + for (const entry of secondary.entries) { + const key = networkEntryKey(entry); + if (seen.has(key)) continue; + seen.add(key); + entries.push(entry); + if (entries.length >= maxEntries) break; + } + return Object.freeze({ + ...primary, + matchedLines: entries.length, + entries: Object.freeze(entries), + }); +} + +export function readRecentNetworkTrafficFromText( + content: string, + options: NetworkDumpParserOptions, +): NetworkDump { + const maxEntries = clampInt(options.maxEntries, 25, 1, 200); + const include = options.include ?? 'summary'; + const maxPayloadChars = clampInt(options.maxPayloadChars, 2048, 64, 16_384); + const maxScanLines = clampInt(options.maxScanLines, 4000, 100, 20_000); + const lineNumberOffset = requireLineNumberOffset(options.lineNumberOffset); + if (!options.exists) { + return Object.freeze({ + path: options.path, + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: Object.freeze([]), + include, + limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + }); + } + const allLines = content.split('\n'); + const startIndex = Math.max(0, allLines.length - maxScanLines); + const lines = allLines.slice(startIndex); + const entries: NetworkEntry[] = []; + for (let i = lines.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) { + if (!lines[i]?.trim()) continue; + const parsed = parseNetworkLine( + lines, + i, + lineNumberOffset + startIndex + i + 1, + options.backend, + include, + maxPayloadChars, + ); + if (parsed) entries.push(parsed); + } + return Object.freeze({ + path: options.path, + exists: true, + scannedLines: lines.length, + matchedLines: entries.length, + entries: Object.freeze(entries), + include, + limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + }); +} + +function requireLineNumberOffset(value: number | undefined): number { + if (value === undefined) return 0; + if (!Number.isInteger(value) || value < 0) { + throw new TypeError('Network line number offset must be a non-negative integer'); + } + return value; +} + +function parseNetworkLine( + lines: string[], + lineIndex: number, + lineNumber: number, + backend: LogBackend | undefined, + include: NetworkDump['include'], + maxPayloadChars: number, +): NetworkEntry | null { + const line = lines[lineIndex]?.trim(); + if (!line) return null; + const maybeJson = parseEmbeddedNetworkJson(line); + const identity = parseNetworkIdentity(line, maybeJson); + if (!identity) return null; + const result = createNetworkEntry(line, lineNumber, identity, maxPayloadChars); + if (backend === 'android') enrichNetworkEntryFromAndroidLines(result, lines, lineIndex); + applyNetworkProjection(result, line, maybeJson, include, maxPayloadChars); + return result; +} + +type NetworkIdentity = Readonly<{ + method?: string; + url: string; + status?: number; +}>; + +function parseNetworkIdentity( + line: string, + maybeJson: Record | null, +): NetworkIdentity | null { + const method = parseNetworkMethod(line, maybeJson); + const url = parseNetworkUrl(line, maybeJson); + if (!url) return null; + const status = parseNetworkStatus(line, maybeJson); + if (!hasExplicitNetworkSignal(line, method.explicit, status)) { + return null; + } + return { method: method.value, url, status }; +} + +function parseNetworkMethod( + line: string, + maybeJson: Record | null, +): Readonly<{ value?: string; explicit: boolean }> { + const jsonMethod = readNetworkJsonString(maybeJson, ['method', 'httpMethod']); + const fieldMethod = /\bmethod["'=: ]+([A-Z]+)\b/i.exec(line)?.[1]; + const inlineMethod = METHOD_WITH_URL_REGEX.exec(line)?.[1]; + const value = jsonMethod ?? fieldMethod ?? inlineMethod; + return { value: value?.toUpperCase(), explicit: Boolean(value) }; +} + +function parseNetworkUrl( + line: string, + maybeJson: Record | null, +): string | undefined { + return readNetworkJsonString(maybeJson, ['url', 'requestUrl']) ?? URL_REGEX.exec(line)?.[0]; +} + +function parseNetworkStatus( + line: string, + maybeJson: Record | null, +): number | undefined { + return ( + readNetworkJsonNumber(maybeJson, ['status', 'statusCode', 'responseCode']) ?? + parseNetworkStatusCode(line) ?? + undefined + ); +} + +function hasExplicitNetworkSignal( + line: string, + method: boolean, + status: number | undefined, +): boolean { + return ( + method || + status !== undefined || + /\bURL["'=: ]+https?:\/\//i.test(line) || + /\bheaders?["'=: ]+/i.test(line) || + /\b(?:requestBody|responseBody|payload|request|response)["'=: ]+/i.test(line) + ); +} + +function createNetworkEntry( + line: string, + lineNumber: number, + identity: NetworkIdentity, + maxPayloadChars: number, +): NetworkEntry { + return { + ...identity, + timestamp: parseNetworkTimestamp(line), + packetId: parseAndroidPacketId(line) ?? undefined, + durationMs: parseAndroidDurationMs(line) ?? undefined, + raw: truncate(line, maxPayloadChars), + line: lineNumber, + }; +} + +function applyNetworkProjection( + result: NetworkEntry, + line: string, + maybeJson: Record | null, + include: NetworkDump['include'], + maxPayloadChars: number, +): void { + if (includesHeaders(include)) { + const headers = readNetworkHeaders(line, maybeJson); + if (headers) result.headers = truncate(headers, maxPayloadChars); + } + if (includesBody(include)) { + const requestBody = readNetworkBody(line, maybeJson, [ + 'requestBody', + 'body', + 'payload', + 'request', + ]); + const responseBody = readNetworkBody(line, maybeJson, ['responseBody', 'response']); + if (requestBody) result.requestBody = truncate(requestBody, maxPayloadChars); + if (responseBody) result.responseBody = truncate(responseBody, maxPayloadChars); + } +} + +function includesHeaders(include: NetworkDump['include']): boolean { + return include === 'headers' || include === 'all'; +} + +function includesBody(include: NetworkDump['include']): boolean { + return include === 'body' || include === 'all'; +} + +function networkEntryKey(entry: NetworkEntry): string { + return `${entry.timestamp ?? ''}|${entry.method ?? ''}|${entry.url}|${entry.status ?? ''}|${entry.raw}`; +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}...`; +} + +function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { + return value === undefined || !Number.isInteger(value) + ? fallback + : Math.max(min, Math.min(max, value)); +} diff --git a/packages/capture-kit/src/platform-runtime-unavailable.test.ts b/packages/capture-kit/src/platform-runtime-unavailable.test.ts new file mode 100644 index 0000000000..71e40139bc --- /dev/null +++ b/packages/capture-kit/src/platform-runtime-unavailable.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { localRuntimeOwner, providerRuntimeOwner } from '@agent-device/contracts/platform'; +import { + createUnavailablePlatformRuntimeBinding, + createUnavailablePlatformRuntimeOwner, +} from './platform-runtime-unavailable.ts'; + +const device = { + id: 'linux-host', + name: 'Linux host', + platform: 'linux', + kind: 'device', + state: 'booted', +} as const; + +const scope = { + signal: new AbortController().signal, + diagnostics: { emit: () => undefined }, + progress: { report: () => undefined }, +}; + +test('builds one complete combined unavailable owner without fake operations', async () => { + const owner = createUnavailablePlatformRuntimeOwner('linux', { + appLog: { available: false, reason: 'unsupported-platform-leaf' }, + network: { available: false, reason: 'owner-capability-missing' }, + }); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + + assert.deepEqual(Object.keys(binding.facts.operations).sort(), [ + 'appLogCleanup', + 'appLogDoctor', + 'appLogInspect', + 'appLogReattach', + 'appLogStart', + 'networkDump', + ]); + assert.deepEqual(binding.operations, {}); + await binding[Symbol.asyncDispose](); +}); + +test('rejects a planted wrong exact owner before producing a binding', async () => { + const owner = createUnavailablePlatformRuntimeOwner('linux', { + appLog: { available: false, reason: 'unsupported-platform-leaf' }, + network: { available: false, reason: 'unsupported-platform-leaf' }, + }); + await assert.rejects( + owner.bind({ + device, + intent: { + kind: 'exact-owner', + owner: localRuntimeOwner('vega'), + fence: { token: 'token', generation: 1 }, + }, + scope, + }), + /identity does not match/, + ); +}); + +test('generic unavailable binding preserves exact provider ownership and mode', async () => { + const owner = providerRuntimeOwner('webdriver', 'tenant-a'); + const binding = createUnavailablePlatformRuntimeBinding(device, owner, { + appLog: { available: false, reason: 'unsupported-provider-mode' }, + network: { available: false, reason: 'owner-capability-missing' }, + }); + + assert.equal(binding.owner, owner); + assert.equal(binding.facts.device.providerMode, 'provider-runtime'); + assert.deepEqual(binding.operations, {}); + await binding[Symbol.asyncDispose](); +}); diff --git a/packages/capture-kit/src/platform-runtime-unavailable.ts b/packages/capture-kit/src/platform-runtime-unavailable.ts new file mode 100644 index 0000000000..b81bdf2df6 --- /dev/null +++ b/packages/capture-kit/src/platform-runtime-unavailable.ts @@ -0,0 +1,80 @@ +import { deviceShape, type DeviceInfo, type Platform } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { + localRuntimeOwner, + sameRuntimeOwner, + type DeviceBinding, + type PlatformRuntimeOperations, + type PlatformRuntimeOwner, + type RuntimeFacts, + type RuntimeOperationUnavailability, + type RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; + +export type UnavailablePlatformRuntimeFacts = Readonly<{ + appLog: RuntimeOperationUnavailability; + network: RuntimeOperationUnavailability; +}>; + +/** Builds one honest combined owner for a family with no app-log or network mechanics. */ +export function createUnavailablePlatformRuntimeOwner( + family: Platform, + unavailable: UnavailablePlatformRuntimeFacts, +): PlatformRuntimeOwner { + const owner = localRuntimeOwner(family); + const appLog = Object.freeze({ ...unavailable.appLog }); + const network = Object.freeze({ ...unavailable.network }); + return Object.freeze({ + owner, + ownsDevice: (device) => device.platform === family, + bind: async (request) => { + if (request.intent.kind === 'exact-owner' && !sameRuntimeOwner(request.intent.owner, owner)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `${family} platform runtime owner identity does not match`, + ); + } + if (request.device.platform !== family) { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + `${family} platform runtime cannot bind ${request.device.platform}`, + ); + } + return createUnavailablePlatformRuntimeBinding(request.device, owner, { + appLog, + network, + }); + }, + shutdown: async () => undefined, + }); +} + +export function createUnavailablePlatformRuntimeBinding( + device: DeviceInfo, + owner: RuntimeOwnerRef, + unavailable: UnavailablePlatformRuntimeFacts, +): DeviceBinding { + const appLog = Object.freeze({ ...unavailable.appLog }); + const network = Object.freeze({ ...unavailable.network }); + const facts: RuntimeFacts = Object.freeze({ + device: { + ...deviceShape(device), + providerMode: owner.kind === 'local-family' ? 'local' : 'provider-runtime', + }, + operations: { + appLogInspect: appLog, + appLogDoctor: appLog, + appLogStart: appLog, + appLogReattach: appLog, + appLogCleanup: appLog, + networkDump: network, + }, + }); + return Object.freeze({ + device, + owner, + facts, + operations: Object.freeze({}), + [Symbol.asyncDispose]: async () => undefined, + }); +} diff --git a/packages/contracts/src/app-log-runtime.ts b/packages/contracts/src/app-log-runtime.ts index 568830c5ef..a305734d52 100644 --- a/packages/contracts/src/app-log-runtime.ts +++ b/packages/contracts/src/app-log-runtime.ts @@ -8,11 +8,8 @@ import type { HostToolchainPreparer, } from './platform-runtime-host.ts'; import type { - DeviceRuntimeOwner, ResourceOwnershipFence, RuntimeProviderMode, - RuntimeOwnerRef, - RuntimePlatformModule, } from './platform-runtime.ts'; import type { PendingTransferGuard } from './async-lifecycle.ts'; import { @@ -183,14 +180,3 @@ export type AppLogRuntimeHost = Readonly<{ sleep(milliseconds: number, signal?: AbortSignal): Promise; }>; }>; - -export type AppLogRuntimePlatformModule = RuntimePlatformModule< - AppLogRuntimeOperations, - AppLogRuntimeHost ->; - -/** Cheap provider metadata stays eager; provider mechanics load only after selection. */ -export type AppLogRuntimeProviderModule = Readonly<{ - owner: Extract; - loadRuntime(host: AppLogRuntimeHost): Promise>; -}>; diff --git a/packages/contracts/src/facades/platform.ts b/packages/contracts/src/facades/platform.ts index 7d23384bf1..9cfa8fa860 100644 --- a/packages/contracts/src/facades/platform.ts +++ b/packages/contracts/src/facades/platform.ts @@ -98,12 +98,40 @@ export type { AppLogProcessTransport, AppLogRuntimeHost, AppLogRuntimeOperations, - AppLogRuntimePlatformModule, - AppLogRuntimeProviderModule, AppLogSessionArtifacts, AppLogStartInput, AppLogStartResult, } from '../app-log-runtime.ts'; +export type { NetworkDump, NetworkDumpParserOptions } from '../network-traffic.ts'; +export type { + NetworkAppLogRead, + NetworkAppLogSnapshot, + NetworkDumpInput, + NetworkDumpResult, + NetworkProviderDump, + NetworkProviderDumpResult, + NetworkProviderEntry, + NetworkRuntimeHost, + NetworkRuntimeOperations, + NetworkTransport, +} from '../network-runtime.ts'; +export { + networkAdmissionUse, + networkDumpUse, + resolveNetworkRuntimePlan, +} from '../network-runtime-plan.ts'; +export type { + NetworkRuntimeAction, + NetworkRuntimePlan, + NetworkRuntimePlanInput, +} from '../network-runtime-plan.ts'; +export type { + PlatformRuntimeHost, + PlatformRuntimeModule, + PlatformRuntimeOperations, + PlatformRuntimeOwner, + PlatformRuntimeProviderModule, +} from '../platform-runtime-operations.ts'; export { appLogAdmissionUse, appLogRuntimePlanUses, diff --git a/packages/contracts/src/logs-runtime-plan.ts b/packages/contracts/src/logs-runtime-plan.ts index 1e4a529472..f389584549 100644 --- a/packages/contracts/src/logs-runtime-plan.ts +++ b/packages/contracts/src/logs-runtime-plan.ts @@ -1,8 +1,8 @@ import { AppError } from '@agent-device/kernel/errors'; -import type { AppLogRuntimeOperations } from './app-log-runtime.ts'; import { runtimeUse } from './platform-runtime.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; -const appLogUse = runtimeUse(); +const appLogUse = runtimeUse(); const appLogInspectUse = appLogUse({ required: ['appLogInspect'] }); const appLogDoctorUse = appLogUse({ required: ['appLogInspect', 'appLogDoctor'] }); diff --git a/packages/contracts/src/network-log.ts b/packages/contracts/src/network-log.ts index 174da2a4b5..a561d8c1e5 100644 --- a/packages/contracts/src/network-log.ts +++ b/packages/contracts/src/network-log.ts @@ -1,8 +1,7 @@ // One parsed network-log entry. // -// Lives here rather than in the daemon because the `network` command's output formatting -// (`commands/observability/`) is declared in terms of it: the daemon parses the log, the -// command surface renders what was parsed. +// Lives here because platform runtimes parse neutral host-supplied app-log text and the +// command surface renders the same cross-layer result shape. export type NetworkEntry = { method?: string; diff --git a/packages/contracts/src/network-runtime-plan.test.ts b/packages/contracts/src/network-runtime-plan.test.ts new file mode 100644 index 0000000000..7d89be9c03 --- /dev/null +++ b/packages/contracts/src/network-runtime-plan.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + networkAdmissionUse, + networkDumpUse, + resolveNetworkRuntimePlan, +} from './network-runtime-plan.ts'; + +test('freezes the exact two action aliases by four include-mode projection cells', () => { + const projectionCells = ['dump', 'log'].flatMap((action) => + ['summary', 'headers', 'body', 'all'].map((include) => + resolveNetworkRuntimePlan({ action, include }), + ), + ); + assert.deepEqual(projectionCells, [ + { action: 'dump', include: 'summary', use: networkDumpUse }, + { action: 'dump', include: 'headers', use: networkDumpUse }, + { action: 'dump', include: 'body', use: networkDumpUse }, + { action: 'dump', include: 'all', use: networkDumpUse }, + { action: 'log', include: 'summary', use: networkDumpUse }, + { action: 'log', include: 'headers', use: networkDumpUse }, + { action: 'log', include: 'body', use: networkDumpUse }, + { action: 'log', include: 'all', use: networkDumpUse }, + ]); + assert.equal( + projectionCells.every((cell) => Object.isFrozen(cell)), + true, + ); +}); + +test('normalizes defaults and retains the requested alias and projection', () => { + assert.deepEqual(resolveNetworkRuntimePlan({}), { + action: 'dump', + include: 'summary', + use: networkDumpUse, + }); + assert.deepEqual(resolveNetworkRuntimePlan({ action: 'LOG', include: 'HEADERS' }), { + action: 'log', + include: 'headers', + use: networkDumpUse, + }); + assert.deepEqual(networkDumpUse, { required: ['networkDump'], preferred: [] }); + assert.deepEqual(networkAdmissionUse, { required: [], preferred: ['networkDump'] }); +}); + +test('rejects actions and projections outside the frozen cells', () => { + for (const input of [{ action: 'stream' }, { include: 'cookies' }]) { + assert.throws( + () => resolveNetworkRuntimePlan(input), + (error) => error instanceof AppError && error.code === 'INVALID_ARGS', + ); + } +}); diff --git a/packages/contracts/src/network-runtime-plan.ts b/packages/contracts/src/network-runtime-plan.ts new file mode 100644 index 0000000000..47dbead66b --- /dev/null +++ b/packages/contracts/src/network-runtime-plan.ts @@ -0,0 +1,50 @@ +import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; +import { AppError } from '@agent-device/kernel/errors'; +import { runtimeUse } from './platform-runtime.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; + +const defineNetworkUse = runtimeUse(); + +export const networkDumpUse = defineNetworkUse({ required: ['networkDump'] }); + +/** Fact-only capability projection; it is not an execution-plan variant. */ +export const networkAdmissionUse = defineNetworkUse({ + required: [], + preferred: ['networkDump'], +}); + +export type NetworkRuntimeAction = 'dump' | 'log'; + +export type NetworkRuntimePlan = Readonly<{ + action: NetworkRuntimeAction; + include: NetworkIncludeMode; + use: typeof networkDumpUse; +}>; + +export type NetworkRuntimePlanInput = Readonly<{ + action?: string; + include?: string; +}>; + +const ACTIONS = ['dump', 'log'] as const; +const INCLUDES = ['summary', 'headers', 'body', 'all'] as const; + +export function resolveNetworkRuntimePlan(input: NetworkRuntimePlanInput): NetworkRuntimePlan { + const action = (input.action ?? 'dump').toLowerCase(); + const include = (input.include ?? 'summary').toLowerCase(); + if (!isNetworkRuntimeAction(action)) { + throw new AppError('INVALID_ARGS', 'network requires dump or log'); + } + if (!isNetworkIncludeMode(include)) { + throw new AppError('INVALID_ARGS', 'network --include must be summary, headers, body, or all'); + } + return Object.freeze({ action, include, use: networkDumpUse }); +} + +function isNetworkRuntimeAction(value: string): value is NetworkRuntimeAction { + return ACTIONS.some((action) => action === value); +} + +function isNetworkIncludeMode(value: string): value is NetworkIncludeMode { + return INCLUDES.some((include) => include === value); +} diff --git a/packages/contracts/src/network-runtime.test.ts b/packages/contracts/src/network-runtime.test.ts new file mode 100644 index 0000000000..8fc9e92abc --- /dev/null +++ b/packages/contracts/src/network-runtime.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { BoundDeviceRuntime } from './platform-runtime.ts'; +import { networkDumpUse } from './network-runtime-plan.ts'; +import type { + NetworkDumpResult, + NetworkProviderDump, + NetworkRuntimeHost, +} from './network-runtime.ts'; + +function compileTimeNetworkProjectionProof( + runtime: BoundDeviceRuntime, +): void { + const dump = runtime.operations.networkDump; + void dump; + // @ts-expect-error A sibling app-log operation cannot cross the network projection. + void runtime.operations.appLogInspect; + // @ts-expect-error The selected handler projection does not own the binding lifetime. + void runtime[Symbol.asyncDispose]; +} +void compileTimeNetworkProjectionProof; + +function compileTimeCanonicalHostProof(host: NetworkRuntimeHost): void { + void host.appLogs.readRecent('session-id', 4000); + void host.appLogs.readProcessMarker('session-id'); + // @ts-expect-error Callers cannot supply an arbitrary app-log path. + void host.appLogs.readRecent('/tmp/untrusted.log', 4000, '/tmp/marker'); +} +void compileTimeCanonicalHostProof; + +test('provider transport treats an empty dump as supported success', async () => { + const dump: NetworkProviderDump = async () => ({ + backend: 'browserstack', + entries: [], + notes: ['No matching provider traffic was recorded.'], + }); + const result: NetworkDumpResult = { + source: 'provider', + ...(await dump({ maxEntries: 25, include: 'summary' })), + }; + + assert.deepEqual(result, { + source: 'provider', + backend: 'browserstack', + entries: [], + notes: ['No matching provider traffic was recorded.'], + }); +}); diff --git a/packages/contracts/src/network-runtime.ts b/packages/contracts/src/network-runtime.ts new file mode 100644 index 0000000000..8d185616a4 --- /dev/null +++ b/packages/contracts/src/network-runtime.ts @@ -0,0 +1,85 @@ +import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AppLogLiveState, AppLogProcessMarkerReadOutcome } from './app-log-runtime.ts'; +import type { LogBackend } from './logs.ts'; +import type { HostCommandRunner } from './platform-runtime-host.ts'; +import type { RuntimeProviderMode } from './platform-runtime.ts'; +import type { NetworkDump } from './network-traffic.ts'; + +export type NetworkAppLogSnapshot = Readonly<{ + state: AppLogLiveState; + startedAt: number; +}>; + +export type NetworkDumpInput = Readonly<{ + sessionId: string; + appBundleId?: string; + appLogSnapshot?: NetworkAppLogSnapshot; + maxEntries: number; + include: NetworkIncludeMode; + maxPayloadChars: number; + maxScanLines: number; +}>; + +export type NetworkProviderEntry = Readonly<{ + timestamp?: string; + method?: string; + url?: string; + status?: number; + durationMs?: number; + requestHeaders?: Readonly>; + responseHeaders?: Readonly>; + requestBody?: string; + responseBody?: string; + metadata?: Readonly>; +}>; + +export type NetworkProviderDumpResult = Readonly<{ + backend: string; + entries: readonly NetworkProviderEntry[]; + notes?: readonly string[]; +}>; + +export type NetworkDumpResult = + | Readonly<{ + source: 'app-log'; + backend: LogBackend; + dump: NetworkDump; + notes: readonly string[]; + }> + | (Readonly<{ source: 'provider' }> & NetworkProviderDumpResult); + +export type NetworkRuntimeOperations = Readonly<{ + networkDump(input: NetworkDumpInput): Promise; +}>; + +export type NetworkProviderDump = ( + input: Readonly<{ maxEntries: number; include: NetworkIncludeMode }>, + signal?: AbortSignal, +) => Promise; + +export type NetworkTransport = Readonly<{ + mode: Extract; + /** Absent when the selected narrow transport cannot provide network traffic. */ + dump?: NetworkProviderDump; +}>; + +export type NetworkAppLogRead = Readonly<{ + path: string; + exists: boolean; + /** Exact line-bounded suffix selected with legacy split/slice semantics. */ + text: string; + /** Number of leading split lines omitted from text; restores absolute source line numbers. */ + skippedLines: number; +}>; + +export type NetworkRuntimeHost = Readonly<{ + commands: HostCommandRunner; + appLogs: Readonly<{ + readRecent(sessionId: string, maxScanLines: number): Promise; + readProcessMarker(sessionId: string): Promise; + }>; + networkTransports: Readonly<{ + resolve(device: DeviceInfo): Promise; + }>; +}>; diff --git a/packages/contracts/src/network-traffic.ts b/packages/contracts/src/network-traffic.ts new file mode 100644 index 0000000000..c9185c3a01 --- /dev/null +++ b/packages/contracts/src/network-traffic.ts @@ -0,0 +1,25 @@ +import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; +import type { LogBackend } from './logs.ts'; +import type { NetworkEntry } from './network-log.ts'; + +export type NetworkDumpParserOptions = Readonly<{ + path: string; + exists: boolean; + backend?: LogBackend; + maxEntries?: number; + include?: NetworkIncludeMode; + maxPayloadChars?: number; + maxScanLines?: number; + /** Absolute source-line offset for host-selected text windows. */ + lineNumberOffset?: number; +}>; + +export type NetworkDump = Readonly<{ + path: string; + exists: boolean; + scannedLines: number; + matchedLines: number; + entries: readonly NetworkEntry[]; + include: NonNullable; + limits: Readonly<{ maxEntries: number; maxPayloadChars: number; maxScanLines: number }>; +}>; diff --git a/packages/contracts/src/platform-runtime-operations.test.ts b/packages/contracts/src/platform-runtime-operations.test.ts new file mode 100644 index 0000000000..b66ea531b5 --- /dev/null +++ b/packages/contracts/src/platform-runtime-operations.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import { localRuntimeOwner, providerRuntimeOwner } from './platform-runtime.ts'; +import type { PlatformRuntimeProviderModule } from './platform-runtime-operations.ts'; + +function compileTimeProviderModuleProof(): void { + const invalid: PlatformRuntimeProviderModule = { + // @ts-expect-error Provider modules cannot advertise a local-family owner. + owner: localRuntimeOwner('apple'), + loadRuntime: async () => { + throw new Error('not loaded'); + }, + }; + void invalid; +} +void compileTimeProviderModuleProof; + +test('provider module exposes inert exact-owner metadata without loading mechanics', () => { + const loadRuntime = vi.fn(async () => { + throw new Error('not loaded'); + }); + const module: PlatformRuntimeProviderModule = { + owner: providerRuntimeOwner('limrun', 'tenant-a'), + loadRuntime, + }; + + assert.deepEqual(module.owner, { + kind: 'provider-runtime', + provider: 'limrun', + instance: 'tenant-a', + }); + assert.equal(loadRuntime.mock.calls.length, 0); +}); diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts new file mode 100644 index 0000000000..f5d1b099b9 --- /dev/null +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -0,0 +1,24 @@ +import type { AppLogRuntimeHost, AppLogRuntimeOperations } from './app-log-runtime.ts'; +import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-runtime.ts'; +import type { + DeviceRuntimeOwner, + RuntimeOwnerRef, + RuntimePlatformModule, +} from './platform-runtime.ts'; + +export type PlatformRuntimeOperations = AppLogRuntimeOperations & NetworkRuntimeOperations; + +export type PlatformRuntimeHost = AppLogRuntimeHost & NetworkRuntimeHost; + +export type PlatformRuntimeOwner = DeviceRuntimeOwner; + +export type PlatformRuntimeModule = RuntimePlatformModule< + PlatformRuntimeOperations, + PlatformRuntimeHost +>; + +/** Cheap exact-owner metadata stays eager; provider mechanics remain lazy until selection. */ +export type PlatformRuntimeProviderModule = Readonly<{ + owner: Extract; + loadRuntime(host: PlatformRuntimeHost): Promise; +}>; diff --git a/packages/platform-android/src/index.ts b/packages/platform-android/src/index.ts index c5dc7b9377..18d36f35b1 100644 --- a/packages/platform-android/src/index.ts +++ b/packages/platform-android/src/index.ts @@ -1,6 +1,6 @@ import type { - AppLogRuntimePlatformModule, InventoryPlatformModule, + PlatformRuntimeModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; import type { AndroidInventoryConfig } from './inventory-config.ts'; @@ -14,10 +14,10 @@ export type { AndroidInventoryConfig } from './inventory-config.ts'; export const runtimeModule = Object.freeze({ ...metadata, loadRuntime: async (host) => { - const { createAndroidAppLogRuntime } = await import('./logs/runtime.ts'); - return createAndroidAppLogRuntime(host); + const { createAndroidPlatformRuntime } = await import('./runtime.ts'); + return createAndroidPlatformRuntime(host); }, -} satisfies AppLogRuntimePlatformModule); +} satisfies PlatformRuntimeModule); export function createAndroidInventoryModule( config: AndroidInventoryConfig, diff --git a/packages/platform-android/src/network/runtime.test.ts b/packages/platform-android/src/network/runtime.test.ts new file mode 100644 index 0000000000..c847a42970 --- /dev/null +++ b/packages/platform-android/src/network/runtime.test.ts @@ -0,0 +1,153 @@ +import { expect, test, vi } from 'vitest'; +import type { + HostCommandRequest, + NetworkDumpInput, + PlatformRuntimeHost, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { dumpAndroidNetworkTraffic } from './runtime.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +test('recovers inactive Android traffic for current and previous package pids before stale entries', async () => { + const result = await dumpAndroidNetworkTraffic( + host({ + marker: { status: 'missing' }, + command: (request) => commandResult(request, '222'), + appLogText: '01-01 00:00:00.000 D/App (99): GET https://stale.example.test status=200', + logcat: [ + '01-01 00:00:00.000 I/ActivityManager( 100): Start proc 111:com.example.app/u0a1', + '01-01 00:00:01.000 D/App (111): GET https://previous.example.test status=200', + '01-01 00:00:02.000 D/App (222): GET https://current.example.test status=201', + ].join('\n'), + }), + device, + input({ appLogSnapshot: { state: 'ended', startedAt: 1 } }), + new AbortController().signal, + ); + + expect(result).toMatchObject({ + source: 'app-log', + backend: 'android', + dump: { + entries: [ + { url: 'https://current.example.test' }, + { url: 'https://previous.example.test' }, + { url: 'https://stale.example.test' }, + ], + }, + }); + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.notes[0]).toContain('PID set 222, 111'); +}); + +test('detects an active stream still bound to the previous Android pid', async () => { + const command = vi.fn((request: HostCommandRequest) => commandResult(request, '222')); + const result = await dumpAndroidNetworkTraffic( + host({ + marker: { + status: 'decoded', + marker: { pid: 42, startTime: 'start', command: 'adb logcat --pid 111' }, + }, + command, + logcat: '01-01 00:00:02.000 D/App (222): GET https://fresh.example.test status=201', + }), + device, + input({ appLogSnapshot: { state: 'active', startedAt: 1 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.notes[0]).toContain('prior Android PID 111'); + expect(command.mock.calls.some(([request]) => request.args.includes('logcat'))).toBe(true); +}); + +function input(overrides: Partial = {}): NetworkDumpInput { + return { + sessionId: 'one', + appBundleId: 'com.example.app', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 4000, + ...overrides, + }; +} + +function host(options: { + marker: Awaited>; + command(request: HostCommandRequest): { stdout: string; stderr: string; exitCode: number }; + appLogText?: string; + logcat: string; +}): PlatformRuntimeHost { + return { + ...unusedAppLogHost(), + commands: { + which: async () => undefined, + run: async (request) => { + if (!request.args.includes('logcat')) return options.command(request); + options.command(request); + return { stdout: options.logcat, stderr: '', exitCode: 0 }; + }, + }, + appLogs: { + readRecent: async () => ({ + path: '/sessions/one/app.log', + exists: options.appLogText !== undefined, + text: options.appLogText ?? '', + skippedLines: 0, + }), + readProcessMarker: async () => options.marker, + }, + networkTransports: { resolve: async () => ({ mode: 'local' }) }, + }; +} + +function commandResult(request: HostCommandRequest, pid: string) { + return request.args.includes('pidof') + ? { stdout: `${pid}\n`, stderr: '', exitCode: 0 } + : { stdout: '', stderr: '', exitCode: 0 }; +} + +function unusedAppLogHost(): Omit< + PlatformRuntimeHost, + 'commands' | 'appLogs' | 'networkTransports' +> { + return { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + toolchains: { prepare: async () => {} }, + artifacts: { + resolveSession: () => ({ + outputPath: '/sessions/one/app.log', + pidPath: '/sessions/one/app-log.pid', + }), + }, + outputs: { + openAppend: async () => { + throw new Error('unused'); + }, + readTail: async () => '', + }, + processes: { + start: async () => { + throw new Error('unused'); + }, + readMarker: async () => ({ status: 'missing' }), + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + processTransports: { resolve: async () => ({ mode: 'local' }) }, + clock: { now: () => 1, sleep: async () => {} }, + }; +} diff --git a/packages/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts new file mode 100644 index 0000000000..ee0c13f417 --- /dev/null +++ b/packages/platform-android/src/network/runtime.ts @@ -0,0 +1,166 @@ +import type { + NetworkDumpInput, + NetworkDumpResult, + PlatformRuntimeHost, +} from '@agent-device/contracts/platform'; +import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { assertAndroidLogPackageSafe } from '../logs/package-name.ts'; + +type RecoveryContext = Readonly<{ + reason: 'inactive' | 'stale-active'; + trackedPid?: string; +}>; + +export async function dumpAndroidNetworkTraffic( + host: PlatformRuntimeHost, + device: DeviceInfo, + input: NetworkDumpInput, + signal: AbortSignal, +): Promise { + const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); + let dump = readRecentNetworkTrafficFromText(recent.text, { + ...input, + path: recent.path, + exists: recent.exists, + lineNumberOffset: recent.skippedLines, + backend: 'android', + }); + const notes: string[] = []; + const context = await recoveryContext(host, device, input, signal); + if (context && input.appBundleId) { + const recovered = await recoverPackageTraffic(host, device, input.appBundleId, signal); + if (recovered) { + const recoveryDump = readRecentNetworkTrafficFromText(recovered.text, { + ...input, + path: `${recent.path} (adb logcat recovery)`, + exists: true, + backend: 'android', + }); + if (recoveryDump.entries.length > 0) { + dump = mergeNetworkDumps(recoveryDump, dump, input.maxEntries); + notes.push( + context.reason === 'stale-active' + ? `Session app log stream was still bound to prior Android PID ${context.trackedPid}. Recovered recent Android HTTP entries from adb logcat for PID set ${recovered.pids.join(', ')}.` + : `Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${recovered.pids.join(', ')}.`, + ); + } + } + } + if (!input.appLogSnapshot) { + notes.push( + 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', + ); + } else if (input.appLogSnapshot.state !== 'active' && notes.length === 0) { + notes.push( + 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', + ); + } + if (dump.entries.length === 0) { + notes.push('No HTTP(s) entries were found in recent session app logs.'); + } + return Object.freeze({ + source: 'app-log', + backend: 'android', + dump, + notes: Object.freeze(notes), + }); +} + +async function recoveryContext( + host: PlatformRuntimeHost, + device: DeviceInfo, + input: NetworkDumpInput, + signal: AbortSignal, +): Promise { + if (!input.appBundleId) return undefined; + if (input.appLogSnapshot?.state === undefined) return undefined; + if (input.appLogSnapshot.state !== 'active') return { reason: 'inactive' }; + const marker = await host.appLogs.readProcessMarker(input.sessionId); + const trackedPid = trackedAndroidPid(marker); + if (!trackedPid) return undefined; + const currentPid = await resolveAndroidPid(host, device.id, input.appBundleId, signal); + if (!currentPid || currentPid === trackedPid) return undefined; + return { reason: 'stale-active', trackedPid }; +} + +function trackedAndroidPid( + marker: Awaited>, +): string | undefined { + if (marker.status !== 'decoded') return undefined; + return /(?:^|\s)--pid\s+(\d+)(?:\s|$)/.exec(marker.marker.command)?.[1]; +} + +async function recoverPackageTraffic( + host: PlatformRuntimeHost, + device: DeviceInfo, + appBundleId: string, + signal: AbortSignal, +): Promise<{ text: string; pids: readonly string[] } | undefined> { + assertAndroidLogPackageSafe(appBundleId); + const currentPid = await resolveAndroidPid(host, device.id, appBundleId, signal); + const result = await host.commands.run( + { + executable: 'adb', + args: ['-s', device.id, 'logcat', '-d', '-v', 'time', '-t', '4000'], + allowFailure: true, + timeoutMs: 3_000, + }, + signal, + ); + if (result.exitCode !== 0 || !result.stdout.trim()) return undefined; + const pids = collectPackagePids(result.stdout, appBundleId, currentPid); + if (pids.length === 0) return undefined; + const pidSet = new Set(pids); + const text = result.stdout + .split('\n') + .filter((line) => { + if (!line.trim()) return false; + if (line.includes(appBundleId)) return true; + const pid = /\(\s*(\d+)\)\s*:/.exec(line)?.[1]; + return Boolean(pid && pidSet.has(pid)); + }) + .join('\n'); + return text.trim() ? { text, pids } : undefined; +} + +async function resolveAndroidPid( + host: PlatformRuntimeHost, + deviceId: string, + appBundleId: string, + signal: AbortSignal, +): Promise { + const result = await host.commands.run( + { + executable: 'adb', + args: ['-s', deviceId, 'shell', 'pidof', appBundleId], + allowFailure: true, + }, + signal, + ); + const pid = result.stdout.trim().split(/\s+/)[0]; + return pid && /^\d+$/.test(pid) ? pid : undefined; +} + +function collectPackagePids( + text: string, + appBundleId: string, + currentPid: string | undefined, +): readonly string[] { + const pids = new Set(currentPid ? [currentPid] : []); + const packagePattern = appBundleId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const patterns = [ + new RegExp(`\\bStart proc\\s+(\\d+):${packagePattern}(?:\\b|/)`, 'i'), + new RegExp(`\\b(\\d+):${packagePattern}(?:\\b|/)`, 'i'), + new RegExp(`${packagePattern}.*?\\bpid\\s*[=:]?\\s*(\\d+)\\b`, 'i'), + new RegExp(`\\bpid\\s*[=:]?\\s*(\\d+)\\b.*${packagePattern}`, 'i'), + ]; + for (const line of text.split('\n')) { + if (!line.includes(appBundleId)) continue; + for (const pattern of patterns) { + const pid = pattern.exec(line)?.[1]; + if (pid && /^\d+$/.test(pid)) pids.add(pid); + } + } + return Object.freeze([...pids]); +} diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts new file mode 100644 index 0000000000..d2e0444381 --- /dev/null +++ b/packages/platform-android/src/runtime.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'vitest'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createAndroidPlatformRuntime } from './runtime.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'android-fact', + name: 'Android', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +test.each([ + ['emulator', device], + ['device', { ...device, kind: 'device' as const }], +])('classifies the Android %s runtime denominator', async (_name, runtimeDevice) => { + const host = { + processTransports: { resolve: async () => ({ mode: 'local' as const }) }, + } as unknown as PlatformRuntimeHost; + const binding = await createAndroidPlatformRuntime(host).bind({ + device: runtimeDevice, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + const { facts } = binding; + expect(facts.device.providerMode).toBe('local'); + expect(facts.operations.networkDump).toEqual({ available: true }); +}); diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts new file mode 100644 index 0000000000..1f9f936aa9 --- /dev/null +++ b/packages/platform-android/src/runtime.ts @@ -0,0 +1,39 @@ +import type { + DeviceBinding, + NetworkDumpInput, + PlatformRuntimeHost, + PlatformRuntimeOperations, + PlatformRuntimeOwner, +} from '@agent-device/contracts/platform'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createAndroidAppLogRuntime } from './logs/runtime.ts'; +import { dumpAndroidNetworkTraffic } from './network/runtime.ts'; + +const owner = localRuntimeOwner('android'); +const available = Object.freeze({ available: true } as const); + +export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { + const appLogs = createAndroidAppLogRuntime(host); + return Object.freeze({ + owner, + ownsDevice: (device) => device.platform === 'android', + bind: async (request) => { + const logs = await appLogs.bind(request); + return Object.freeze({ + device: logs.device, + owner, + facts: Object.freeze({ + device: logs.facts.device, + operations: { ...logs.facts.operations, networkDump: available }, + }), + operations: Object.freeze({ + ...logs.operations, + networkDump: async (input: NetworkDumpInput) => + await dumpAndroidNetworkTraffic(host, request.device, input, request.scope.signal), + }), + [Symbol.asyncDispose]: async () => await logs[Symbol.asyncDispose](), + }) satisfies DeviceBinding; + }, + shutdown: async () => await appLogs.shutdown(), + }); +} diff --git a/packages/platform-apple/src/index.ts b/packages/platform-apple/src/index.ts index c6a17eb820..cd5036e3f8 100644 --- a/packages/platform-apple/src/index.ts +++ b/packages/platform-apple/src/index.ts @@ -1,6 +1,6 @@ import type { - AppLogRuntimePlatformModule, InventoryPlatformModule, + PlatformRuntimeModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -11,10 +11,10 @@ const metadata = Object.freeze({ export const runtimeModule = Object.freeze({ ...metadata, loadRuntime: async (host) => { - const { createAppleAppLogRuntime } = await import('./logs/runtime.ts'); - return createAppleAppLogRuntime(host); + const { createApplePlatformRuntime } = await import('./runtime.ts'); + return createApplePlatformRuntime(host); }, -} satisfies AppLogRuntimePlatformModule); +} satisfies PlatformRuntimeModule); export const inventoryModule = Object.freeze({ ...metadata, diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts new file mode 100644 index 0000000000..09f497f2ad --- /dev/null +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -0,0 +1,156 @@ +import { expect, test, vi } from 'vitest'; +import type { NetworkDumpInput, PlatformRuntimeHost } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { dumpAppleNetworkTraffic } from './runtime.ts'; + +const simulator: DeviceInfo = { + platform: 'apple', + appleOs: 'ios', + id: 'sim-1', + name: 'iPhone', + kind: 'simulator', + target: 'mobile', + booted: true, +}; + +test('recovers an empty iOS simulator dump from bounded simctl log history', async () => { + const commandRun = vi.fn(async () => ({ + stdout: [ + 'Timestamp Ty Process[PID:TID]', + '2026-08-10T10:00:00Z GET https://recovered.example.test status=200', + ].join('\n'), + stderr: '', + exitCode: 0, + })); + const result = await dumpAppleNetworkTraffic( + host({ commandRun }), + simulator, + input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + expect(result).toMatchObject({ + source: 'app-log', + backend: 'ios-simulator', + dump: { entries: [{ url: 'https://recovered.example.test' }] }, + }); + expect(result.source).toBe('app-log'); + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.notes[0]).toContain('Recovered 1 iOS simulator HTTP entry'); + expect(commandRun).toHaveBeenCalledWith( + expect.objectContaining({ + executable: 'xcrun', + args: expect.arrayContaining(['simctl', 'spawn', 'sim-1', '--start', '@1']), + timeoutMs: 4_000, + }), + expect.any(AbortSignal), + ); +}); + +test('preserves the simulator no-HTTP explanation after recovery returns app lines', async () => { + const result = await dumpAppleNetworkTraffic( + host({ + commandRun: async () => ({ + stdout: '2026-08-10T10:00:00Z application diagnostic only\n', + stderr: '', + exitCode: 0, + }), + }), + simulator, + input({ appLogSnapshot: { state: 'ended', startedAt: 1_000 } }), + new AbortController().signal, + ); + + expect(result.source).toBe('app-log'); + expect(result.notes).toEqual([ + expect.stringContaining('none looked like HTTP traffic'), + expect.stringContaining('No HTTP(s) entries were found in recent iOS simulator app logs'), + ]); +}); + +test('parses macOS session app-log traffic without loading simulator recovery', async () => { + const commandRun = vi.fn(); + const result = await dumpAppleNetworkTraffic( + host({ + commandRun, + text: '2026-08-10T10:00:00Z GET https://mac.example.test status=204\n', + }), + { ...simulator, appleOs: 'macos', kind: 'device', target: 'desktop' }, + input(), + new AbortController().signal, + ); + expect(result).toMatchObject({ + source: 'app-log', + backend: 'macos', + dump: { entries: [{ url: 'https://mac.example.test', status: 204 }] }, + }); + expect(commandRun).not.toHaveBeenCalled(); +}); + +function input(overrides: Partial = {}): NetworkDumpInput { + return { + sessionId: 'one', + appBundleId: 'com.example.app', + maxEntries: 25, + include: 'summary' as const, + maxPayloadChars: 2048, + maxScanLines: 4000, + ...overrides, + }; +} + +function host(options: { + text?: string; + commandRun: PlatformRuntimeHost['commands']['run']; +}): PlatformRuntimeHost { + return { + ...unusedAppLogHost(), + commands: { which: async () => undefined, run: options.commandRun }, + appLogs: { + readRecent: async () => ({ + path: '/sessions/one/app.log', + exists: options.text !== undefined, + text: options.text ?? '', + skippedLines: 0, + }), + readProcessMarker: async () => ({ status: 'missing' }), + }, + networkTransports: { resolve: async () => ({ mode: 'local' }) }, + }; +} + +function unusedAppLogHost(): Omit< + PlatformRuntimeHost, + 'commands' | 'appLogs' | 'networkTransports' +> { + return { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + toolchains: { prepare: async () => {} }, + artifacts: { + resolveSession: () => ({ + outputPath: '/sessions/one/app.log', + pidPath: '/sessions/one/app-log.pid', + }), + }, + outputs: { + openAppend: async () => { + throw new Error('unused'); + }, + readTail: async () => '', + }, + processes: { + start: async () => { + throw new Error('unused'); + }, + readMarker: async () => ({ status: 'missing' }), + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + processTransports: { resolve: async () => ({ mode: 'local' }) }, + clock: { now: () => 1, sleep: async () => {} }, + }; +} diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts new file mode 100644 index 0000000000..496aa3e25d --- /dev/null +++ b/packages/platform-apple/src/network/runtime.ts @@ -0,0 +1,142 @@ +import type { + NetworkDump, + NetworkDumpInput, + NetworkDumpResult, + PlatformRuntimeHost, +} from '@agent-device/contracts/platform'; +import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; +import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; +import { backendForAppleDevice } from '../logs/backend.ts'; + +export async function dumpAppleNetworkTraffic( + host: PlatformRuntimeHost, + device: DeviceInfo, + input: NetworkDumpInput, + signal: AbortSignal, +): Promise { + const backend = backendForAppleDevice(device); + const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); + let dump = readRecentNetworkTrafficFromText(recent.text, { + ...input, + path: recent.path, + exists: recent.exists, + lineNumberOffset: recent.skippedLines, + backend, + }); + const notes: string[] = []; + if (canRecoverSimulator(device, input, dump)) { + const recovery = await recoverSimulatorTraffic(host, device, input, recent.path, signal); + if (recovery) { + if (recovery.dump.entries.length > 0) { + dump = mergeNetworkDumps(recovery.dump, dump, input.maxEntries); + notes.push( + `Recovered ${recovery.dump.entries.length} iOS simulator HTTP entr${recovery.dump.entries.length === 1 ? 'y' : 'ies'} from simctl log show (${recovery.lineCount} app log lines scanned).`, + ); + } else if (recovery.lineCount > 0) { + notes.push( + `Recovered ${recovery.lineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, + ); + } + } + } + appendLifecycleNote(notes, device, input); + if (dump.entries.length === 0) notes.push(noEntriesNote(device)); + return Object.freeze({ source: 'app-log', backend, dump, notes: Object.freeze(notes) }); +} + +function canRecoverSimulator( + device: DeviceInfo, + input: NetworkDumpInput, + dump: NetworkDump, +): boolean { + return ( + isIosFamily(device) && + device.kind === 'simulator' && + Boolean(input.appBundleId) && + dump.entries.length === 0 + ); +} + +async function recoverSimulatorTraffic( + host: PlatformRuntimeHost, + device: DeviceInfo, + input: NetworkDumpInput, + appLogPath: string, + signal: AbortSignal, +): Promise<{ dump: NetworkDump; lineCount: number } | undefined> { + const args = [ + 'simctl', + ...(device.simulatorSetPath ? ['--set', device.simulatorSetPath] : []), + 'spawn', + device.id, + 'log', + 'show', + '--style', + 'compact', + '--info', + '--predicate', + buildPredicate(input.appBundleId as string), + ]; + const startedAt = input.appLogSnapshot?.startedAt; + args.push( + ...(typeof startedAt === 'number' && Number.isFinite(startedAt) && startedAt > 0 + ? ['--start', `@${Math.floor(startedAt / 1000)}`] + : ['--last', '5m']), + ); + const result = await host.commands.run( + { executable: 'xcrun', args, allowFailure: true, timeoutMs: 4_000 }, + signal, + ); + if (result.exitCode !== 0 || !result.stdout.trim()) return undefined; + const lines = result.stdout + .split('\n') + .map((line) => line.trimEnd()) + .filter( + (line) => + line.trim() && !line.trim().startsWith('Timestamp Ty Process[PID:TID]'), + ); + if (lines.length === 0) return undefined; + return { + dump: readRecentNetworkTrafficFromText(`${lines.join('\n')}\n`, { + ...input, + path: `${appLogPath} (simctl log show recovery)`, + exists: true, + backend: 'ios-simulator', + }), + lineCount: lines.length, + }; +} + +function buildPredicate(appBundleId: string): string { + const value = appBundleId.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + return [ + `subsystem == "${value}"`, + `subsystem CONTAINS "${value}"`, + `processImagePath ENDSWITH[c] "/${value}"`, + `senderImagePath ENDSWITH[c] "/${value}"`, + ].join(' OR '); +} + +function appendLifecycleNote(notes: string[], device: DeviceInfo, input: NetworkDumpInput): void { + if (!input.appLogSnapshot) { + notes.push( + 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', + ); + } else if (input.appLogSnapshot.state !== 'active' && notes.length === 0) { + notes.push( + isIosFamily(device) && device.kind === 'simulator' + ? 'Session app log stream is inactive. The iOS simulator recovery path scanned recent simctl log history, but a fresh logs clear --restart window is still the most reliable repro loop.' + : 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', + ); + } +} + +function noEntriesNote(device: DeviceInfo): string { + if (isIosFamily(device) && device.kind === 'simulator') { + return 'No HTTP(s) entries were found in recent iOS simulator app logs. If the app only emits non-HTTP diagnostics, inspect logs path or add app-side URLSession/network logging for per-request timing and payload details.'; + } + if (isIosFamily(device)) { + return 'No HTTP(s) entries were found in recent iOS device app logs. iOS network dump only sees what the app emits into Unified Logging for this process.'; + } + return 'No HTTP(s) entries were found in recent session app logs.'; +} diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts new file mode 100644 index 0000000000..8401c401e2 --- /dev/null +++ b/packages/platform-apple/src/runtime.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from 'vitest'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform'; +import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device'; +import { createApplePlatformRuntime } from './runtime.ts'; + +function appleDevice(overrides: Partial = {}): DeviceInfo { + return { + platform: 'apple', + appleOs: 'ios', + id: 'apple-fact', + name: 'Apple', + kind: 'simulator', + target: 'mobile', + booted: true, + ...overrides, + }; +} + +const leaves = { + ios: appleDevice(), + ipados: appleDevice({ appleOs: 'ipados' }), + tvos: appleDevice({ appleOs: 'tvos', target: 'tv' }), + macos: appleDevice({ appleOs: 'macos', kind: 'device', target: 'desktop' }), + visionos: appleDevice({ appleOs: 'visionos' }), + watchos: appleDevice({ appleOs: 'watchos' }), +} satisfies Record; + +test.each([ + ['iOS simulator', leaves.ios, true, undefined], + [ + 'iOS physical CoreDevice', + appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'coredevice' }), + true, + undefined, + ], + [ + 'iOS physical XCTest', + appleDevice({ kind: 'device', iosPhysicalDeviceBackend: 'xctest' }), + false, + 'CoreDevice-backed physical iOS device', + ], + ['iPadOS simulator', leaves.ipados, true, undefined], + ['tvOS simulator', leaves.tvos, true, undefined], + ['macOS host', leaves.macos, true, undefined], + ['visionOS simulator', leaves.visionos, true, undefined], + ['watchOS sentinel', leaves.watchos, false, 'watchOS app logs are not supported'], +])('classifies the %s leaf explicitly', async (_name, device, available, hint) => { + const binding = await createApplePlatformRuntime({} as PlatformRuntimeHost).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + const { facts } = binding; + expect(facts.device.providerMode).toBe('local'); + expect(facts.operations.networkDump).toEqual({ available: true }); + for (const operation of ['appLogInspect', 'appLogDoctor', 'appLogStart'] as const) { + const fact = facts.operations[operation]; + expect(fact.available).toBe(available); + if (!available && hint) expect(fact).toHaveProperty('hint', expect.stringContaining(hint)); + } +}); diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts new file mode 100644 index 0000000000..ea56011ac9 --- /dev/null +++ b/packages/platform-apple/src/runtime.ts @@ -0,0 +1,39 @@ +import type { + DeviceBinding, + NetworkDumpInput, + PlatformRuntimeHost, + PlatformRuntimeOperations, + PlatformRuntimeOwner, +} from '@agent-device/contracts/platform'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createAppleAppLogRuntime } from './logs/runtime.ts'; +import { dumpAppleNetworkTraffic } from './network/runtime.ts'; + +const owner = localRuntimeOwner('apple'); +const available = Object.freeze({ available: true } as const); + +export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { + const appLogs = createAppleAppLogRuntime(host); + return Object.freeze({ + owner, + ownsDevice: (device) => device.platform === 'apple', + bind: async (request) => { + const logs = await appLogs.bind(request); + return Object.freeze({ + device: logs.device, + owner, + facts: Object.freeze({ + device: logs.facts.device, + operations: { ...logs.facts.operations, networkDump: available }, + }), + operations: Object.freeze({ + ...logs.operations, + networkDump: async (input: NetworkDumpInput) => + await dumpAppleNetworkTraffic(host, request.device, input, request.scope.signal), + }), + [Symbol.asyncDispose]: async () => await logs[Symbol.asyncDispose](), + }) satisfies DeviceBinding; + }, + shutdown: async () => await appLogs.shutdown(), + }); +} diff --git a/packages/platform-harmonyos/src/index.ts b/packages/platform-harmonyos/src/index.ts index 51e4910752..e85c985290 100644 --- a/packages/platform-harmonyos/src/index.ts +++ b/packages/platform-harmonyos/src/index.ts @@ -1,6 +1,6 @@ import type { - AppLogRuntimePlatformModule, InventoryPlatformModule, + PlatformRuntimeModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; import type { HarmonyInventoryConfig } from './inventory-config.ts'; @@ -12,10 +12,10 @@ const metadata = Object.freeze({ export const runtimeModule = Object.freeze({ ...metadata, loadRuntime: async (host) => { - const { createHarmonyAppLogRuntime } = await import('./logs/runtime.ts'); - return createHarmonyAppLogRuntime(host); + const { createHarmonyPlatformRuntime } = await import('./runtime.ts'); + return createHarmonyPlatformRuntime(host); }, -} satisfies AppLogRuntimePlatformModule); +} satisfies PlatformRuntimeModule); export type { HarmonyInventoryConfig } from './inventory-config.ts'; diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts new file mode 100644 index 0000000000..ffaccfc529 --- /dev/null +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'vitest'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createHarmonyPlatformRuntime } from './runtime.ts'; + +const device: DeviceInfo = { + platform: 'harmonyos', + id: 'harmony-fact', + name: 'HarmonyOS', + kind: 'device', + target: 'mobile', + booted: true, +}; + +test('classifies the HarmonyOS runtime denominator', async () => { + const host = { + processTransports: { resolve: async () => ({ mode: 'local' as const }) }, + } as unknown as PlatformRuntimeHost; + const binding = await createHarmonyPlatformRuntime(host).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + const { facts } = binding; + expect(facts.device.providerMode).toBe('local'); + expect(facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + }); + expect(facts.operations.appLogInspect).toEqual({ available: true }); +}); diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts new file mode 100644 index 0000000000..73411bed44 --- /dev/null +++ b/packages/platform-harmonyos/src/runtime.ts @@ -0,0 +1,36 @@ +import type { + DeviceBinding, + PlatformRuntimeHost, + PlatformRuntimeOperations, + PlatformRuntimeOwner, +} from '@agent-device/contracts/platform'; +import { localRuntimeOwner } from '@agent-device/contracts/platform'; +import { createHarmonyAppLogRuntime } from './logs/runtime.ts'; + +const owner = localRuntimeOwner('harmonyos'); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { + const appLogs = createHarmonyAppLogRuntime(host); + return Object.freeze({ + owner, + ownsDevice: (device) => device.platform === 'harmonyos', + bind: async (request) => { + const logs = await appLogs.bind(request); + return Object.freeze({ + device: logs.device, + owner, + facts: Object.freeze({ + device: logs.facts.device, + operations: { ...logs.facts.operations, networkDump: unavailable }, + }), + operations: logs.operations, + [Symbol.asyncDispose]: async () => await logs[Symbol.asyncDispose](), + }) satisfies DeviceBinding; + }, + shutdown: async () => await appLogs.shutdown(), + }); +} diff --git a/packages/platform-linux/src/index.ts b/packages/platform-linux/src/index.ts index bd00111262..39fc4ec8c5 100644 --- a/packages/platform-linux/src/index.ts +++ b/packages/platform-linux/src/index.ts @@ -1,6 +1,6 @@ import type { - AppLogRuntimePlatformModule, InventoryPlatformModule, + PlatformRuntimeModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -11,10 +11,10 @@ const metadata = Object.freeze({ export const runtimeModule = Object.freeze({ ...metadata, loadRuntime: async (_host) => { - const { createLinuxAppLogRuntime } = await import('./logs/runtime.ts'); - return createLinuxAppLogRuntime(); + const { createLinuxPlatformRuntime } = await import('./runtime.ts'); + return createLinuxPlatformRuntime(); }, -} satisfies AppLogRuntimePlatformModule); +} satisfies PlatformRuntimeModule); export const inventoryModule: InventoryPlatformModule<'linux'> = Object.freeze({ ...metadata, diff --git a/packages/platform-linux/src/logs/runtime.ts b/packages/platform-linux/src/logs/runtime.ts deleted file mode 100644 index 8de8e0b181..0000000000 --- a/packages/platform-linux/src/logs/runtime.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AppLogRuntimeOperations, DeviceRuntimeOwner } from '@agent-device/contracts/platform'; -import { createUnavailableAppLogRuntimeOwner } from '@agent-device/capture-kit'; - -const unavailable = Object.freeze({ - available: false, - reason: 'unsupported-platform-leaf', -} as const); - -export function createLinuxAppLogRuntime(): DeviceRuntimeOwner { - return createUnavailableAppLogRuntimeOwner('linux', unavailable); -} diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts new file mode 100644 index 0000000000..76a40beb7c --- /dev/null +++ b/packages/platform-linux/src/runtime.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest'; +import { createLinuxPlatformRuntime } from './runtime.ts'; + +test('classifies the Linux runtime denominator as unavailable', async () => { + const binding = await createLinuxPlatformRuntime().bind({ + device: { + platform: 'linux', + id: 'linux', + name: 'Linux', + kind: 'device', + target: 'desktop', + booted: true, + }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + expect(binding.facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + }); +}); diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts new file mode 100644 index 0000000000..436c0f3199 --- /dev/null +++ b/packages/platform-linux/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PlatformRuntimeOwner } from '@agent-device/contracts/platform'; +import { createUnavailablePlatformRuntimeOwner } from '@agent-device/capture-kit'; + +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +export function createLinuxPlatformRuntime(): PlatformRuntimeOwner { + return createUnavailablePlatformRuntimeOwner('linux', { + appLog: unavailable, + network: unavailable, + }); +} diff --git a/packages/platform-vega/src/index.ts b/packages/platform-vega/src/index.ts index 144226a753..af9e8f5057 100644 --- a/packages/platform-vega/src/index.ts +++ b/packages/platform-vega/src/index.ts @@ -1,6 +1,6 @@ import type { - AppLogRuntimePlatformModule, InventoryPlatformModule, + PlatformRuntimeModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -11,10 +11,10 @@ const metadata = Object.freeze({ export const runtimeModule = Object.freeze({ ...metadata, loadRuntime: async (_host) => { - const { createVegaAppLogRuntime } = await import('./logs/runtime.ts'); - return createVegaAppLogRuntime(); + const { createVegaPlatformRuntime } = await import('./runtime.ts'); + return createVegaPlatformRuntime(); }, -} satisfies AppLogRuntimePlatformModule); +} satisfies PlatformRuntimeModule); export const inventoryModule: InventoryPlatformModule<'vega'> = Object.freeze({ ...metadata, diff --git a/packages/platform-vega/src/logs/runtime.ts b/packages/platform-vega/src/logs/runtime.ts deleted file mode 100644 index e2663c7700..0000000000 --- a/packages/platform-vega/src/logs/runtime.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AppLogRuntimeOperations, DeviceRuntimeOwner } from '@agent-device/contracts/platform'; -import { createUnavailableAppLogRuntimeOwner } from '@agent-device/capture-kit'; - -const unavailable = Object.freeze({ - available: false, - reason: 'unsupported-platform-leaf', -} as const); - -export function createVegaAppLogRuntime(): DeviceRuntimeOwner { - return createUnavailableAppLogRuntimeOwner('vega', unavailable); -} diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts new file mode 100644 index 0000000000..df81780b83 --- /dev/null +++ b/packages/platform-vega/src/runtime.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest'; +import { createVegaPlatformRuntime } from './runtime.ts'; + +test('classifies the Vega runtime denominator as unavailable', async () => { + const binding = await createVegaPlatformRuntime().bind({ + device: { + platform: 'vega', + id: 'vega', + name: 'Vega', + kind: 'device', + target: 'tv', + booted: true, + }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + expect(binding.facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + }); +}); diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts new file mode 100644 index 0000000000..2e61cd4dcd --- /dev/null +++ b/packages/platform-vega/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PlatformRuntimeOwner } from '@agent-device/contracts/platform'; +import { createUnavailablePlatformRuntimeOwner } from '@agent-device/capture-kit'; + +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +export function createVegaPlatformRuntime(): PlatformRuntimeOwner { + return createUnavailablePlatformRuntimeOwner('vega', { + appLog: unavailable, + network: unavailable, + }); +} diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index 26aee4edea..b2ad8335e4 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -6,7 +6,8 @@ "description": "Web-family platform runtime metadata and implementations for agent-device.", "dependencies": { "@agent-device/capture-kit": "workspace:*", - "@agent-device/contracts": "workspace:*" + "@agent-device/contracts": "workspace:*", + "@agent-device/kernel": "workspace:*" }, "exports": { ".": { diff --git a/packages/platform-web/src/index.ts b/packages/platform-web/src/index.ts index 0424338ea6..2d9cba4986 100644 --- a/packages/platform-web/src/index.ts +++ b/packages/platform-web/src/index.ts @@ -1,6 +1,6 @@ import type { - AppLogRuntimePlatformModule, InventoryPlatformModule, + PlatformRuntimeModule, PlatformModuleMetadata, } from '@agent-device/contracts/platform'; @@ -10,11 +10,11 @@ const metadata = Object.freeze({ export const runtimeModule = Object.freeze({ ...metadata, - loadRuntime: async (_host) => { - const { createWebAppLogRuntime } = await import('./logs/runtime.ts'); - return createWebAppLogRuntime(); + loadRuntime: async (host) => { + const { createWebPlatformRuntime } = await import('./runtime.ts'); + return createWebPlatformRuntime(host); }, -} satisfies AppLogRuntimePlatformModule); +} satisfies PlatformRuntimeModule); export const inventoryModule: InventoryPlatformModule<'web'> = Object.freeze({ ...metadata, diff --git a/packages/platform-web/src/logs/runtime.ts b/packages/platform-web/src/logs/runtime.ts deleted file mode 100644 index d70b131c1d..0000000000 --- a/packages/platform-web/src/logs/runtime.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AppLogRuntimeOperations, DeviceRuntimeOwner } from '@agent-device/contracts/platform'; -import { createUnavailableAppLogRuntimeOwner } from '@agent-device/capture-kit'; - -const unavailable = Object.freeze({ - available: false, - reason: 'unsupported-platform-leaf', -} as const); - -export function createWebAppLogRuntime(): DeviceRuntimeOwner { - return createUnavailableAppLogRuntimeOwner('web', unavailable); -} diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts new file mode 100644 index 0000000000..dfce090c55 --- /dev/null +++ b/packages/platform-web/src/runtime.test.ts @@ -0,0 +1,116 @@ +import { expect, test, vi } from 'vitest'; +import type { NetworkProviderDump, PlatformRuntimeHost } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createWebPlatformRuntime } from './runtime.ts'; + +const device: DeviceInfo = { + platform: 'web', + id: 'web', + name: 'Browser', + kind: 'device', + target: 'desktop', + booted: true, +}; + +test('preserves a narrow web provider dump including empty successful entries', async () => { + const dump: NetworkProviderDump = vi.fn(async () => ({ + backend: 'fixture-web', + entries: [], + notes: ['provider note'], + })); + const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed', dump })).bind({ + device, + intent: { kind: 'ordinary' }, + scope: scope(), + }); + + await expect(binding.operations.networkDump?.(input())).resolves.toEqual({ + source: 'provider', + backend: 'fixture-web', + entries: [], + notes: ['provider note'], + }); + expect(binding.facts.device.providerMode).toBe('transport-composed'); + expect(binding.facts.operations.networkDump).toEqual({ available: true }); +}); + +test('keeps a web transport without dumpNetwork unavailable instead of throwing a stub', async () => { + const binding = await createWebPlatformRuntime(host({ mode: 'transport-composed' })).bind({ + device, + intent: { kind: 'ordinary' }, + scope: scope(), + }); + expect(binding.operations.networkDump).toBeUndefined(); + expect(binding.facts.operations.networkDump).toMatchObject({ + available: false, + reason: 'owner-capability-missing', + hint: 'network is not supported by this web provider', + }); +}); + +function input() { + return { + sessionId: 'one', + maxEntries: 25, + include: 'summary' as const, + maxPayloadChars: 2048, + maxScanLines: 4000, + }; +} + +function scope() { + return { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }; +} + +function host( + transport: Awaited>, +): PlatformRuntimeHost { + return { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + toolchains: { prepare: async () => {} }, + commands: { + which: async () => undefined, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + artifacts: { + resolveSession: () => ({ + outputPath: '/sessions/one/app.log', + pidPath: '/sessions/one/app-log.pid', + }), + }, + outputs: { + openAppend: async () => { + throw new Error('unused'); + }, + readTail: async () => '', + }, + processes: { + start: async () => { + throw new Error('unused'); + }, + readMarker: async () => ({ status: 'missing' }), + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + processTransports: { resolve: async () => ({ mode: 'local' }) }, + clock: { now: () => 1, sleep: async () => {} }, + appLogs: { + readRecent: async () => ({ + path: '/sessions/one/app.log', + exists: false, + text: '', + skippedLines: 0, + }), + readProcessMarker: async () => ({ status: 'missing' }), + }, + networkTransports: { resolve: async () => transport }, + }; +} diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts new file mode 100644 index 0000000000..623925d631 --- /dev/null +++ b/packages/platform-web/src/runtime.ts @@ -0,0 +1,85 @@ +import type { + DeviceBinding, + PlatformRuntimeHost, + PlatformRuntimeOperations, + PlatformRuntimeOwner, + RuntimeFacts, +} from '@agent-device/contracts/platform'; +import { localRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +const owner = localRuntimeOwner('web'); +const available = Object.freeze({ available: true } as const); +const appLogUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', +} as const); + +export function createWebPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner { + return Object.freeze({ + owner, + ownsDevice: (device) => device.platform === 'web', + bind: async (request) => { + if (request.intent.kind === 'exact-owner' && !sameRuntimeOwner(request.intent.owner, owner)) { + throw new AppError('UNSUPPORTED_OPERATION', 'Web runtime owner identity does not match'); + } + if (request.device.platform !== 'web') { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + `Web runtime owner cannot bind ${request.device.platform}`, + ); + } + const transport = await host.networkTransports.resolve(request.device); + return bindWebRuntime(request.device, request.scope.signal, transport); + }, + shutdown: async () => undefined, + }); +} + +function bindWebRuntime( + device: DeviceInfo, + signal: AbortSignal, + transport: Awaited>, +): DeviceBinding { + const networkUnavailable = Object.freeze({ + available: false, + reason: 'owner-capability-missing', + hint: 'network is not supported by this web provider', + } as const); + const dump = transport.dump; + const operations: DeviceBinding['operations'] = dump + ? { + networkDump: async (input) => { + const result = await dump( + { maxEntries: input.maxEntries, include: input.include }, + signal, + ); + return Object.freeze({ source: 'provider' as const, ...result }); + }, + } + : {}; + const facts: RuntimeFacts = Object.freeze({ + device: { + family: 'web', + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + providerMode: transport.mode, + }, + operations: { + appLogInspect: appLogUnavailable, + appLogDoctor: appLogUnavailable, + appLogStart: appLogUnavailable, + appLogReattach: appLogUnavailable, + appLogCleanup: appLogUnavailable, + networkDump: transport.dump ? available : networkUnavailable, + }, + }); + return Object.freeze({ + device, + owner, + facts, + operations: Object.freeze(operations), + [Symbol.asyncDispose]: async () => undefined, + }); +} diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 4e6bbcee02..ac2278f0b7 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -1,8 +1,8 @@ -import type { AppLogRuntimeHost, PlatformRequestScope } from '@agent-device/contracts/platform'; +import type { PlatformRequestScope, PlatformRuntimeHost } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { expect, test, vi } from 'vitest'; import { createLimrunAppLogEnvelope } from './app-log-descriptor.ts'; -import { createLimrunAppLogRuntimeOwner } from './app-log-runtime.ts'; +import { createLimrunPlatformRuntimeOwner } from './app-log-runtime.ts'; const device: DeviceInfo = { platform: 'apple', @@ -22,7 +22,7 @@ const scope: PlatformRequestScope = { test('rejects a cross-platform durable descriptor before provider reconnection', async () => { const reconnect = vi.fn(async () => ({ status: 'missing' as const })); - const owner = createLimrunAppLogRuntimeOwner({ + const owner = createLimrunPlatformRuntimeOwner({ host: unusedHost(), runtimeInstance: 'default', ownsDevice: () => true, @@ -63,7 +63,7 @@ test('rejects a cross-platform durable descriptor before provider reconnection', test('rejects cross-session paths before reconnecting or opening a provider reader', async () => { const reconnect = vi.fn(async () => ({ status: 'missing' as const })); const openCurrent = vi.fn(async () => undefined); - const owner = createLimrunAppLogRuntimeOwner({ + const owner = createLimrunPlatformRuntimeOwner({ host: unusedHost(), runtimeInstance: 'default', ownsDevice: () => true, @@ -123,7 +123,7 @@ test.each([ }, ])('rejects exact binding for an impossible Limrun $name', async ({ device: invalidDevice }) => { const reconnect = vi.fn(async () => ({ status: 'missing' as const })); - const owner = createLimrunAppLogRuntimeOwner({ + const owner = createLimrunPlatformRuntimeOwner({ host: unusedHost(), runtimeInstance: 'default', ownsDevice: () => true, @@ -145,7 +145,72 @@ test.each([ expect(reconnect).not.toHaveBeenCalled(); }); -function unusedHost(): AppLogRuntimeHost { +test('serves provider-owned network from the canonical session app log without local recovery', async () => { + const commandRun = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })); + const base = unusedHost(); + const owner = createLimrunPlatformRuntimeOwner({ + host: { + ...base, + commands: { ...base.commands, run: commandRun }, + appLogs: { + ...base.appLogs, + readRecent: async () => ({ + path: '/sessions/session/app.log', + exists: true, + text: '2026-08-10T10:00:00Z GET https://limrun.example.test status=200\n', + skippedLines: 0, + }), + }, + }, + runtimeInstance: 'default', + ownsDevice: () => true, + openCurrent: async () => undefined, + reconnect: async () => ({ status: 'missing' }), + }); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + await expect( + binding.operations.networkDump?.({ + sessionId: 'session', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 4000, + }), + ).resolves.toMatchObject({ + source: 'app-log', + backend: 'ios-simulator', + dump: { entries: [{ url: 'https://limrun.example.test' }] }, + }); + expect(commandRun).not.toHaveBeenCalled(); +}); + +test.each([ + ['iOS simulator', device], + [ + 'Android emulator', + { + platform: 'android' as const, + id: 'limrun:android:lease-a', + name: 'Limrun Android', + kind: 'emulator' as const, + target: 'mobile' as const, + booted: true, + }, + ], +])('classifies the direct Limrun %s runtime denominator', async (_name, runtimeDevice) => { + const owner = createLimrunPlatformRuntimeOwner({ + host: unusedHost(), + runtimeInstance: 'default', + ownsDevice: () => true, + openCurrent: async () => undefined, + reconnect: async () => ({ status: 'missing' }), + }); + const binding = await owner.bind({ device: runtimeDevice, intent: { kind: 'ordinary' }, scope }); + expect(binding.facts.device.providerMode).toBe('provider-runtime'); + expect(binding.facts.operations.networkDump).toEqual({ available: true }); +}); + +function unusedHost(): PlatformRuntimeHost { return { appleTools: { isXcrunAvailable: async () => false, @@ -183,5 +248,15 @@ function unusedHost(): AppLogRuntimeHost { terminate: async () => 'already-missing', }, clock: { now: () => 1, sleep: async () => {} }, + appLogs: { + readRecent: async () => ({ + path: '/sessions/session/app.log', + exists: false, + text: '', + skippedLines: 0, + }), + readProcessMarker: async () => ({ status: 'missing' }), + }, + networkTransports: { resolve: async () => ({ mode: 'local' }) }, }; } diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index f0384a4ced..7e7a55fc4d 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -2,10 +2,10 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { parseLimrunDeviceId } from './device.ts'; import type { - AppLogRuntimeHost, - AppLogRuntimeOperations, DeviceBinding, - DeviceRuntimeOwner, + PlatformRuntimeHost, + PlatformRuntimeOperations, + PlatformRuntimeOwner, RuntimeFacts, } from '@agent-device/contracts/platform'; import { @@ -13,6 +13,7 @@ import { assertAppLogSessionArtifacts, createAppLogRecoveryOperations, createAppLogStartResult, + readRecentNetworkTrafficFromText, } from '@agent-device/capture-kit'; import { providerRuntimeOwner, sameRuntimeOwner } from '@agent-device/contracts/platform'; import { @@ -27,8 +28,8 @@ export type LimrunAppLogReconnectOutcome = | Readonly<{ status: 'missing' }> | Readonly<{ status: 'ownership-lost' }>; -export type LimrunAppLogRuntimeOwnerOptions = Readonly<{ - host: AppLogRuntimeHost; +export type LimrunPlatformRuntimeOwnerOptions = Readonly<{ + host: PlatformRuntimeHost; runtimeInstance: string; ownsDevice(device: DeviceInfo): boolean; openCurrent(device: DeviceInfo): Promise; @@ -40,9 +41,9 @@ export type LimrunAppLogRuntimeOwnerOptions = Readonly<{ const available = Object.freeze({ available: true } as const); -export function createLimrunAppLogRuntimeOwner( - options: LimrunAppLogRuntimeOwnerOptions, -): DeviceRuntimeOwner { +export function createLimrunPlatformRuntimeOwner( + options: LimrunPlatformRuntimeOwnerOptions, +): PlatformRuntimeOwner { const owner = providerRuntimeOwner('limrun', options.runtimeInstance); return Object.freeze({ owner, @@ -64,11 +65,11 @@ export function createLimrunAppLogRuntimeOwner( } function bindLimrunAppLogs( - options: LimrunAppLogRuntimeOwnerOptions, + options: LimrunPlatformRuntimeOwnerOptions, owner: ReturnType, device: DeviceInfo, signal: AbortSignal, -): DeviceBinding { +): DeviceBinding { const recovery = createAppLogRecoveryOperations({ codec: limrunAppLogDescriptorCodec, reattach: async (descriptor, context) => { @@ -153,7 +154,23 @@ function bindLimrunAppLogs( } }, ...recovery, - } satisfies AppLogRuntimeOperations; + networkDump: async (input) => { + const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); + const backend = backendForDevice(device); + const dump = readRecentNetworkTrafficFromText(recent.text, { + ...input, + path: recent.path, + exists: recent.exists, + lineNumberOffset: recent.skippedLines, + backend, + }); + const notes = + dump.entries.length === 0 + ? ['No HTTP(s) entries were found in recent session app logs.'] + : []; + return Object.freeze({ source: 'app-log' as const, backend, dump, notes }); + }, + } satisfies PlatformRuntimeOperations; return Object.freeze({ device, owner, @@ -164,7 +181,7 @@ function bindLimrunAppLogs( } async function currentSessionAvailable( - options: LimrunAppLogRuntimeOwnerOptions, + options: LimrunPlatformRuntimeOwnerOptions, device: DeviceInfo, signal: AbortSignal, ): Promise { @@ -179,7 +196,7 @@ async function currentSessionAvailable( return true; } -function facts(device: DeviceInfo): RuntimeFacts { +function facts(device: DeviceInfo): RuntimeFacts { return Object.freeze({ device: { family: device.platform, @@ -197,6 +214,7 @@ function facts(device: DeviceInfo): RuntimeFacts { appLogStart: available, appLogReattach: available, appLogCleanup: available, + networkDump: available, }, }); } diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index b98d569561..1b6e10af3e 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -35,10 +35,9 @@ import { import { createLimrunDeviceSession, type LimrunDeviceSession } from './device-session.ts'; import type { LimrunRuntimeDependencies } from './runtime-dependencies.ts'; import type { - AppLogRuntimeHost, - AppLogRuntimeOperations, - AppLogRuntimeProviderModule, - DeviceRuntimeOwner, + PlatformRuntimeHost, + PlatformRuntimeOwner, + PlatformRuntimeProviderModule, } from '@agent-device/contracts/platform'; import { providerRuntimeOwner } from '@agent-device/contracts/platform'; import type { LimrunAppLogDescriptor } from './app-log-descriptor.ts'; @@ -70,13 +69,13 @@ export type LimrunRuntime = ProviderDeviceRuntime & { export type LimrunRuntimeRegistration = Readonly<{ runtime: LimrunRuntime; - appLogModule: AppLogRuntimeProviderModule; + platformModule: PlatformRuntimeProviderModule; }>; export function createLimrunRuntime( options: LimrunRuntimeOptions, dependencies: LimrunRuntimeDependencies, - mode: Readonly<{ includeAppLogModule: true }>, + mode: Readonly<{ includePlatformModule: true }>, ): LimrunRuntimeRegistration; export function createLimrunRuntime( options: LimrunRuntimeOptions, @@ -85,18 +84,18 @@ export function createLimrunRuntime( export function createLimrunRuntime( options: LimrunRuntimeOptions, dependencies: LimrunRuntimeDependencies, - mode?: Readonly<{ includeAppLogModule: true }>, + mode?: Readonly<{ includePlatformModule: true }>, ): LimrunRuntime | LimrunRuntimeRegistration { const runtime = new LimrunRuntimeImplementation(options, dependencies); - if (!mode?.includeAppLogModule) return runtime; + if (!mode?.includePlatformModule) return runtime; const owner = providerRuntimeOwner(LIMRUN_PROVIDER, resolveLimrunRuntimeInstance(options)); if (owner.kind !== 'provider-runtime') throw new TypeError('Invalid Limrun runtime owner'); return Object.freeze({ runtime, - appLogModule: Object.freeze({ + platformModule: Object.freeze({ owner, - loadRuntime: async (host: AppLogRuntimeHost) => - await loadLimrunAppLogRuntime(runtime, owner.instance, host), + loadRuntime: async (host: PlatformRuntimeHost) => + await loadLimrunPlatformRuntime(runtime, owner.instance, host), }), }); } @@ -367,13 +366,13 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime { } } -async function loadLimrunAppLogRuntime( +async function loadLimrunPlatformRuntime( runtime: LimrunRuntimeImplementation, runtimeInstance: string, - host: AppLogRuntimeHost, -): Promise> { - const { createLimrunAppLogRuntimeOwner } = await import('./app-log-runtime.ts'); - return createLimrunAppLogRuntimeOwner({ + host: PlatformRuntimeHost, +): Promise { + const { createLimrunPlatformRuntimeOwner } = await import('./app-log-runtime.ts'); + return createLimrunPlatformRuntimeOwner({ host, runtimeInstance, ownsDevice: (device) => runtime.ownsDevice(device), diff --git a/packages/provider-webdriver/package.json b/packages/provider-webdriver/package.json index 35c6b546d4..685ceadbe3 100644 --- a/packages/provider-webdriver/package.json +++ b/packages/provider-webdriver/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "BrowserStack and AWS Device Farm WebDriver providers for agent-device. Internal workspace package bundled into the published artifact.", "dependencies": { + "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*", "@agent-device/xml": "workspace:*" diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts new file mode 100644 index 0000000000..3673daf0ae --- /dev/null +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -0,0 +1,134 @@ +import { expect, test, vi } from 'vitest'; +import type { PlatformRuntimeHost } from '@agent-device/contracts/platform'; +import { providerRuntimeOwner } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createWebDriverPlatformRuntimeOwner } from './platform-runtime.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'browserstack:lease-one', + name: 'Remote Android', + kind: 'device', + target: 'mobile', + booted: true, +}; + +test('direct WebDriver network uses only the canonical session log and preserves empty success', async () => { + const run = vi.fn(async () => ({ stdout: 'must not execute', stderr: '', exitCode: 0 })); + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(run), + owner: providerRuntimeOwner('browserstack', 'android'), + ownsDevice: () => true, + }); + const binding = await owner.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + 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.facts.operations.networkDump).toEqual({ available: true }); + expect(binding.facts.operations.appLogInspect).toMatchObject({ available: false }); + expect(run).not.toHaveBeenCalled(); +}); + +test.each([ + ['Android', device, 'android'], + [ + 'iOS', + { + ...device, + platform: 'apple' as const, + appleOs: 'ios' as const, + id: 'browserstack:ios:lease-one', + name: 'Remote iPhone', + }, + 'ios-device', + ], +])('classifies the direct WebDriver %s network cell', async (_name, runtimeDevice, backend) => { + const owner = createWebDriverPlatformRuntimeOwner({ + host: host(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + owner: providerRuntimeOwner('browserstack', String(_name).toLowerCase()), + ownsDevice: () => true, + }); + const binding = await owner.bind({ + device: runtimeDevice, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + expect(binding.facts.device.providerMode).toBe('provider-runtime'); + expect(binding.facts.operations.networkDump).toEqual({ available: true }); + await expect( + binding.operations.networkDump?.({ + sessionId: 'one', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 4000, + }), + ).resolves.toMatchObject({ source: 'app-log', backend }); +}); + +function host(run: PlatformRuntimeHost['commands']['run']): PlatformRuntimeHost { + return { + appleTools: { + isXcrunAvailable: async () => false, + run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), + }, + toolchains: { prepare: async () => {} }, + commands: { which: async () => undefined, run }, + artifacts: { + resolveSession: () => ({ + outputPath: '/sessions/one/app.log', + pidPath: '/sessions/one/app-log.pid', + }), + }, + outputs: { + openAppend: async () => { + throw new Error('unused'); + }, + readTail: async () => '', + }, + processes: { + start: async () => { + throw new Error('unused'); + }, + readMarker: async () => ({ status: 'missing' }), + clearMarker: async () => {}, + inspect: async () => 'missing', + terminate: async () => 'already-missing', + }, + processTransports: { resolve: async () => ({ mode: 'local' }) }, + clock: { now: () => 1, sleep: async () => {} }, + appLogs: { + readRecent: async () => ({ + path: '/sessions/one/app.log', + exists: false, + text: '', + skippedLines: 0, + }), + readProcessMarker: async () => ({ status: 'missing' }), + }, + networkTransports: { resolve: async () => ({ mode: 'local' }) }, + }; +} diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts new file mode 100644 index 0000000000..b3cdd11b8f --- /dev/null +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -0,0 +1,102 @@ +import type { + DeviceBinding, + PlatformRuntimeHost, + PlatformRuntimeOperations, + PlatformRuntimeOwner, + RuntimeFacts, + RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import { + createUnavailablePlatformRuntimeBinding, + readRecentNetworkTrafficFromText, +} from '@agent-device/capture-kit'; +import { sameRuntimeOwner } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +const available = Object.freeze({ available: true } as const); +const appLogUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-provider-mode', +} as const); + +export function createWebDriverPlatformRuntimeOwner( + options: Readonly<{ + host: PlatformRuntimeHost; + owner: Extract; + ownsDevice(device: DeviceInfo): boolean; + }>, +): PlatformRuntimeOwner { + return Object.freeze({ + owner: options.owner, + ownsDevice: options.ownsDevice, + bind: async (request) => { + if ( + request.intent.kind === 'exact-owner' && + !sameRuntimeOwner(request.intent.owner, options.owner) + ) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'WebDriver runtime owner identity does not match', + ); + } + if (!options.ownsDevice(request.device)) { + throw new AppError('UNSUPPORTED_PLATFORM', 'WebDriver runtime does not own this device'); + } + return bindWebDriverPlatformRuntime(options.host, options.owner, request.device); + }, + shutdown: async () => undefined, + }); +} + +function bindWebDriverPlatformRuntime( + host: PlatformRuntimeHost, + owner: Extract, + device: DeviceInfo, +): DeviceBinding { + const backend = device.platform === 'apple' ? 'ios-device' : 'android'; + const unavailable = createUnavailablePlatformRuntimeBinding(device, owner, { + appLog: appLogUnavailable, + network: appLogUnavailable, + }); + const facts: RuntimeFacts = Object.freeze({ + device: unavailable.facts.device, + operations: { + appLogInspect: appLogUnavailable, + appLogDoctor: appLogUnavailable, + appLogStart: appLogUnavailable, + appLogReattach: appLogUnavailable, + appLogCleanup: appLogUnavailable, + networkDump: available, + }, + }); + const operations: DeviceBinding['operations'] = Object.freeze({ + networkDump: async (input) => { + const recent = await 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.'] + : [], + ), + }); + }, + }); + return Object.freeze({ + device, + owner, + facts, + operations, + [Symbol.asyncDispose]: async () => undefined, + }); +} diff --git a/packages/provider-webdriver/src/runtime.ts b/packages/provider-webdriver/src/runtime.ts index 73d7c7b396..b934154074 100644 --- a/packages/provider-webdriver/src/runtime.ts +++ b/packages/provider-webdriver/src/runtime.ts @@ -34,6 +34,12 @@ import { type WebDriverRequestPolicy, } from './webdriver-client.ts'; import { createWebDriverInteractor } from './webdriver-interactor.ts'; +import type { + PlatformRuntimeHost, + PlatformRuntimeOwner, + PlatformRuntimeProviderModule, +} from '@agent-device/contracts/platform'; +import { providerRuntimeOwner } from '@agent-device/contracts/platform'; export type CloudWebDriverPlatform = 'android' | 'ios'; @@ -143,6 +149,7 @@ export function buildCloudWebDriverBaseCapabilities( class CloudWebDriverRuntime implements ProviderDeviceRuntime { readonly provider: string; + readonly owner: PlatformRuntimeProviderModule['owner']; readonly leaseLifecycle: LeaseLifecycleProvider; readonly cloudArtifacts: CloudArtifactProvider; readonly deviceInventoryProvider: DeviceInventoryProvider; @@ -155,6 +162,7 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { constructor(options: CloudWebDriverRuntimeOptions) { this.options = options; this.provider = options.provider; + this.owner = providerRuntimeOwner(options.provider, options.platform); this.capabilities = createCloudWebDriverCapabilities({ provider: options.provider, platform: options.platform, @@ -176,6 +184,15 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { }; } + async loadRuntime(host: PlatformRuntimeHost): Promise { + const { createWebDriverPlatformRuntimeOwner } = await import('./platform-runtime.ts'); + return createWebDriverPlatformRuntimeOwner({ + host, + owner: this.owner, + ownsDevice: (device) => this.ownsDevice(device), + }); + } + ownsDevice(device: DeviceInfo): boolean { return [...this.sessionsByLeaseId.values()].some((session) => session.device.id === device.id); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02e0a8e5c6..e63a9d25a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -269,6 +269,9 @@ importers: '@agent-device/contracts': specifier: workspace:* version: link:../contracts + '@agent-device/kernel': + specifier: workspace:* + version: link:../kernel packages/provider-limrun: dependencies: @@ -287,6 +290,9 @@ importers: packages/provider-webdriver: dependencies: + '@agent-device/capture-kit': + specifier: workspace:* + version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 5304acf3da..3b26f77c93 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -35,6 +35,8 @@ // composition file; premature implementation loading and forbidden cross-boundary edges fail (R13). // - Over the DEVICES COMMAND CUTOVER: the handler calls the neutral inventory gateway and no // superseded inventory module, import, or identifier remains in production (R13). +// - Over COMMAND-ATOMIC RUNTIME CUTOVERS: retired logs and network routes/admission cannot coexist +// with their operation-fact-derived descriptor and handler paths (R14-R15). // Only `(root)` is unranked among src/ zones (see `UNRANKED_ZONES` in model.ts): // it holds entrypoints and composition roots. Extracted workspace package zones // are classified separately and held behind R11 instead of the src folder spine. @@ -100,6 +102,11 @@ import { sourceExecutedUsingDeclarationViolations, } from './logs-runtime-cutover-policy.ts'; import { contractsImplementationAuthorityViolations } from './contracts-implementation-policy.ts'; +import { + networkLegacyRouteViolations, + networkRuntimeNarrowingViolations, + networkRuntimeRouteViolations, +} from './network-runtime-cutover-policy.ts'; const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', @@ -195,6 +202,23 @@ function checkContractsImplementationAuthority( ); } +function checkNetworkRuntimeCutover(sources: ReadonlyMap): LayeringViolation[] { + const production = [...sources].map(([file, source]) => ({ path: file, source })); + return [ + ...networkLegacyRouteViolations(production), + ...networkRuntimeNarrowingViolations(production), + ...networkRuntimeRouteViolations(production), + ].map((violation) => { + const separator = violation.indexOf(': '); + return { + rule: 'R15 network-runtime-cutover', + file: separator < 0 ? '(network runtime)' : violation.slice(0, separator), + line: 1, + message: separator < 0 ? violation : violation.slice(separator + 2), + }; + }); +} + function checkBackEdges(edges: readonly ResolvedImportEdge[]): LayeringViolation[] { const seen = new Set(); return edges.flatMap((edge) => { @@ -609,6 +633,8 @@ export function main(): number { ...checkCycles(edges), ...checkLogsRuntimeCutover(sources), ...checkContractsImplementationAuthority(sources), + ...checkNetworkRuntimeCutover(sources), + ...checkContractsImplementationAuthority(sources), ...checkBackEdges(edges), ...checkTypeInversions(edges), ...checkSessionStateOwnership(sources), diff --git a/scripts/layering/network-runtime-cutover-policy.test.ts b/scripts/layering/network-runtime-cutover-policy.test.ts new file mode 100644 index 0000000000..46ccbe4087 --- /dev/null +++ b/scripts/layering/network-runtime-cutover-policy.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + networkLegacyRouteViolations, + networkRuntimeNarrowingViolations, + networkRuntimeRouteViolations, +} from './network-runtime-cutover-policy.ts'; + +test('network cutover scan catches planted legacy execution and dual admission', () => { + assert.deepEqual( + networkLegacyRouteViolations([ + { + path: 'src/daemon/handlers/planted.ts', + source: ` + await readSessionNetworkCapture(input); + await provider.dumpNetwork({ include: 'all' }); + requireCommandSupported('network', device); + `, + }, + { + path: 'src/core/command-descriptor/registry.ts', + source: `const commands = [{ name: 'network', capability: { apple: {} } }];`, + }, + { + path: 'src/core/capabilities.ts', + source: `const WEB_QUERY_COMMANDS = ['find', 'network'];`, + }, + { + path: 'src/platforms/apple/plugin.ts', + source: `const supports = { [PUBLIC_COMMANDS.network]: supportsDevice };`, + }, + ]), + [ + 'src/daemon/handlers/planted.ts: legacy network route readSessionNetworkCapture', + 'src/daemon/handlers/planted.ts: daemon invokes legacy WebProvider.dumpNetwork', + 'src/daemon/handlers/planted.ts: legacy network capability admission requireCommandSupported', + 'src/core/command-descriptor/registry.ts: network descriptor retains legacy capability admission', + 'src/core/capabilities.ts: static platform command set retains network admission', + 'src/platforms/apple/plugin.ts: Apple plugin retains legacy network support or hint closure', + ], + ); +}); + +test('network cutover scan rejects a retired implementation by committed path', () => { + assert.deepEqual( + networkLegacyRouteViolations([ + { path: 'src/daemon/network-log.ts', source: `export const parser = () => [];` }, + ]), + ['src/daemon/network-log.ts: retired daemon network implementation remains'], + ); +}); + +test('network cutover scan ignores retired names in comments and string data', () => { + assert.deepEqual( + networkLegacyRouteViolations([ + { + path: 'src/daemon/planted.ts', + source: ` + // readSessionNetworkCapture and requireCommandSupported('network') were removed. + const note = 'provider.dumpNetwork and PUBLIC_COMMANDS.network'; + `, + }, + ]), + [], + ); +}); + +test('network narrowing scan catches planted casts, non-null repair, and bracket access', () => { + const violations = networkRuntimeNarrowingViolations([ + { + path: 'src/daemon/handlers/planted.ts', + source: ` + const widened = runtime as BoundDeviceRuntime; + widened.operations.networkDump!({}); + widened.operations['networkDump']({}); + `, + }, + ]); + assert.equal(violations.length, 3); + assert.match(violations.join('\n'), /type assertion/); + assert.match(violations.join('\n'), /non-null/); + assert.match(violations.join('\n'), /bracketed/); +}); + +test('network route scan rejects planted neither and duplicate routes', () => { + assert.deepEqual(networkRuntimeRouteViolations([]), [ + '(network runtime): expected one handleNetworkCommand route, found 0', + '(network runtime): expected one narrowed networkDump call, found 0', + ]); + assert.deepEqual( + networkRuntimeRouteViolations([ + { + path: 'src/daemon/planted.ts', + source: ` + handleNetworkCommand(first); + handleNetworkCommand(second); + runtime.operations.networkDump(input); + fallback.operations.networkDump(input); + `, + }, + ]), + [ + '(network runtime): expected one handleNetworkCommand route, found 2', + '(network runtime): expected one narrowed networkDump call, found 2', + ], + ); +}); diff --git a/scripts/layering/network-runtime-cutover-policy.ts b/scripts/layering/network-runtime-cutover-policy.ts new file mode 100644 index 0000000000..b6ce326d1f --- /dev/null +++ b/scripts/layering/network-runtime-cutover-policy.ts @@ -0,0 +1,257 @@ +import { parseSync } from 'oxc-parser'; + +export type NetworkRuntimeProductionSource = Readonly<{ path: string; source: string }>; + +const RETIRED_DAEMON_MODULES = new Set([ + 'src/daemon/network-log.ts', + 'src/daemon/app-log-network-recovery.ts', + 'src/daemon/network-log-android-recovery.ts', + 'src/daemon/network-log-ios-simulator-recovery.ts', +]); +const LEGACY_NETWORK_ROUTE_NAMES = new Set([ + 'handleWebNetworkCommand', + 'readSessionNetworkCapture', + 'readRecentNetworkTraffic', + 'readRecentAndroidLogcatForPackage', + 'readRecentIosSimulatorLogShowForBundle', +]); + +/** Network command admission and execution have one operation-fact-derived route. */ +export function networkLegacyRouteViolations( + sources: readonly NetworkRuntimeProductionSource[], +): string[] { + const violations: string[] = []; + for (const file of sources) { + if (RETIRED_DAEMON_MODULES.has(file.path)) { + violations.push(`${file.path}: retired daemon network implementation remains`); + } + const parsed = parseSync(file.path, file.source); + const seen = new Set(); + visitAst(parsed.program, (node) => { + const route = legacyRouteName(node); + if (route && file.path.startsWith('src/daemon/')) { + const identity = `${String(node.start ?? '')}:${route}`; + if (!seen.has(identity)) { + seen.add(identity); + violations.push(`${file.path}: legacy network route ${route}`); + } + } + if (isLegacyNetworkAdmission(node)) { + violations.push( + `${file.path}: legacy network capability admission requireCommandSupported`, + ); + } + if ( + file.path === 'src/core/command-descriptor/registry.ts' && + isNetworkDescriptorWithCapability(node) + ) { + violations.push(`${file.path}: network descriptor retains legacy capability admission`); + } + if (file.path === 'src/core/capabilities.ts' && isStaticNetworkAdmission(node)) { + violations.push(`${file.path}: static platform command set retains network admission`); + } + if (file.path === 'src/platforms/apple/plugin.ts' && isAppleNetworkCommandMember(node)) { + violations.push( + `${file.path}: Apple plugin retains legacy network support or hint closure`, + ); + } + if (file.path.startsWith('src/daemon/') && isLegacyWebDumpCall(node)) { + violations.push(`${file.path}: daemon invokes legacy WebProvider.dumpNetwork`); + } + }); + } + return violations; +} + +/** Daemon consumers must use the use-narrowed network operation without repairing proof. */ +export function networkRuntimeNarrowingViolations( + sources: readonly NetworkRuntimeProductionSource[], +): string[] { + const violations: string[] = []; + for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) { + const parsed = parseSync(file.path, file.source); + visitAst(parsed.program, (node) => { + if (node.type === 'TSAsExpression' && containsRuntimeTypeRepair(node.typeAnnotation)) { + violations.push(`${file.path}: widened network runtime type assertion`); + } + if (node.type === 'TSNonNullExpression' && isNetworkOperationAccess(node.expression)) { + violations.push(`${file.path}: non-null repair of networkDump operation`); + } + if (isBracketedNetworkOperationAccess(node)) { + violations.push(`${file.path}: bracketed networkDump operation access`); + } + }); + } + return violations; +} + +/** The command has one daemon orchestration route and one operation projection call. */ +export function networkRuntimeRouteViolations( + sources: readonly NetworkRuntimeProductionSource[], +): string[] { + let handlerCalls = 0; + let operationCalls = 0; + for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) { + const parsed = parseSync(file.path, file.source); + visitAst(parsed.program, (node) => { + if (node.type !== 'CallExpression') return; + const callee = node.callee as Record | undefined; + if (callee?.type === 'Identifier' && callee.name === 'handleNetworkCommand') { + handlerCalls += 1; + } + if (callee?.type === 'MemberExpression' && isRuntimeNetworkOperation(callee)) { + operationCalls += 1; + } + }); + } + const violations: string[] = []; + if (handlerCalls !== 1) { + violations.push( + `(network runtime): expected one handleNetworkCommand route, found ${handlerCalls}`, + ); + } + if (operationCalls !== 1) { + violations.push( + `(network runtime): expected one narrowed networkDump call, found ${operationCalls}`, + ); + } + return violations; +} + +function legacyRouteName(node: Record): string | undefined { + if (node.type === 'Identifier' && LEGACY_NETWORK_ROUTE_NAMES.has(String(node.name))) { + return String(node.name); + } + if (node.type === 'Property' || node.type === 'TSPropertySignature') { + const name = propertyName(node.key); + return name && LEGACY_NETWORK_ROUTE_NAMES.has(name) ? name : undefined; + } + return undefined; +} + +function isLegacyNetworkAdmission(node: Record): boolean { + if (node.type !== 'CallExpression') return false; + const callee = node.callee as Record | undefined; + const args = node.arguments as readonly Record[] | undefined; + return ( + callee?.type === 'Identifier' && + callee.name === 'requireCommandSupported' && + args?.[0]?.type === 'Literal' && + args[0].value === 'network' + ); +} + +function isNetworkDescriptorWithCapability(node: Record): boolean { + if (node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) return false; + let network = false; + let capability = false; + for (const property of node.properties as Record[]) { + if (property.type !== 'Property' || property.computed === true) continue; + const key = propertyName(property.key); + const value = property.value as Record | undefined; + if (key === 'name' && value?.type === 'Literal' && value.value === 'network') network = true; + if (key === 'capability') capability = true; + } + return network && capability; +} + +function isStaticNetworkAdmission(node: Record): boolean { + if (node.type !== 'VariableDeclarator') return false; + const id = node.id as Record | undefined; + return ( + id?.type === 'Identifier' && + /(?:WEB|HARMONY).*COMMANDS/.test(String(id.name)) && + astContainsString(node.init, 'network') + ); +} + +function isAppleNetworkCommandMember(node: Record): boolean { + if (node.type !== 'MemberExpression') return false; + const object = node.object as Record | undefined; + return ( + object?.type === 'Identifier' && + object.name === 'PUBLIC_COMMANDS' && + memberName(node) === 'network' + ); +} + +function isLegacyWebDumpCall(node: Record): boolean { + if (node.type !== 'CallExpression') return false; + const callee = node.callee as Record | undefined; + return callee?.type === 'MemberExpression' && memberName(callee) === 'dumpNetwork'; +} + +function containsRuntimeTypeRepair(node: unknown): boolean { + let found = false; + visitAst(node, (candidate) => { + if ( + candidate.type === 'Identifier' && + (candidate.name === 'BoundDeviceRuntime' || candidate.name === 'NetworkRuntimeOperations') + ) { + found = true; + } + }); + return found; +} + +function isNetworkOperationAccess(node: unknown): boolean { + if (node === null || typeof node !== 'object') return false; + const member = node as Record; + return member.type === 'MemberExpression' && memberName(member) === 'networkDump'; +} + +function isBracketedNetworkOperationAccess(node: Record): boolean { + if (node.type !== 'MemberExpression' || node.computed !== true) return false; + const object = node.object as Record | undefined; + return ( + memberName(node) === 'networkDump' && + object?.type === 'MemberExpression' && + memberName(object) === 'operations' + ); +} + +function isRuntimeNetworkOperation(node: Record): boolean { + const object = node.object as Record | undefined; + return ( + memberName(node) === 'networkDump' && + object?.type === 'MemberExpression' && + memberName(object) === 'operations' + ); +} + +function memberName(node: Record): string | undefined { + const property = node.property as Record | undefined; + if (!property) return undefined; + return node.computed === true + ? propertyName(property) + : property.type === 'Identifier' + ? String(property.name) + : undefined; +} + +function astContainsString(node: unknown, expected: string): boolean { + let found = false; + visitAst(node, (candidate) => { + if (candidate.type === 'Literal' && candidate.value === expected) found = true; + }); + return found; +} + +function propertyName(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const value = node as Record; + return value.type === 'Identifier' || value.type === 'Literal' + ? ((value.name as string | undefined) ?? (value.value as string | undefined)) + : undefined; +} + +function visitAst(node: unknown, visitor: (node: Record) => void): void { + if (node === null || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (const child of node) visitAst(child, visitor); + return; + } + const record = node as Record; + visitor(record); + for (const child of Object.values(record)) visitAst(child, visitor); +} diff --git a/scripts/layering/platform-composition-policy.ts b/scripts/layering/platform-composition-policy.ts index 018462074a..cbbdad0352 100644 --- a/scripts/layering/platform-composition-policy.ts +++ b/scripts/layering/platform-composition-policy.ts @@ -58,8 +58,8 @@ function isAllowedCompositionImport(specifier: string): boolean { return ( /^@agent-device\/contracts(?:\/|$)/.test(specifier) || /^@agent-device\/platform-[^/]+$/.test(specifier) || - specifier === './platform-runtime-app-log.ts' || - specifier === './platform-runtime-app-log-host.ts' || + specifier === './platform-runtime-gateway.ts' || + specifier === './platform-runtime-operation-host.ts' || specifier === './platform-runtime-device-inventory.ts' || specifier === './platform-runtime-host.ts' || specifier.startsWith('./platform-runtime-host/') diff --git a/scripts/layering/platform-package-policy.test.ts b/scripts/layering/platform-package-policy.test.ts index b4b4a3421d..61ddc871f0 100644 --- a/scripts/layering/platform-package-policy.test.ts +++ b/scripts/layering/platform-package-policy.test.ts @@ -254,13 +254,13 @@ test('composition imports are category-based for future contracts and host adapt [ composition(), "import type { PlatformRequestScope } from '@agent-device/contracts/platform';", - "import { createComposedAppLogRuntimeGateway } from './platform-runtime-app-log.ts';", - "import { createAppLogRuntimeHost } from './platform-runtime-app-log-host.ts';", + "import { createComposedPlatformRuntimeGateway } from './platform-runtime-gateway.ts';", + "import { createPlatformRuntimeHost } from './platform-runtime-operation-host.ts';", "import { hostCommandRunner } from './platform-runtime-host/command.ts';", "import { createComposedDeviceInventoryGateways } from './platform-runtime-device-inventory.ts';", 'void hostCommandRunner;', - 'void createComposedAppLogRuntimeGateway;', - 'void createAppLogRuntimeHost;', + 'void createComposedPlatformRuntimeGateway;', + 'void createPlatformRuntimeHost;', 'void createComposedDeviceInventoryGateways;', 'export type Scope = PlatformRequestScope;', ].join('\n'), diff --git a/src/__tests__/provider-device-runtimes.test.ts b/src/__tests__/provider-device-runtimes.test.ts index a16c6c4aae..d9b5470ffb 100644 --- a/src/__tests__/provider-device-runtimes.test.ts +++ b/src/__tests__/provider-device-runtimes.test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import { createDefaultProviderRuntimeComposition } from '../provider-device-runtimes.ts'; test('default provider runtimes skip Limrun when only the removed API key alias is configured', async () => { - const { runtimes, appLogModules } = await createDefaultProviderRuntimeComposition({ + const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ LIM_API_KEY: 'lim_test_key', }); @@ -11,12 +11,12 @@ test('default provider runtimes skip Limrun when only the removed API key alias runtimes.some((runtime) => runtime.provider === 'limrun'), false, ); - assert.equal(appLogModules.length, 0); + assert.equal(platformModules.length, 0); await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); }); test('default provider runtimes load Limrun when a Limrun API key is configured', async () => { - const { runtimes, appLogModules } = await createDefaultProviderRuntimeComposition({ + const { runtimes, platformModules } = await createDefaultProviderRuntimeComposition({ LIMRUN_API_KEY: 'lim_test_key', }); @@ -26,7 +26,7 @@ test('default provider runtimes load Limrun when a Limrun API key is configured' ); const limrun = runtimes.find((runtime) => runtime.provider === 'limrun'); assert.equal(limrun ? 'loadRuntime' in limrun : true, false); - assert.equal(appLogModules.length, 1); - assert.equal(appLogModules[0]?.runtime, limrun); + assert.equal(platformModules.length, 1); + assert.equal(platformModules[0]?.runtime, limrun); await Promise.all(runtimes.map(async (runtime) => await runtime.shutdown())); }); diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 05c753f113..b41c176862 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -292,7 +292,6 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only 'is', 'longpress', 'logs', - 'network', 'open', 'perf', 'press', @@ -372,6 +371,8 @@ test('tvOS follows iOS capability matrix by device kind', () => { }); test('Linux supports desktop interaction commands and blocks mobile/unsupported ones', () => { + // Runtime-backed network admission is proven from operation facts in + // session-capabilities.test.ts, not through this legacy matrix projection. assertCommandSupport( [ 'back', @@ -406,7 +407,6 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'install', 'install-from-source', 'keyboard', - 'network', 'perf', 'push', 'record', @@ -431,7 +431,6 @@ test('web supports only the initial browser interaction slice', () => { 'find', 'get', 'is', - 'network', 'open', 'press', 'record', diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 7574adee94..3b4f5d2167 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -340,7 +340,9 @@ test('(b.2) unsupportedHint closures are verbatim across the full device matrix' test('the capability catalog includes runtime-backed commands without restoring legacy admission', () => { assert.ok(listCapabilityCommands().includes('logs')); + assert.ok(listCapabilityCommands().includes('network')); assert.equal(BASE_COMMAND_CAPABILITY_MATRIX['logs'], undefined); + assert.equal(BASE_COMMAND_CAPABILITY_MATRIX['network'], undefined); }); test('(b.2) the Apple plugin carries exactly the relocated supports/hint closures', () => { diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index dc2e566944..9234847d67 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -73,7 +73,6 @@ const WEB_QUERY_COMMANDS = [ 'find', 'get', 'is', - 'network', 'screenshot', 'snapshot', 'wait', diff --git a/src/core/command-descriptor/__tests__/network-runtime-execution.test.ts b/src/core/command-descriptor/__tests__/network-runtime-execution.test.ts new file mode 100644 index 0000000000..41133f58da --- /dev/null +++ b/src/core/command-descriptor/__tests__/network-runtime-execution.test.ts @@ -0,0 +1,18 @@ +import { networkDumpUse, resolveNetworkRuntimePlan } from '@agent-device/contracts/platform'; +import { expect, test } from 'vitest'; +import { commandDescriptors } from '../registry.ts'; + +test('network descriptor declares the exact use selected by all eight projection cells', () => { + const network = commandDescriptors.find(({ name }) => name === 'network'); + + expect(network?.platformExecution).toEqual({ + kind: 'device-runtime', + use: networkDumpUse, + }); + const selectedUses = ['dump', 'log'].flatMap((action) => + ['summary', 'headers', 'body', 'all'].map( + (include) => resolveNetworkRuntimePlan({ action, include }).use, + ), + ); + expect(new Set(selectedUses)).toEqual(new Set([networkDumpUse])); +}); diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index e560bf749c..6066f6dd31 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -42,8 +42,8 @@ const DAEMON_FUNCTION_TRAITS = [ const UNROUTED_PUBLIC_COMMANDS = new Set([PUBLIC_COMMANDS.installFromSource]); // Public commands that intentionally carry no legacy capability entry. Most are -// pure control-plane or always-admitted commands; logs is admitted from exact -// runtime facts and therefore belongs to the capability catalog without a matrix row. +// pure control-plane or always-admitted commands; logs and network are admitted from +// exact runtime facts and therefore belong to the capability catalog without matrix rows. const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.appState, PUBLIC_COMMANDS.artifacts, @@ -53,6 +53,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.doctor, PUBLIC_COMMANDS.events, PUBLIC_COMMANDS.logs, + PUBLIC_COMMANDS.network, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.test, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 43a11d93b2..84eb1f0e96 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -16,6 +16,7 @@ import { appLogRuntimePlanUses, assertCommandPlatformExecution, inventoryUse, + networkDumpUse, } from '@agent-device/contracts/platform'; import type { CommandCatalogGroup, @@ -543,7 +544,7 @@ export const RAW_COMMAND_DESCRIPTORS = [ catalog: { group: 'public' }, recordsSessionAction: false, daemon: { route: 'session', refFrameEffect: 'preserve', sessionKind: 'observability' }, - capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, + platformExecution: { kind: 'device-runtime', use: networkDumpUse }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, diff --git a/src/daemon/__tests__/app-log-resource-recovery.test.ts b/src/daemon/__tests__/app-log-resource-recovery.test.ts index 0604bd13d5..06b71754bd 100644 --- a/src/daemon/__tests__/app-log-resource-recovery.test.ts +++ b/src/daemon/__tests__/app-log-resource-recovery.test.ts @@ -3,9 +3,9 @@ import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { localRuntimeOwner, - type AppLogRuntimeOperations, type CleanupOutcome, type DeviceRuntimeGateway, + type PlatformRuntimeOperations, type ReattachOutcome, } from '@agent-device/contracts/platform'; import { createDurableResourceEnvelope } from '@agent-device/capture-kit'; @@ -305,7 +305,7 @@ function makeGateway( const handle = createHandle(forceCleanup); const bindingDispose = vi.fn(async () => {}); const cleanup = vi.fn(async () => ({ status: 'cleaned' as const })); - const operations: AppLogRuntimeOperations = { + const operations: PlatformRuntimeOperations = { appLogInspect: async () => ({ backend: 'android' }), appLogDoctor: async () => ({ backend: 'android', checks: {}, notes: [] }), appLogStart: async () => { @@ -315,6 +315,24 @@ function makeGateway( options.reattachImplementation ?? (async () => options.reattach ?? { status: 'active', handle }), appLogCleanup: cleanup, + networkDump: async (input) => ({ + source: 'app-log', + backend: 'android', + dump: { + path: '/tmp/app.log', + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: [], + include: input.include, + limits: { + maxEntries: input.maxEntries, + maxPayloadChars: input.maxPayloadChars, + maxScanLines: input.maxScanLines, + }, + }, + notes: [], + }), }; const boundSignals: AbortSignal[] = []; const bind = vi.fn(async ({ device, scope: bindingScope }) => { @@ -330,13 +348,14 @@ function makeGateway( appLogStart: { available: true as const }, appLogReattach: { available: true as const }, appLogCleanup: { available: true as const }, + networkDump: { available: true as const }, }, }, operations, [Symbol.asyncDispose]: bindingDispose, }; }); - const gateway: DeviceRuntimeGateway = { + const gateway: DeviceRuntimeGateway = { bind, shutdown: async () => {}, }; diff --git a/src/daemon/__tests__/network-log.test.ts b/src/daemon/__tests__/network-log.test.ts deleted file mode 100644 index 1ded7cbfdc..0000000000 --- a/src/daemon/__tests__/network-log.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { readRecentNetworkTraffic } from '../network-log.ts'; -import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; - -test('readRecentNetworkTraffic parses latest HTTP entries from session log', () => { - const tempDir = mkdtempForTestSync('agent-device-network-log-'); - const logPath = path.join(tempDir, 'app.log'); - fs.writeFileSync( - logPath, - [ - '2026-02-24T10:00:00Z GET https://api.example.com/v1/profile status=200', - '2026-02-24T10:00:02Z {"method":"POST","url":"https://api.example.com/v1/login","statusCode":401,"headers":{"x-id":"abc"},"requestBody":{"email":"u@example.com"},"responseBody":{"error":"denied"}}', - 'non-network-line', - ].join('\n'), - 'utf8', - ); - - const dump = readRecentNetworkTraffic(logPath, { - backend: 'ios-simulator', - maxEntries: 5, - include: 'all', - maxPayloadChars: 2048, - maxScanLines: 100, - }); - - assert.equal(dump.exists, true); - assert.equal(dump.entries.length, 2); - assert.equal(dump.entries[0]?.method, 'POST'); - assert.equal(dump.entries[0]?.url, 'https://api.example.com/v1/login'); - assert.equal(dump.entries[0]?.status, 401); - assert.equal(dump.entries[0]?.timestamp, '2026-02-24T10:00:02Z'); - assert.equal(typeof dump.entries[0]?.headers, 'string'); - assert.equal(typeof dump.entries[0]?.requestBody, 'string'); - assert.equal(typeof dump.entries[0]?.responseBody, 'string'); - assert.equal(dump.entries[1]?.method, 'GET'); - assert.equal(dump.entries[1]?.status, 200); - assert.equal(dump.entries[1]?.timestamp, '2026-02-24T10:00:00Z'); -}); - -test('readRecentNetworkTraffic enriches Android GIBSDK URL lines with timing metadata', () => { - const tempDir = mkdtempForTestSync('agent-device-network-log-'); - const logPath = path.join(tempDir, 'app.log'); - fs.writeFileSync( - logPath, - [ - '03-31 17:43:32.564 V/GIBSDK (17434): [NetworkAgent]: packet id 23911610 added, queue size: 1', - '03-31 17:43:33.031 D/GIBSDK (17434): [NetworkAgent] packet id 23911610 total elapsed request/response time, ms: 377; response code: 200;', - '03-31 17:43:33.031 D/GIBSDK (17434): URL: https://api.example.com/v1/fixture?as=2.0.2816300925', - '03-31 17:43:33.032 V/GIBSDK (17434): [NetworkAgent]: packet id 23911610 sent successfully, 0 left in queue', - ].join('\n'), - 'utf8', - ); - - const dump = readRecentNetworkTraffic(logPath, { - backend: 'android', - maxEntries: 5, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 100, - }); - - assert.equal(dump.exists, true); - assert.equal(dump.entries.length, 1); - assert.equal(dump.entries[0]?.url, 'https://api.example.com/v1/fixture?as=2.0.2816300925'); - assert.equal(dump.entries[0]?.timestamp, '03-31 17:43:33.031'); - assert.equal(dump.entries[0]?.status, 200); - assert.equal(dump.entries[0]?.durationMs, 377); - assert.equal(dump.entries[0]?.packetId, '23911610'); -}); - -test('readRecentNetworkTraffic tolerates interleaved Android lines within the packet scan window', () => { - const tempDir = mkdtempForTestSync('agent-device-network-log-'); - const logPath = path.join(tempDir, 'app.log'); - fs.writeFileSync( - logPath, - [ - '03-31 17:43:32.564 V/GIBSDK (17434): [NetworkAgent]: packet id 23911610 added, queue size: 1', - '03-31 17:43:32.700 V/OtherTag (17434): unrelated line 1', - '03-31 17:43:32.800 V/OtherTag (17434): unrelated line 2', - '03-31 17:43:32.900 V/OtherTag (17434): unrelated line 3', - '03-31 17:43:33.000 V/OtherTag (17434): unrelated line 4', - '03-31 17:43:33.031 D/GIBSDK (17434): [NetworkAgent] packet id 23911610 total elapsed request/response time, ms: 377; response code: 200;', - '03-31 17:43:33.032 D/GIBSDK (17434): URL: https://api.example.com/v1/fixture?as=2.0.2816300925', - ].join('\n'), - 'utf8', - ); - - const dump = readRecentNetworkTraffic(logPath, { - backend: 'android', - maxEntries: 5, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 100, - }); - - assert.equal(dump.entries.length, 1); - assert.equal(dump.entries[0]?.status, 200); - assert.equal(dump.entries[0]?.durationMs, 377); - assert.equal(dump.entries[0]?.packetId, '23911610'); -}); - -test('readRecentNetworkTraffic keeps Android packet enrichment disabled for Apple backends', () => { - const tempDir = mkdtempForTestSync('agent-device-network-log-'); - const logPath = path.join(tempDir, 'app.log'); - fs.writeFileSync( - logPath, - [ - '2026-03-31 17:43:33.031 response code: 200', - '2026-03-31 17:43:33.032 URL: https://api.example.com/v1/fixture?as=2.0.2816300925', - ].join('\n'), - 'utf8', - ); - - const dump = readRecentNetworkTraffic(logPath, { - backend: 'macos', - maxEntries: 5, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 100, - }); - - assert.equal(dump.entries.length, 1); - assert.equal(dump.entries[0]?.url, 'https://api.example.com/v1/fixture?as=2.0.2816300925'); - assert.equal(dump.entries[0]?.timestamp, '2026-03-31 17:43:33.032'); - assert.equal(dump.entries[0]?.status, undefined); - assert.equal(dump.entries[0]?.durationMs, undefined); -}); - -test('readRecentNetworkTraffic ignores plain documentation URLs in non-network log messages', () => { - const tempDir = mkdtempForTestSync('agent-device-network-log-'); - const logPath = path.join(tempDir, 'app.log'); - fs.writeFileSync( - logPath, - '2026-04-02 08:14:44.371 E Agent Device Tester[32193:8c7e18d] Airship config warning. See https://docs.airship.com/platform/mobile/setup/sdk/ios/#url-allow-list for more information.\n', - 'utf8', - ); - - const dump = readRecentNetworkTraffic(logPath, { - backend: 'ios-simulator', - maxEntries: 5, - include: 'summary', - maxPayloadChars: 2048, - maxScanLines: 100, - }); - - assert.equal(dump.entries.length, 0); -}); - -test('readRecentNetworkTraffic returns empty result when log file is missing', () => { - const logPath = path.join(os.tmpdir(), 'agent-device-network-log-missing', 'app.log'); - const dump = readRecentNetworkTraffic(logPath, { - backend: 'android', - maxEntries: 10, - include: 'summary', - }); - assert.equal(dump.exists, false); - assert.equal(dump.entries.length, 0); -}); diff --git a/src/daemon/__tests__/request-runtime-binding-router.test.ts b/src/daemon/__tests__/request-runtime-binding-router.test.ts index f59300d92b..fe678d9f4d 100644 --- a/src/daemon/__tests__/request-runtime-binding-router.test.ts +++ b/src/daemon/__tests__/request-runtime-binding-router.test.ts @@ -2,8 +2,8 @@ import fs from 'node:fs'; import { expect, test, vi } from 'vitest'; import { localRuntimeOwner, - type AppLogRuntimeOperations, type DeviceRuntimeGateway, + type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { createTestAppLogLiveHandle } from '../../__tests__/test-utils/app-log-live-handle.ts'; @@ -45,7 +45,7 @@ test('primary request failure survives a rejecting binding disposal', async () = expect(phases).toContain('request_binding_cleanup_failed'); }); -function makeHandler(gateway: DeviceRuntimeGateway) { +function makeHandler(gateway: DeviceRuntimeGateway) { const sessionStore = makeSessionStore('request-runtime-binding-router-'); sessionStore.set('session', { name: 'session', @@ -90,7 +90,7 @@ function makeGateway(disposeError?: Error) { }), forceCleanup, }); - const operations: AppLogRuntimeOperations = { + const operations: PlatformRuntimeOperations = { appLogInspect: async () => ({ backend: 'android' }), appLogDoctor: async () => ({ backend: 'android', checks: {}, notes: [] }), appLogStart: async (input) => @@ -108,6 +108,24 @@ function makeGateway(disposeError?: Error) { ), appLogReattach: async () => ({ status: 'missing' }), appLogCleanup: async () => ({ status: 'cleaned' }), + networkDump: async (input) => ({ + source: 'app-log', + backend: 'android', + dump: { + path: '/tmp/app.log', + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: [], + include: input.include, + limits: { + maxEntries: input.maxEntries, + maxPayloadChars: input.maxPayloadChars, + maxScanLines: input.maxScanLines, + }, + }, + notes: [], + }), }; const bindingDispose = vi.fn(async () => { if (disposeError) throw disposeError; @@ -123,12 +141,13 @@ function makeGateway(disposeError?: Error) { appLogStart: { available: true as const }, appLogReattach: { available: true as const }, appLogCleanup: { available: true as const }, + networkDump: { available: true as const }, }, }, operations, [Symbol.asyncDispose]: bindingDispose, })); - const gateway: DeviceRuntimeGateway = { + const gateway: DeviceRuntimeGateway = { bind, shutdown: async () => {}, }; diff --git a/src/daemon/__tests__/request-runtime-binding.test.ts b/src/daemon/__tests__/request-runtime-binding.test.ts index fa46e7226e..6e155c1025 100644 --- a/src/daemon/__tests__/request-runtime-binding.test.ts +++ b/src/daemon/__tests__/request-runtime-binding.test.ts @@ -2,10 +2,11 @@ import { expect, test, vi } from 'vitest'; import { appLogAdmissionUse, localRuntimeOwner, + networkDumpUse, resolveLogsRuntimePlan, - type AppLogRuntimeOperations, type DeviceBinding, type DeviceRuntimeGateway, + type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createRequestRuntimeBindings } from '../request-runtime-binding.ts'; @@ -31,11 +32,13 @@ test('request runtime binding caches one broad owner and projects each declared const admission = await bindings.bindDevice(device('one'), appLogAdmissionUse); const inspect = await bindings.bindDevice(device('one'), appLogInspectUse); const doctor = await bindings.bindDevice(device('one'), appLogDoctorUse); + const network = await bindings.bindDevice(device('one'), networkDumpUse); expect(runtime.bind).toHaveBeenCalledOnce(); expect(admission.operations.appLogInspect).toBe(runtime.operations.appLogInspect); expect(Object.keys(inspect.operations)).toEqual(['appLogInspect']); expect(Object.keys(doctor.operations)).toEqual(['appLogInspect', 'appLogDoctor']); + expect(Object.keys(network.operations)).toEqual(['networkDump']); expect(Symbol.asyncDispose in doctor).toBe(false); await bindings[Symbol.asyncDispose](); expect(runtime.disposals).toEqual(['one']); @@ -58,6 +61,7 @@ test('concurrent uses share one in-flight broad binding for the device', async ( await Promise.all([ bindings.bindDevice(selected, appLogInspectUse), bindings.bindDevice(selected, appLogDoctorUse), + bindings.bindDevice(selected, networkDumpUse), ]); expect(runtime.bind).toHaveBeenCalledOnce(); @@ -79,7 +83,7 @@ test('preferred absence is visible without failing while required absence fails function makeGateway(options: { inspectAvailable?: boolean } = {}) { const disposals: string[] = []; - const operations: AppLogRuntimeOperations = { + const operations: PlatformRuntimeOperations = { appLogInspect: vi.fn(async () => ({ backend: 'android' as const })), appLogDoctor: vi.fn(async () => ({ backend: 'android' as const, checks: {}, notes: [] })), appLogStart: vi.fn(async () => { @@ -87,9 +91,23 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) { }), appLogReattach: vi.fn(async () => ({ status: 'missing' as const })), appLogCleanup: vi.fn(async () => ({ status: 'already-missing' as const })), + networkDump: vi.fn(async () => ({ + source: 'app-log' as const, + backend: 'android' as const, + dump: { + path: '/tmp/app.log', + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: [], + include: 'summary' as const, + limits: { maxEntries: 25, maxPayloadChars: 2048, maxScanLines: 4000 }, + }, + notes: [], + })), }; const bind = vi.fn( - async ({ device: selected }): Promise> => ({ + async ({ device: selected }): Promise> => ({ device: selected, owner: localRuntimeOwner('android'), facts: { @@ -103,6 +121,7 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) { appLogStart: { available: true }, appLogReattach: { available: true }, appLogCleanup: { available: true }, + networkDump: { available: true }, }, }, operations: @@ -112,6 +131,7 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) { appLogStart: operations.appLogStart, appLogReattach: operations.appLogReattach, appLogCleanup: operations.appLogCleanup, + networkDump: operations.networkDump, } : operations, [Symbol.asyncDispose]: async () => { @@ -119,7 +139,7 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) { }, }), ); - const gateway: DeviceRuntimeGateway = { + const gateway: DeviceRuntimeGateway = { bind, shutdown: async () => {}, }; diff --git a/src/daemon/__tests__/test-device-runtime-gateway.ts b/src/daemon/__tests__/test-device-runtime-gateway.ts index 674ab1c4f0..6d6cc15b48 100644 --- a/src/daemon/__tests__/test-device-runtime-gateway.ts +++ b/src/daemon/__tests__/test-device-runtime-gateway.ts @@ -1,8 +1,8 @@ import { localRuntimeOwner, narrowDeviceBinding, - type AppLogRuntimeOperations, type DeviceRuntimeGateway, + type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import { createRequestHandler as createProductionRequestHandler, @@ -10,7 +10,7 @@ import { } from '../request-router.ts'; import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; -export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway = +export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway = Object.freeze({ bind: async ({ device }) => ({ device, @@ -32,6 +32,7 @@ export const unavailableDeviceRuntimeGateway: DeviceRuntimeGateway { - const backend = resolveLegacyNetworkLogBackend(params.device); - let dump = readRecentNetworkTraffic(params.appLogPath, { - backend, - maxEntries: params.maxEntries, - include: params.include, - maxPayloadChars: params.maxPayloadChars, - maxScanLines: params.maxScanLines, - }); - const notes: string[] = []; - - const android = await recoverAndroidNetworkCapture(params, dump); - dump = android.dump; - appendNetworkNote(notes, android.note); - const iosSimulator = await recoverIosSimulatorNetworkCapture(params, dump); - dump = iosSimulator.dump; - appendNetworkNote(notes, iosSimulator.note); - appendNetworkNote(notes, buildNetworkLifecycleNote(params, notes.length)); - appendNetworkNote( - notes, - dump.entries.length === 0 ? buildNoHttpEntriesNote(params.device) : null, - ); - return { backend, dump, notes }; -} - -async function recoverAndroidNetworkCapture( - params: SessionNetworkCaptureParams, - dump: NetworkDump, -): Promise { - const context = await resolveAndroidNetworkRecoveryContext(params); - if (!context || !params.appBundleId) return { dump }; - const recovered = await readRecentAndroidLogcatForPackage(params.device.id, params.appBundleId); - if (!recovered) return { dump }; - const recoveredDump = readRecentNetworkTrafficFromText(recovered.text, { - path: `${params.appLogPath} (adb logcat recovery)`, - backend: 'android', - maxEntries: params.maxEntries, - include: params.include, - maxPayloadChars: params.maxPayloadChars, - maxScanLines: params.maxScanLines, - }); - if (recoveredDump.entries.length === 0) return { dump }; - return { - dump: mergeNetworkDumps(recoveredDump, dump, params.maxEntries), - note: buildAndroidRecoveryNote(context, recovered.recoveredPids), - }; -} - -async function recoverIosSimulatorNetworkCapture( - params: SessionNetworkCaptureParams, - dump: NetworkDump, -): Promise { - if (!canRecoverIosSimulatorCapture(params) || dump.entries.length > 0) return { dump }; - const recovered = await readRecentIosSimulatorNetworkCapture({ - deviceId: params.device.id, - appBundleId: params.appBundleId as string, - startedAt: params.appLogStartedAt, - simulatorSetPath: params.device.simulatorSetPath, - appLogPath: params.appLogPath, - maxEntries: params.maxEntries, - include: params.include, - maxPayloadChars: params.maxPayloadChars, - maxScanLines: params.maxScanLines, - }); - if (!recovered) return { dump }; - if (recovered.dump.entries.length === 0) { - return { dump, note: buildEmptyIosSimulatorRecoveryNote(recovered.recoveredLineCount) }; - } - return { - dump: mergeNetworkDumps(recovered.dump, dump, params.maxEntries), - note: buildIosSimulatorRecoveryNote(recovered), - }; -} - -function canRecoverIosSimulatorCapture(params: SessionNetworkCaptureParams): boolean { - return ( - isIosFamily(params.device) && params.device.kind === 'simulator' && Boolean(params.appBundleId) - ); -} - -function buildIosSimulatorRecoveryNote(recovered: IosSimulatorNetworkRecovery): string { - const entryCount = recovered.dump.entries.length; - return `Recovered ${entryCount} iOS simulator HTTP entr${ - entryCount === 1 ? 'y' : 'ies' - } from simctl log show (${recovered.recoveredLineCount} app log lines scanned).`; -} - -function buildEmptyIosSimulatorRecoveryNote(recoveredLineCount: number): string | undefined { - if (recoveredLineCount === 0) return undefined; - return `Recovered ${recoveredLineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`; -} - -function buildNetworkLifecycleNote( - params: SessionNetworkCaptureParams, - recoveryNoteCount: number, -): string | undefined { - if (params.appLogState === undefined) { - return 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.'; - } - if (params.appLogState === 'active' || recoveryNoteCount > 0) return undefined; - if (isIosFamily(params.device) && params.device.kind === 'simulator') { - return 'Session app log stream is inactive. The iOS simulator recovery path scanned recent simctl log history, but a fresh logs clear --restart window is still the most reliable repro loop.'; - } - return 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.'; -} - -function appendNetworkNote(notes: string[], note: string | null | undefined): void { - if (note) notes.push(note); -} - -async function resolveAndroidNetworkRecoveryContext(params: { - device: DeviceInfo; - appBundleId?: string; - appLogPath: string; - appLogState?: NetworkAppLogState; -}): Promise { - const { device, appBundleId, appLogPath, appLogState } = params; - if (device.platform !== 'android' || !appBundleId) return null; - if (appLogState !== undefined && appLogState !== 'active') return { reason: 'inactive' }; - if (appLogState !== 'active') return null; - - const trackedPid = readTrackedAndroidLogcatPid( - path.join(path.dirname(appLogPath), APP_LOG_PID_FILENAME), - ); - if (!trackedPid) return null; - const currentPid = await resolveAndroidPid(device.id, appBundleId); - if (!currentPid || currentPid === trackedPid) return null; - return { reason: 'stale-active', trackedPid }; -} - -function buildAndroidRecoveryNote( - context: AndroidNetworkRecoveryContext, - recoveredPids: string[], -): string { - if (context.reason === 'stale-active') { - return `Session app log stream was still bound to prior Android PID ${context.trackedPid}. Recovered recent Android HTTP entries from adb logcat for PID set ${recoveredPids.join(', ')}.`; - } - return `Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set ${recoveredPids.join(', ')}.`; -} - -async function readRecentIosSimulatorNetworkCapture(params: { - deviceId: string; - appBundleId: string; - startedAt?: number; - simulatorSetPath?: string; - appLogPath: string; - maxEntries: number; - include: NetworkIncludeMode; - maxPayloadChars: number; - maxScanLines: number; -}): Promise { - const recovered = await readRecentIosSimulatorLogShowForBundle({ - deviceId: params.deviceId, - appBundleId: params.appBundleId, - startedAt: params.startedAt, - simulatorSetPath: params.simulatorSetPath, - }); - if (!recovered) return null; - return { - dump: readRecentNetworkTrafficFromText(recovered.text, { - path: `${params.appLogPath} (simctl log show recovery)`, - backend: 'ios-simulator', - maxEntries: params.maxEntries, - include: params.include, - maxPayloadChars: params.maxPayloadChars, - maxScanLines: params.maxScanLines, - }), - recoveredLineCount: recovered.recoveredLineCount, - }; -} - -function buildNoHttpEntriesNote(device: DeviceInfo): string { - if (isIosFamily(device) && device.kind === 'simulator') { - return 'No HTTP(s) entries were found in recent iOS simulator app logs. If the app only emits non-HTTP diagnostics, inspect logs path or add app-side URLSession/network logging for per-request timing and payload details.'; - } - if (isIosFamily(device)) { - return 'No HTTP(s) entries were found in recent iOS device app logs. iOS network dump only sees what the app emits into Unified Logging for this process.'; - } - return 'No HTTP(s) entries were found in recent session app logs.'; -} diff --git a/src/daemon/app-log-resource-recovery.ts b/src/daemon/app-log-resource-recovery.ts index f978863271..299e644de6 100644 --- a/src/daemon/app-log-resource-recovery.ts +++ b/src/daemon/app-log-resource-recovery.ts @@ -3,13 +3,13 @@ import { isConfirmedCleanup, narrowDeviceBinding, runtimeUse, - type AppLogRuntimeOperations, type AppLogCompletion, type AppLogLiveHandle, type CleanupOutcome, type DeviceBinding, type DeviceRuntimeGateway, type DurableResourceEnvelope, + type PlatformRuntimeOperations, type PlatformRequestScope, type ReattachOutcome, } from '@agent-device/contracts/platform'; @@ -23,7 +23,7 @@ import { } from './app-log-resource-store.ts'; import { safeSessionName } from './session-paths.ts'; -const appLogRecoveryUse = runtimeUse()({ +const appLogRecoveryUse = runtimeUse()({ required: ['appLogReattach', 'appLogCleanup'], }); @@ -43,7 +43,7 @@ export type AppLogRecoveryDiagnostic = Readonly<{ type AppLogRecoveryParams = { sessionsDir: string; - gateway: DeviceRuntimeGateway; + gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; perRecordDeadlineMs?: number; onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; @@ -158,7 +158,7 @@ class AppLogRecoveryDeadlineError extends Error { async function recoverOneBeforeDeadline(params: { resourcePath: string; envelope: DurableResourceEnvelope<'app-log'>; - gateway: DeviceRuntimeGateway; + gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; deadlineMs: number; onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; @@ -196,18 +196,18 @@ async function recoverOneBeforeDeadline(params: { } type AppLogRecoveryAuthority = Readonly<{ - binding: DeviceBinding; + binding: DeviceBinding; reattached: ReattachOutcome; }>; async function acquireRecoveryAuthority(params: { resourcePath: string; envelope: DurableResourceEnvelope<'app-log'>; - gateway: DeviceRuntimeGateway; + gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; }): Promise { - let binding: DeviceBinding | undefined; + let binding: DeviceBinding | undefined; let reattached: ReattachOutcome | undefined; try { binding = await params.gateway.bind({ @@ -233,7 +233,7 @@ async function acquireRecoveryAuthority(params: { async function disposeAbortedRecoveryAuthority( params: Pick[0], 'resourcePath' | 'onDiagnostic'>, primaryError: unknown, - binding: DeviceBinding | undefined, + binding: DeviceBinding | undefined, reattached: ReattachOutcome | undefined, ): Promise { if (reattached?.status === 'active') { @@ -278,7 +278,7 @@ async function disposeRecoveryValue( async function settleRecoveryAuthority(params: { resourcePath: string; envelope: DurableResourceEnvelope<'app-log'>; - binding: DeviceBinding; + binding: DeviceBinding; reattached: ReattachOutcome; onDiagnostic?: (diagnostic: AppLogRecoveryDiagnostic) => void; }): Promise { diff --git a/src/daemon/handlers/__tests__/network-runtime-harness.ts b/src/daemon/handlers/__tests__/network-runtime-harness.ts new file mode 100644 index 0000000000..921014369d --- /dev/null +++ b/src/daemon/handlers/__tests__/network-runtime-harness.ts @@ -0,0 +1,83 @@ +import { vi } from 'vitest'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type NetworkDumpInput, + type NetworkDumpResult, + type PlatformRuntimeOperations, + type RuntimeOperationFact, + type RuntimeOwnerRef, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; + +export function createNetworkRuntime( + device: DeviceInfo, + implementation: (input: NetworkDumpInput) => Promise, + networkFact: RuntimeOperationFact = { available: true }, + owner: RuntimeOwnerRef = localRuntimeOwner(device.platform), +) { + const uses: Array<{ required: readonly string[]; preferred: readonly string[] }> = []; + const networkDump = vi.fn(implementation); + const unavailable = { + available: false, + reason: 'owner-capability-missing', + } as const; + const bindDevice: BindDeviceRuntime = async (selected, use) => { + uses.push({ required: [...use.required], preferred: [...use.preferred] }); + return narrowDeviceBinding( + { + device: selected, + owner, + facts: { + device: { + family: selected.platform, + ...(selected.appleOs === undefined ? {} : { appleOs: selected.appleOs }), + kind: selected.kind, + ...(selected.target === undefined ? {} : { target: selected.target }), + ...(selected.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: selected.iosPhysicalDeviceBackend }), + providerMode: owner.kind === 'provider-runtime' ? 'provider-runtime' : 'local', + }, + operations: { + appLogInspect: unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + networkDump: networkFact, + }, + }, + operations: networkFact.available ? { networkDump } : {}, + [Symbol.asyncDispose]: async () => {}, + } satisfies import('@agent-device/contracts/platform').DeviceBinding, + use, + ); + }; + return { bindDevice, networkDump, uses }; +} + +export function emptyAppLogResult( + backend: 'android' | 'harmonyos', + input?: NetworkDumpInput, +): Extract { + return { + source: 'app-log', + backend, + dump: { + path: '/sessions/app.log', + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: [], + include: input?.include ?? 'summary', + limits: { + maxEntries: input?.maxEntries ?? 25, + maxPayloadChars: input?.maxPayloadChars ?? 2048, + maxScanLines: input?.maxScanLines ?? 4000, + }, + }, + notes: [], + }; +} diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts index a54c84fe3a..fdf8403340 100644 --- a/src/daemon/handlers/__tests__/session-capabilities.test.ts +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -9,8 +9,8 @@ import { localRuntimeOwner, narrowDeviceBinding, providerRuntimeOwner, - type AppLogRuntimeOperations, type DeviceBinding, + type PlatformRuntimeOperations, type RuntimeProviderMode, } from '@agent-device/contracts/platform'; import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; @@ -25,7 +25,11 @@ test('capabilities reports supported commands for the selected session device', const sessionName = 'android-capabilities'; const sessionStore = makeSessionStore('agent-device-capabilities-'); sessionStore.set(sessionName, makeAndroidSession(sessionName)); - const runtime = createAdmissionRuntime({ available: true, providerMode: 'local' }); + const runtime = createAdmissionRuntime({ + appLogAvailable: true, + networkAvailable: true, + providerMode: 'local', + }); const response = await handleSessionCommands({ req: { @@ -66,7 +70,10 @@ test('capabilities reports supported commands for the selected session device', expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.capabilities); expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.devices); assertAndroidCapabilityHonesty(response.data?.availableCommands); - expect(runtime.uses).toEqual([{ required: [], preferred: ['appLogInspect'] }]); + expect(runtime.uses).toEqual([ + { required: [], preferred: ['appLogInspect'] }, + { required: [], preferred: ['networkDump'] }, + ]); }); test('capabilities excludes logs from an unavailable provider-mode XCTest runtime fact', async () => { @@ -86,7 +93,8 @@ test('capabilities excludes logs from an unavailable provider-mode XCTest runtim actions: [], }); const runtime = createAdmissionRuntime({ - available: false, + appLogAvailable: false, + networkAvailable: true, providerMode: 'provider-runtime', }); @@ -108,7 +116,42 @@ test('capabilities excludes logs from an unavailable provider-mode XCTest runtim expect(response?.ok).toBe(true); if (!response?.ok) return; expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.logs); - expect(runtime.uses).toEqual([{ required: [], preferred: ['appLogInspect'] }]); + expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.network); + expect(runtime.uses).toEqual([ + { required: [], preferred: ['appLogInspect'] }, + { required: [], preferred: ['networkDump'] }, + ]); +}); + +test('capabilities excludes network when the runtime fact is unavailable', async () => { + const sessionName = 'android-capabilities-no-network'; + const sessionStore = makeSessionStore('agent-device-capabilities-no-network-'); + sessionStore.set(sessionName, makeAndroidSession(sessionName)); + const runtime = createAdmissionRuntime({ + appLogAvailable: true, + networkAvailable: false, + providerMode: 'local', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: PUBLIC_COMMANDS.capabilities, + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + bindDevice: runtime.bindDevice, + invoke: async () => ({ ok: true, data: {} }), + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) return; + expect(response.data?.availableCommands).toContain(PUBLIC_COMMANDS.logs); + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.network); }); test('capabilities accepts a stopped Android AVD placeholder for explicit platform discovery', async () => { @@ -154,48 +197,92 @@ test('capabilities accepts a stopped Android AVD placeholder for explicit platfo }); function createAdmissionRuntime(options: { - available: boolean; + appLogAvailable: boolean; + networkAvailable: boolean; providerMode: RuntimeProviderMode; }) { const uses: Array<{ required: readonly string[]; preferred: readonly string[] }> = []; const bindDevice: BindDeviceRuntime = async (device, use) => { uses.push({ required: [...use.required], preferred: [...use.preferred] }); - const unavailable = { - available: false as const, - reason: - options.providerMode === 'provider-runtime' - ? ('unsupported-provider-mode' as const) - : ('owner-capability-missing' as const), - }; - const binding: DeviceBinding = { - device, - owner: - options.providerMode === 'provider-runtime' - ? providerRuntimeOwner('test', 'capabilities') - : localRuntimeOwner(device.platform), - facts: { - device: { - family: device.platform, - ...(device.appleOs === undefined ? {} : { appleOs: device.appleOs }), - kind: device.kind, - ...(device.target === undefined ? {} : { target: device.target }), - ...(device.iosPhysicalDeviceBackend === undefined - ? {} - : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), - providerMode: options.providerMode, - }, - operations: { - appLogInspect: options.available ? { available: true } : unavailable, - appLogDoctor: unavailable, - appLogStart: unavailable, - appLogReattach: unavailable, - appLogCleanup: unavailable, - }, - }, - operations: options.available ? { appLogInspect: async () => ({ backend: 'android' }) } : {}, - [Symbol.asyncDispose]: async () => {}, - }; - return narrowDeviceBinding(binding, use); + return narrowDeviceBinding(createAdmissionBinding(device, options), use); }; return { bindDevice, uses }; } + +type AdmissionRuntimeOptions = Readonly<{ + appLogAvailable: boolean; + networkAvailable: boolean; + providerMode: RuntimeProviderMode; +}>; + +function createAdmissionBinding( + device: DeviceInfo, + options: AdmissionRuntimeOptions, +): DeviceBinding { + const unavailable = unavailableOperationFact(options.providerMode); + return { + device, + owner: + options.providerMode === 'provider-runtime' + ? providerRuntimeOwner('test', 'capabilities') + : localRuntimeOwner(device.platform), + facts: { + device: { + family: device.platform, + ...(device.appleOs === undefined ? {} : { appleOs: device.appleOs }), + kind: device.kind, + ...(device.target === undefined ? {} : { target: device.target }), + ...(device.iosPhysicalDeviceBackend === undefined + ? {} + : { iosPhysicalDeviceBackend: device.iosPhysicalDeviceBackend }), + providerMode: options.providerMode, + }, + operations: { + appLogInspect: options.appLogAvailable ? { available: true } : unavailable, + appLogDoctor: unavailable, + appLogStart: unavailable, + appLogReattach: unavailable, + appLogCleanup: unavailable, + networkDump: options.networkAvailable ? { available: true } : unavailable, + }, + }, + operations: { + ...(options.appLogAvailable ? { appLogInspect: inspectAndroidAppLog } : {}), + ...(options.networkAvailable ? { networkDump: dumpEmptyAndroidNetwork } : {}), + }, + [Symbol.asyncDispose]: async () => {}, + }; +} + +function unavailableOperationFact(providerMode: RuntimeProviderMode) { + return { + available: false as const, + reason: + providerMode === 'provider-runtime' + ? ('unsupported-provider-mode' as const) + : ('owner-capability-missing' as const), + }; +} + +const inspectAndroidAppLog: PlatformRuntimeOperations['appLogInspect'] = async () => ({ + backend: 'android', +}); + +const dumpEmptyAndroidNetwork: PlatformRuntimeOperations['networkDump'] = async (input) => ({ + source: 'app-log', + backend: 'android', + dump: { + path: '/tmp/app.log', + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: [], + include: input.include, + limits: { + maxEntries: input.maxEntries, + maxPayloadChars: input.maxPayloadChars, + maxScanLines: input.maxScanLines, + }, + }, + notes: [], +}); diff --git a/src/daemon/handlers/__tests__/session-logs.test.ts b/src/daemon/handlers/__tests__/session-logs.test.ts index 95510f3a8a..8580037d93 100644 --- a/src/daemon/handlers/__tests__/session-logs.test.ts +++ b/src/daemon/handlers/__tests__/session-logs.test.ts @@ -5,6 +5,7 @@ import { type AppLogRuntimeOperations, type CleanupOutcome, type DeviceBinding, + type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { createTestAppLogLiveHandle } from '../../../__tests__/test-utils/app-log-live-handle.ts'; @@ -358,16 +359,34 @@ function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { }); return createAppLogStartResult(handle, envelope); }); - const operations: AppLogRuntimeOperations = { + const operations: PlatformRuntimeOperations = { appLogInspect: inspect, appLogDoctor: doctor, appLogStart: start, appLogReattach: async () => ({ status: 'missing' }), appLogCleanup: async () => ({ status: 'cleaned' }), + networkDump: async (input) => ({ + source: 'app-log', + backend: 'ios-simulator', + dump: { + path: '/tmp/app.log', + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: [], + include: input.include, + limits: { + maxEntries: input.maxEntries, + maxPayloadChars: input.maxPayloadChars, + maxScanLines: input.maxScanLines, + }, + }, + notes: [], + }), }; const uses: Array<{ required: readonly string[]; preferred: readonly string[] }> = []; const bind = vi.fn( - async (device: DeviceInfo): Promise> => ({ + async (device: DeviceInfo): Promise> => ({ device, owner, facts: { @@ -390,6 +409,7 @@ function createRuntimeHarness(options: { inspectAvailable?: boolean } = {}) { appLogStart: { available: true }, appLogReattach: { available: true }, appLogCleanup: { available: true }, + networkDump: { available: true }, }, }, operations, diff --git a/src/daemon/handlers/__tests__/session-network-runtime-parity-fixtures.ts b/src/daemon/handlers/__tests__/session-network-runtime-parity-fixtures.ts new file mode 100644 index 0000000000..ba99d763d6 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-network-runtime-parity-fixtures.ts @@ -0,0 +1,9 @@ +import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; + +export const NETWORK_RUNTIME_PROJECTION_PARITY = Object.freeze( + (['dump', 'log'] as const).flatMap((action) => + (['summary', 'headers', 'body', 'all'] as const satisfies readonly NetworkIncludeMode[]).map( + (include) => Object.freeze({ action, include }), + ), + ), +); diff --git a/src/daemon/handlers/__tests__/session-network-runtime-parity.test.ts b/src/daemon/handlers/__tests__/session-network-runtime-parity.test.ts new file mode 100644 index 0000000000..1b1227b07a --- /dev/null +++ b/src/daemon/handlers/__tests__/session-network-runtime-parity.test.ts @@ -0,0 +1,225 @@ +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { providerRuntimeOwner } from '@agent-device/contracts/platform'; +import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; +import { handleSessionCommands } from '../session.ts'; +import { + makeSession, + makeSessionStore, + makeTestAppLogResource, + noopInvoke, +} from './session-test-harness.ts'; +import { NETWORK_RUNTIME_PROJECTION_PARITY } from './session-network-runtime-parity-fixtures.ts'; +import { createNetworkRuntime, emptyAppLogResult } from './network-runtime-harness.ts'; + +test.each(NETWORK_RUNTIME_PROJECTION_PARITY)( + 'network $action preserves the $include projection through the request runtime', + async ({ action, include }) => { + const sessionName = `network-${action}-${include}`; + const sessionStore = makeSessionStore(); + const session = makeSession(sessionName, { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }); + sessionStore.set(sessionName, { + ...session, + appBundleId: 'com.example.app', + appLog: makeTestAppLogResource(session, { + backend: 'android', + state: 'active', + startedAt: 123, + outputPath: sessionStore.resolveAppLogPath(sessionName), + }), + }); + + const result = { + source: 'app-log' as const, + backend: 'android' as const, + dump: { + path: sessionStore.resolveAppLogPath(sessionName), + exists: true, + scannedLines: 1, + matchedLines: 1, + entries: [ + { + method: 'GET', + url: `https://example.test/${action}/${include}`, + raw: 'GET https://example.test', + line: 1, + }, + ], + include, + limits: { maxEntries: 7, maxPayloadChars: 2048, maxScanLines: 4000 }, + }, + notes: ['runtime-owned capture'], + }; + const runtime = createNetworkRuntime(session.device, async () => result); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: [action, '7', include], + flags: {}, + }, + sessionName, + logPath: path.join(sessionStore.resolveSessionDir(sessionName), 'daemon.log'), + sessionStore, + bindDevice: runtime.bindDevice, + invoke: noopInvoke, + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) return; + expect(response.data?.entries).toEqual([ + expect.objectContaining({ url: `https://example.test/${action}/${include}` }), + ]); + expect(response.data?.include).toBe(include); + expect(response.data?.notes).toContain('runtime-owned capture'); + expect(runtime.uses).toEqual([ + { required: [], preferred: ['networkDump'] }, + { required: ['networkDump'], preferred: [] }, + ]); + expect(runtime.networkDump).toHaveBeenCalledWith({ + sessionId: sessionName, + appBundleId: 'com.example.app', + appLogSnapshot: { state: 'active', startedAt: 123 }, + maxEntries: 7, + include, + maxPayloadChars: 2048, + maxScanLines: 4000, + }); + }, +); + +test('network admission remains fail-closed before parsing invalid input', async () => { + const sessionStore = makeSessionStore(); + const session = makeSession('unsupported', { + platform: 'harmonyos', + id: 'harmony-device', + name: 'Harmony device', + kind: 'device', + }); + sessionStore.set(session.name, session); + const runtime = createNetworkRuntime(session.device, async () => emptyAppLogResult('harmonyos'), { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Network capture is unavailable for this platform leaf.', + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: session.name, + command: 'network', + positionals: ['not-an-action'], + flags: {}, + }, + sessionName: session.name, + logPath: path.join(sessionStore.resolveSessionDir(session.name), 'daemon.log'), + sessionStore, + bindDevice: runtime.bindDevice, + invoke: noopInvoke, + }); + + expect(response).toEqual({ + ok: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'network is not supported on this device', + hint: 'Network capture is unavailable for this platform leaf.', + }, + }); + expect(runtime.uses).toEqual([{ required: [], preferred: ['networkDump'] }]); + expect(runtime.networkDump).not.toHaveBeenCalled(); +}); + +test('an explicit web provider preserves empty-success projection', async () => { + const sessionStore = makeSessionStore(); + const session = makeSession('web-provider', { + platform: 'web', + id: 'browser', + name: 'Browser', + kind: 'device', + target: 'desktop', + }); + sessionStore.set(session.name, session); + const runtime = createNetworkRuntime( + session.device, + async () => ({ source: 'provider', backend: 'agent-browser', entries: [], notes: [] }), + { available: true }, + providerRuntimeOwner('web', 'default'), + ); + + const response = await runNetwork(session.name, sessionStore, runtime.bindDevice); + + expect(response).toMatchObject({ + ok: true, + data: { + active: true, + state: 'active', + backend: 'agent-browser', + entries: [], + include: 'summary', + matchedLines: 0, + scannedLines: 0, + }, + }); +}); + +test.each(['browserstack', 'aws-device-farm', 'limrun'] as const)( + '%s exact provider preserves canonical app-log empty success', + async (provider) => { + const sessionStore = makeSessionStore(); + const session = makeSession(provider, { + platform: 'android', + id: `${provider}-device`, + name: `${provider} device`, + kind: 'device', + }); + sessionStore.set(session.name, session); + const runtime = createNetworkRuntime( + session.device, + async () => emptyAppLogResult('android'), + { available: true }, + providerRuntimeOwner(provider, 'default'), + ); + + const response = await runNetwork(session.name, sessionStore, runtime.bindDevice); + + expect(response).toMatchObject({ + ok: true, + data: { + active: false, + state: 'inactive', + backend: 'android', + entries: [], + }, + }); + }, +); + +async function runNetwork( + sessionName: string, + sessionStore: ReturnType, + bindDevice: BindDeviceRuntime, +) { + return await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'network', + positionals: ['dump'], + flags: {}, + }, + sessionName, + logPath: path.join(sessionStore.resolveSessionDir(sessionName), 'daemon.log'), + sessionStore, + bindDevice, + invoke: noopInvoke, + }); +} diff --git a/src/daemon/handlers/__tests__/session-network.test.ts b/src/daemon/handlers/__tests__/session-network.test.ts index 62f602dd1a..3a31a81fd5 100644 --- a/src/daemon/handlers/__tests__/session-network.test.ts +++ b/src/daemon/handlers/__tests__/session-network.test.ts @@ -1,603 +1,67 @@ -import { test, expect } from 'vitest'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { - mockRunCmd, - makeSessionStore, - makeSession, - makeTestAppLogResource, - noopInvoke, -} from './session-test-harness.ts'; +import path from 'node:path'; +import { expect, test } from 'vitest'; import { handleSessionCommands } from '../session.ts'; +import { createNetworkRuntime, emptyAppLogResult } from './network-runtime-harness.ts'; +import { makeSession, makeSessionStore, noopInvoke } from './session-test-harness.ts'; -function androidAppLog( - sessionName: string, - state: 'active' | 'failed', - outputPath = '/tmp/app.log', -) { - return makeTestAppLogResource( - { - name: sessionName, - device: { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - }, - }, - { backend: 'android', state, outputPath }, - ); -} - -function iosSimulatorAppLog(sessionName: string, outputPath: string) { - return makeTestAppLogResource( - { - name: sessionName, - device: { - platform: 'apple', - appleOs: 'ios', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - }, - }, - { - backend: 'ios-simulator', - state: 'active', - outputPath, - startedAt: 1_712_040_000_000, - }, - ); -} - -test('network requires an active session', async () => { - const sessionStore = makeSessionStore(); - const response = await handleSessionCommands({ - req: { - token: 't', - session: 'default', - command: 'network', - positionals: ['dump'], - flags: {}, - }, - sessionName: 'default', - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - if (response && !response.ok) { - expect(response.error.code).toBe('SESSION_NOT_FOUND'); - } -}); - -test('network dump adds a targeted note when the session app log stream is inactive', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-network-inactive'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: androidAppLog(sessionName, 'failed'), - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.active).toBe(true); - expect(response.data?.state).toBe('failed'); - expect(response.data?.notes).toContain( - 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', - ); - } -}); - -test('network dump recovers Android entries from adb logcat when the session stream is inactive', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-network-recovery'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: androidAppLog(sessionName, 'failed'), - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { - return { - stdout: - '04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + - '04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/documents status=200 duration=15032\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.path).toContain('adb logcat recovery'); - expect(response.data?.state).toBe('failed'); - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(1); - const latest = entries[0] as Record; - expect(latest.method).toBe('POST'); - expect(latest.url).toBe('https://api.example.com/v1/documents'); - expect(latest.status).toBe(200); - expect(response.data?.notes).toContain( - 'Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set 4321.', - ); - } -}); - -test('network dump merges Android recovery entries ahead of stale session log traffic', async () => { +test('network requires an active session before requesting a runtime binding', async () => { const sessionStore = makeSessionStore(); - const sessionName = 'android-network-merge'; - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - '2026-04-01T09:59:00Z GET https://api.example.com/v1/stale status=200\n', - 'utf8', - ); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: androidAppLog(sessionName, 'failed', appLogPath), - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { - return { - stdout: - '04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + - '04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/fresh status=201 duration=15032\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); const response = await handleSessionCommands({ req: { token: 't', - session: sessionName, + session: 'missing', command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(2); - expect((entries[0] as Record).url).toBe('https://api.example.com/v1/fresh'); - expect((entries[1] as Record).url).toBe('https://api.example.com/v1/stale'); - } -}); - -test('network dump recovers Android entries from previous package pid in bounded logcat window', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-network-previous-pid'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: androidAppLog(sessionName, 'failed'), - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { - return { - stdout: - '04-01 10:00:00.000 I/ActivityManager( 9999): Process com.example.app (pid 1234) has died\n' + - '04-01 10:00:00.500 D/GIBSDK (1234): POST https://api.example.com/v1/submit status=504 duration=15000\n' + - '04-01 10:00:01.000 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(1); - expect((entries[0] as Record).url).toBe('https://api.example.com/v1/submit'); - expect(response.data?.notes).toContain( - 'Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set 4321, 1234.', - ); - } -}); - -test('network dump recovers Android entries when an active stream is still bound to a prior pid', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'android-network-stale-active-pid'; - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - const appLogPidPath = sessionStore.resolveAppLogPidPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - '2026-04-01T09:59:00Z GET https://api.example.com/v1/stale status=200\n', - 'utf8', - ); - fs.writeFileSync( - appLogPidPath, - `${JSON.stringify({ - pid: 9999, - startTime: 'Tue Apr 1 09:59:00 2026', - command: 'adb -s emulator-5554 logcat -v time --pid 1234', - })}\n`, - 'utf8', - ); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, - }), - appBundleId: 'com.example.app', - appLog: androidAppLog(sessionName, 'active', appLogPath), - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if (args.join(' ') === '-s emulator-5554 shell pidof com.example.app') { - return { stdout: '4321\n', stderr: '', exitCode: 0 }; - } - if (args.join(' ') === '-s emulator-5554 logcat -d -v time -t 4000') { - return { - stdout: - '04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' + - '04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/fresh status=201 duration=15032\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], + positionals: ['dump'], flags: {}, }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionName: 'missing', + logPath: path.join(sessionStore.resolveSessionDir('missing'), 'daemon.log'), sessionStore, invoke: noopInvoke, }); - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.path).toContain('adb logcat recovery'); - expect(response.data?.state).toBe('active'); - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(2); - expect((entries[0] as Record).url).toBe('https://api.example.com/v1/fresh'); - expect((entries[1] as Record).url).toBe('https://api.example.com/v1/stale'); - expect(response.data?.notes).toContain( - 'Session app log stream was still bound to prior Android PID 1234. Recovered recent Android HTTP entries from adb logcat for PID set 4321.', - ); - } -}); - -test('network dump recovers iOS simulator entries from simctl log show when the live stream is empty', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'ios-network-recovery'; - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - 'Filtering the log data using "subsystem == \\"com.agentdevice.tester\\""\n', - 'utf8', - ); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.agentdevice.tester', - appLog: iosSimulatorAppLog(sessionName, appLogPath), - }); - - mockRunCmd.mockImplementation(async (_cmd, args) => { - if ( - args[0] === 'simctl' && - args[1] === 'spawn' && - args[2] === 'sim-1' && - args[3] === 'log' && - args[4] === 'show' - ) { - return { - stdout: - 'Timestamp Ty Process[PID:TID]\n' + - '2026-04-02 08:08:50.665 I Agent Device Tester[32193:8c7411e] POST https://api.example.com/v1/search statusCode=200 duration=42\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, + expect(response).toEqual({ + ok: false, + error: { code: 'SESSION_NOT_FOUND', message: 'network requires an active session' }, }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.path).toContain('simctl log show recovery'); - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(1); - expect((entries[0] as Record).url).toBe('https://api.example.com/v1/search'); - expect((entries[0] as Record).status).toBe(200); - expect((entries[0] as Record).durationMs).toBe(42); - expect(response.data?.notes).toContain( - 'Recovered 1 iOS simulator HTTP entry from simctl log show (1 app log lines scanned).', - ); - } }); -test('network dump explains when iOS simulator recovery found app logs but no HTTP-shaped entries', async () => { +test('network validates the legacy limit after runtime-fact admission', async () => { const sessionStore = makeSessionStore(); - const sessionName = 'ios-network-no-http'; - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - 'Filtering the log data using "subsystem == \\"com.agentdevice.tester\\""\n', - 'utf8', + const session = makeSession('android-network', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + }); + sessionStore.set(session.name, session); + const runtime = createNetworkRuntime(session.device, async (input) => + emptyAppLogResult('android', input), ); - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone 17 Pro', - kind: 'simulator', - booted: true, - }), - appBundleId: 'com.agentdevice.tester', - appLog: iosSimulatorAppLog(sessionName, appLogPath), - }); - mockRunCmd.mockImplementation(async (_cmd, args) => { - if ( - args[0] === 'simctl' && - args[1] === 'spawn' && - args[2] === 'sim-1' && - args[3] === 'log' && - args[4] === 'show' - ) { - return { - stdout: - 'Timestamp Ty Process[PID:TID]\n' + - '2026-04-02 08:08:50.665 E Agent Device Tester[32193:8c7411e] Airship config warning\n', - stderr: '', - exitCode: 0, - }; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }); - - const response = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(Array.isArray(response.data?.entries) ? response.data.entries : []).toHaveLength(0); - expect(response.data?.notes).toContain( - 'Recovered 1 recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.', - ); - expect(response.data?.notes).toContain( - 'No HTTP(s) entries were found in recent iOS simulator app logs. If the app only emits non-HTTP diagnostics, inspect logs path or add app-side URLSession/network logging for per-request timing and payload details.', - ); - } -}); - -test('network dump supports macOS desktop sessions', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-network'; - sessionStore.set(sessionName, { - ...makeSession(sessionName, { - platform: 'apple', - appleOs: 'macos', - id: 'host-macos-local', - name: 'Host Mac', - kind: 'device', - target: 'desktop', - booted: true, - }), - appBundleId: 'com.apple.systempreferences', - }); - const appLogPath = sessionStore.resolveAppLogPath(sessionName); - fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); - fs.writeFileSync( - appLogPath, - '2026-02-24T10:00:00Z GET https://example.com/mac status=204', - 'utf8', - ); const response = await handleSessionCommands({ req: { token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'summary'], - flags: {}, - }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, - }); - expect(response?.ok).toBe(true); - if (response && response.ok) { - expect(response.data?.backend).toBe('macos'); - const entries = Array.isArray(response.data?.entries) ? response.data.entries : []; - expect(entries.length).toBe(1); - expect((entries[0] as Record).url).toBe('https://example.com/mac'); - } -}); - -test('network dump validates include mode and limit', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'default'; - sessionStore.set( - sessionName, - makeSession(sessionName, { - platform: 'apple', - id: 'sim-1', - name: 'iPhone Simulator', - kind: 'simulator', - booted: true, - }), - ); - - const invalidLimit = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, + session: session.name, command: 'network', positionals: ['dump', '0'], flags: {}, }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionName: session.name, + logPath: path.join(sessionStore.resolveSessionDir(session.name), 'daemon.log'), sessionStore, + bindDevice: runtime.bindDevice, invoke: noopInvoke, }); - expect(invalidLimit).toBeTruthy(); - expect(invalidLimit?.ok).toBe(false); - if (invalidLimit && !invalidLimit.ok) { - expect(invalidLimit.error.code).toBe('INVALID_ARGS'); - expect(invalidLimit.error.message).toMatch(/1\.\.200/); - } - const invalidMode = await handleSessionCommands({ - req: { - token: 't', - session: sessionName, - command: 'network', - positionals: ['dump', '10', 'verbose'], - flags: {}, + expect(response).toEqual({ + ok: false, + error: { + code: 'INVALID_ARGS', + message: 'network dump limit must be an integer in range 1..200', }, - sessionName, - logPath: path.join(os.tmpdir(), 'daemon.log'), - sessionStore, - invoke: noopInvoke, }); - expect(invalidMode).toBeTruthy(); - expect(invalidMode?.ok).toBe(false); - if (invalidMode && !invalidMode.ok) { - expect(invalidMode.error.code).toBe('INVALID_ARGS'); - expect(invalidMode.error.message).toMatch(/summary, headers, body, all/); - } + expect(runtime.uses).toEqual([{ required: [], preferred: ['networkDump'] }]); + expect(runtime.networkDump).not.toHaveBeenCalled(); }); diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index a1cec6f7f8..e1a54b8ba2 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -27,6 +27,7 @@ vi.mock('../../../platforms/apple/core/perf-xctrace.ts', async (importOriginal) }; }); import { handleSessionObservabilityCommands } from '../session-observability.ts'; +import { createNetworkRuntime, emptyAppLogResult } from './network-runtime-harness.ts'; beforeEach(() => { vi.resetAllMocks(); @@ -48,6 +49,11 @@ test('network dump validates include mode directly', async () => { }, }); + const session = sessionStore.get('android'); + assert.ok(session); + const runtime = createNetworkRuntime(session.device, async (input) => + emptyAppLogResult('android', input), + ); const response = await handleSessionObservabilityCommands({ req: { token: 't', @@ -58,6 +64,7 @@ test('network dump validates include mode directly', async () => { }, sessionName: 'android', sessionStore, + bindDevice: runtime.bindDevice, }); assert.ok(response); @@ -374,6 +381,11 @@ test('network dump accepts explicit include flag and rejects conflicting values' }, }); + const session = sessionStore.get('android'); + assert.ok(session); + const runtime = createNetworkRuntime(session.device, async (input) => + emptyAppLogResult('android', input), + ); const okResponse = await handleSessionObservabilityCommands({ req: { token: 't', @@ -384,6 +396,7 @@ test('network dump accepts explicit include flag and rejects conflicting values' }, sessionName: 'android', sessionStore, + bindDevice: runtime.bindDevice, }); assert.ok(okResponse); @@ -402,6 +415,7 @@ test('network dump accepts explicit include flag and rejects conflicting values' }, sessionName: 'android', sessionStore, + bindDevice: runtime.bindDevice, }); assert.ok(conflictResponse); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 1eaad86a9f..cae4f54081 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -24,7 +24,7 @@ import { getRequestSignal } from '../../request/cancel.ts'; import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts'; import { errorResponse, requireCommandSupported } from './response.ts'; import { resolveImplicitSessionScope, sessionMatchesScope } from '../session-routing.ts'; -import { appLogAdmissionUse } from '@agent-device/contracts/platform'; +import { appLogAdmissionUse, networkAdmissionUse } from '@agent-device/contracts/platform'; import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; export async function handleSessionInventoryCommands(params: { @@ -174,15 +174,26 @@ async function capabilitiesInventoryResponse(params: { }); if ('response' in resolution) return resolution.response; const { device } = resolution; - const logsAvailable = params.bindDevice - ? (await params.bindDevice(device, appLogAdmissionUse)).facts.appLogInspect.available - : false; + const [logsAvailable, networkAvailable] = params.bindDevice + ? await Promise.all([ + params + .bindDevice(device, appLogAdmissionUse) + .then((runtime) => runtime.facts.appLogInspect.available), + params + .bindDevice(device, networkAdmissionUse) + .then((runtime) => runtime.facts.networkDump.available), + ]) + : [false, false]; return { ok: true, data: { device: publicDeviceInfo(device), availableCommands: listCapabilityCommands().filter((command) => - command === 'logs' ? logsAvailable : isCommandSupportedOnDevice(command, device), + command === 'logs' + ? logsAvailable + : command === 'network' + ? networkAvailable + : isCommandSupportedOnDevice(command, device), ), }, }; diff --git a/src/daemon/handlers/session-network.ts b/src/daemon/handlers/session-network.ts new file mode 100644 index 0000000000..657b27d7ca --- /dev/null +++ b/src/daemon/handlers/session-network.ts @@ -0,0 +1,177 @@ +import { + networkAdmissionUse, + resolveNetworkRuntimePlan, + type NetworkDumpResult, +} from '@agent-device/contracts/platform'; +import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '@agent-device/kernel/contracts'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; + +const NETWORK_ACTIONS = ['dump', 'log'] as const; +const NETWORK_ACTIONS_MESSAGE = `network requires ${NETWORK_ACTIONS.join(' or ')}`; +const NETWORK_INCLUDE_MESSAGE = `network include mode must be one of: ${NETWORK_INCLUDE_MODES.join(', ')}`; +const NETWORK_MAX_PAYLOAD_CHARS = 2048; +const NETWORK_MAX_SCAN_LINES = 4000; + +export type NetworkHandlerParams = Readonly<{ + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + bindDevice?: BindDeviceRuntime; +}>; + +type NetworkRequest = Readonly<{ + action: (typeof NETWORK_ACTIONS)[number]; + include: NetworkIncludeMode; + maxEntries: number; +}>; + +export async function handleNetworkCommand(params: NetworkHandlerParams): Promise { + const session = params.sessionStore.get(params.sessionName); + if (!session) { + return errorResponse('SESSION_NOT_FOUND', 'network requires an active session'); + } + + try { + const bindDevice = requireNetworkBindDevice(params.bindDevice); + const admission = await bindDevice(session.device, networkAdmissionUse); + const networkFact = admission.facts.networkDump; + if (!networkFact.available) return unsupportedNetworkResponse(networkFact.hint); + + const request = resolveNetworkRequest(params.req); + if (!request.ok) return request; + const plan = resolveNetworkRuntimePlan({ + action: request.action, + include: request.include, + }); + const runtime = await bindDevice(session.device, plan.use); + const appLog = session.appLog?.handle.inspect(); + const result = await runtime.operations.networkDump({ + sessionId: params.sessionName, + appBundleId: session.appBundleId, + ...(appLog ? { appLogSnapshot: { state: appLog.state, startedAt: appLog.startedAt } } : {}), + maxEntries: request.maxEntries, + include: plan.include, + maxPayloadChars: NETWORK_MAX_PAYLOAD_CHARS, + maxScanLines: NETWORK_MAX_SCAN_LINES, + }); + return networkDumpResponse(result, request, Boolean(session.appLog), appLog?.state); + } catch (error) { + return { ok: false, error: normalizeError(error) }; + } +} + +function requireNetworkBindDevice(bindDevice: BindDeviceRuntime | undefined): BindDeviceRuntime { + if (bindDevice) return bindDevice; + throw new AppError('COMMAND_FAILED', 'Device runtime gateway is not configured', { + reason: 'runtime-gateway-missing', + }); +} + +function resolveNetworkRequest( + req: DaemonRequest, +): (NetworkRequest & Readonly<{ ok: true }>) | DaemonFailureResponse { + const action = resolveNetworkAction(req); + if (!action.ok) return action; + const maxEntries = resolveNetworkLimit(req); + if (!maxEntries.ok) return maxEntries; + const include = resolveNetworkIncludeMode(req); + if (!include.ok) return include; + return { + ok: true, + action: action.action, + include: include.include, + maxEntries: maxEntries.maxEntries, + }; +} + +function resolveNetworkAction( + req: DaemonRequest, +): { ok: true; action: (typeof NETWORK_ACTIONS)[number] } | DaemonFailureResponse { + const action = (req.positionals?.[0] ?? 'dump').toLowerCase(); + if (!NETWORK_ACTIONS.includes(action as (typeof NETWORK_ACTIONS)[number])) { + return errorResponse('INVALID_ARGS', NETWORK_ACTIONS_MESSAGE); + } + return { ok: true, action: action as (typeof NETWORK_ACTIONS)[number] }; +} + +function resolveNetworkLimit( + req: DaemonRequest, +): { ok: true; maxEntries: number } | DaemonFailureResponse { + const maxEntries = req.positionals?.[1] ? Number.parseInt(req.positionals[1], 10) : 25; + if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > 200) { + return errorResponse('INVALID_ARGS', 'network dump limit must be an integer in range 1..200'); + } + return { ok: true, maxEntries }; +} + +function resolveNetworkIncludeMode( + req: DaemonRequest, +): { ok: true; include: NetworkIncludeMode } | DaemonFailureResponse { + const positionalInclude = req.positionals?.[2]?.toLowerCase(); + const flagInclude = req.flags?.networkInclude; + if (positionalInclude && flagInclude && positionalInclude !== flagInclude) { + return errorResponse( + 'INVALID_ARGS', + 'network include mode was provided both positionally and via --include with different values', + ); + } + const requestedInclude = (flagInclude ?? positionalInclude ?? 'summary').toLowerCase(); + if (!NETWORK_INCLUDE_MODES.includes(requestedInclude as NetworkIncludeMode)) { + return errorResponse('INVALID_ARGS', NETWORK_INCLUDE_MESSAGE); + } + 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, + active: boolean, + state: 'active' | 'recovering' | 'ended' | 'failed' | undefined, +): DaemonResponse { + if (result.source === 'app-log') { + return { + ok: true, + data: { + ...result.dump, + active, + state: state ?? 'inactive', + backend: result.backend, + notes: result.notes, + }, + }; + } + + return { + ok: true, + data: { + entries: result.entries, + active: true, + state: 'active', + backend: result.backend, + include: request.include, + matchedLines: result.entries.length, + scannedLines: result.entries.length, + limits: { + maxEntries: request.maxEntries, + maxPayloadChars: NETWORK_MAX_PAYLOAD_CHARS, + maxScanLines: result.entries.length, + }, + notes: result.notes, + }, + }; +} diff --git a/src/daemon/handlers/session-observability.ts b/src/daemon/handlers/session-observability.ts index 9ee23da89e..57f161b265 100644 --- a/src/daemon/handlers/session-observability.ts +++ b/src/daemon/handlers/session-observability.ts @@ -21,13 +21,10 @@ import { type RuntimeOwnerRef, } from '@agent-device/contracts/platform'; import { uniqueStrings } from '@agent-device/kernel/collections'; -import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '@agent-device/kernel/contracts'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; -import { resolveWebProvider } from '../../platforms/web/provider.ts'; import { appendAppLogMarker, clearAppLogFiles, getAppLogPathMetadata } from '../app-log.ts'; import type { AppLogAdmissionLedger } from '../app-log-admission-ledger.ts'; -import { readSessionNetworkCapture } from '../app-log-network-recovery.ts'; import { resolveAppLogResourcePath } from '../app-log-resource-store.ts'; import { adoptStartedSessionAppLog, @@ -40,7 +37,7 @@ import { createNextAppLogFence } from '../app-log-start-preflight.ts'; import type { BindDeviceRuntime } from '../request-runtime-binding.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData, SessionState } from '../types.ts'; -import { errorResponse, requireCommandSupported, type DaemonFailureResponse } from './response.ts'; +import { errorResponse, type DaemonFailureResponse } from './response.ts'; import { handleAudioCommand } from './session-audio.ts'; import { handleNativePerfCommand as handleAndroidNativePerfCommand } from './session-native-perf.ts'; import { handleNativePerfCommand as handleAppleNativePerfCommand } from './session-perf-xctrace.ts'; @@ -49,10 +46,7 @@ import { buildPerfMemoryResponseData, buildPerfResponseData, } from './session-perf.ts'; - -const NETWORK_ACTIONS = ['dump', 'log'] as const; -const NETWORK_ACTIONS_MESSAGE = `network requires ${NETWORK_ACTIONS.join(' or ')}`; -const NETWORK_INCLUDE_MESSAGE = `network include mode must be one of: ${NETWORK_INCLUDE_MODES.join(', ')}`; +import { handleNetworkCommand } from './session-network.ts'; type ObservabilityParams = { req: DaemonRequest; @@ -137,7 +131,7 @@ export async function handleSessionObservabilityCommands( return await handleEventsCommand(params); } if (req.command === 'network') { - return handleNetworkCommand(params); + return await handleNetworkCommand(params); } if (req.command === 'audio') { return await handleAudioCommand(params); @@ -621,7 +615,6 @@ async function startSessionAppLog( return { ok: false, error: normalized }; } } - function requireLogsHandlerParams( params: ObservabilityParams & { session: SessionState }, ): LogsHandlerParams { @@ -641,123 +634,3 @@ function requireLogsHandlerParams( appLogAdmissionLedger: params.appLogAdmissionLedger, }; } - -// --------------------------------------------------------------------------- -// network -// --------------------------------------------------------------------------- - -async function handleNetworkCommand(params: ObservabilityParams): Promise { - const request = resolveNetworkCommandRequest(params); - if (!request.ok) return request; - const { include, maxEntries, session } = request; - - if (session.device.platform === 'web') { - return await handleWebNetworkCommand({ include, maxEntries }); - } - - const appLogSnapshot = session.appLog?.handle.inspect(); - const capture = await readSessionNetworkCapture({ - device: session.device, - appBundleId: session.appBundleId, - appLogState: appLogSnapshot?.state, - appLogStartedAt: appLogSnapshot?.startedAt, - appLogPath: params.sessionStore.resolveAppLogPath(params.sessionName), - maxEntries, - include, - maxPayloadChars: 2048, - maxScanLines: 4000, - }); - - return { - ok: true, - data: { - ...capture.dump, - active: Boolean(session.appLog), - state: appLogSnapshot?.state ?? 'inactive', - backend: capture.backend, - notes: capture.notes, - }, - }; -} - -async function handleWebNetworkCommand(params: { - include: NetworkIncludeMode; - maxEntries: number; -}): Promise { - const provider = resolveWebProvider(); - if (!provider.dumpNetwork) { - return errorResponse('UNSUPPORTED_OPERATION', 'network is not supported by this web provider'); - } - try { - const result = await provider.dumpNetwork({ - include: params.include, - limit: params.maxEntries, - }); - return { - ok: true, - data: { - entries: result.entries, - active: true, - state: 'active', - backend: result.backend ?? 'agent-browser', - include: params.include, - matchedLines: result.entries.length, - scannedLines: result.entries.length, - limits: { - maxEntries: params.maxEntries, - maxPayloadChars: 2048, - maxScanLines: result.entries.length, - }, - notes: result.notes, - }, - }; - } catch (error) { - return { ok: false, error: normalizeError(error) }; - } -} - -function resolveNetworkCommandRequest( - params: ObservabilityParams, -): - | { ok: true; session: SessionState; maxEntries: number; include: NetworkIncludeMode } - | DaemonFailureResponse { - const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); - if (!session) { - return errorResponse('SESSION_NOT_FOUND', 'network requires an active session'); - } - const unsupported = requireCommandSupported('network', session.device); - if (unsupported) return unsupported; - - const action = (req.positionals?.[0] ?? 'dump').toLowerCase(); - if (!NETWORK_ACTIONS.includes(action as (typeof NETWORK_ACTIONS)[number])) { - return errorResponse('INVALID_ARGS', NETWORK_ACTIONS_MESSAGE); - } - - const maxEntries = req.positionals?.[1] ? Number.parseInt(req.positionals[1], 10) : 25; - if (!Number.isInteger(maxEntries) || maxEntries < 1 || maxEntries > 200) { - return errorResponse('INVALID_ARGS', 'network dump limit must be an integer in range 1..200'); - } - - const includeValidation = resolveNetworkIncludeMode(req); - if (!includeValidation.ok) return includeValidation; - return { ok: true, session, maxEntries, include: includeValidation.include }; -} - -function resolveNetworkIncludeMode( - req: DaemonRequest, -): { ok: true; include: NetworkIncludeMode } | DaemonFailureResponse { - const positionalInclude = req.positionals?.[2]?.toLowerCase(); - const flagInclude = req.flags?.networkInclude; - if (positionalInclude && flagInclude && positionalInclude !== flagInclude) { - return errorResponse( - 'INVALID_ARGS', - 'network include mode was provided both positionally and via --include with different values', - ); - } - const requestedInclude = (flagInclude ?? positionalInclude ?? 'summary').toLowerCase(); - if (!NETWORK_INCLUDE_MODES.includes(requestedInclude as (typeof NETWORK_INCLUDE_MODES)[number])) { - return errorResponse('INVALID_ARGS', NETWORK_INCLUDE_MESSAGE); - } - return { ok: true, include: requestedInclude as NetworkIncludeMode }; -} diff --git a/src/daemon/network-log-android-recovery.ts b/src/daemon/network-log-android-recovery.ts deleted file mode 100644 index c41fe47ac8..0000000000 --- a/src/daemon/network-log-android-recovery.ts +++ /dev/null @@ -1,148 +0,0 @@ -import fs from 'node:fs'; -import { AppError } from '@agent-device/kernel/errors'; -import { androidDeviceForSerial } from '../platforms/android/adb.ts'; -import { resolveAndroidAdbExecutor } from '../platforms/android/adb-executor.ts'; -import { captureAndroidLogcatWithAdb } from '../platforms/android/logcat.ts'; - -type StoredNetworkLogProcessMeta = Readonly<{ - pid: number; - command?: string; -}>; - -export async function resolveAndroidPid( - deviceId: string, - appBundleId: string, -): Promise { - const pidResult = await resolveAndroidAdbExecutor(androidDeviceForSerial(deviceId))( - ['shell', 'pidof', appBundleId], - { allowFailure: true }, - ); - const pid = pidResult.stdout.trim().split(/\s+/)[0]; - return pid && /^\d+$/.test(pid) ? pid : null; -} - -export function readTrackedAndroidLogcatPid(pidPath: string | undefined): string | null { - const command = readStoredNetworkLogProcessMeta(pidPath)?.command; - if (!command) return null; - const match = /(?:^|\s)--pid\s+(\d+)(?:\s|$)/.exec(command); - return match?.[1] ?? null; -} - -function readStoredNetworkLogProcessMeta( - pidPath: string | undefined, -): StoredNetworkLogProcessMeta | null { - if (!pidPath) return null; - const raw = readStoredNetworkLogProcessText(pidPath); - if (raw === null || raw.length === 0 || /^\d+$/.test(raw)) return null; - return decodeStoredNetworkLogProcessMeta(raw); -} - -function readStoredNetworkLogProcessText(pidPath: string): string | null { - try { - return fs.readFileSync(pidPath, 'utf8').trim(); - } catch { - return null; - } -} - -function decodeStoredNetworkLogProcessMeta(raw: string): StoredNetworkLogProcessMeta | null { - try { - const parsed = JSON.parse(raw) as unknown; - if (!isUnknownRecord(parsed)) return null; - const record = parsed as Record; - const pid = positiveInteger(record.pid); - if (pid === null) return null; - return { - pid, - ...(typeof record.command === 'string' ? { command: record.command } : {}), - }; - } catch { - return null; - } -} - -function isUnknownRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object'; -} - -function positiveInteger(value: unknown): number | null { - const number = Number(value); - return Number.isInteger(value) && number > 0 ? number : null; -} - -export async function readRecentAndroidLogcatForPackage( - deviceId: string, - appBundleId: string, -): Promise<{ pid: string | null; text: string; recoveredPids: string[] } | null> { - assertAndroidNetworkPackageSafe(appBundleId); - const pid = await resolveAndroidPid(deviceId, appBundleId); - const adb = resolveAndroidAdbExecutor(androidDeviceForSerial(deviceId)); - const text = await captureAndroidLogcatWithAdb(adb, { lines: 4000, timeoutMs: 3_000 }).catch( - () => '', - ); - if (text.trim().length === 0) return null; - const recoveredPids = collectAndroidPackagePids(text, appBundleId, pid); - if (recoveredPids.length === 0) return null; - const filteredText = filterAndroidLogcatToPids(text, appBundleId, recoveredPids); - if (filteredText.trim().length === 0) return null; - return { pid, text: filteredText, recoveredPids }; -} - -function assertAndroidNetworkPackageSafe(appBundleId: string): void { - if (!/^[a-zA-Z0-9._:-]+$/.test(appBundleId)) { - throw new AppError('INVALID_ARGS', `Invalid Android package name for logs: ${appBundleId}`); - } -} - -function collectAndroidPackagePids( - content: string, - appBundleId: string, - currentPid: string | null, -): string[] { - const pids = new Set(); - if (currentPid) pids.add(currentPid); - for (const line of content.split('\n')) { - if (!line.includes(appBundleId)) continue; - for (const candidate of extractAndroidPidsFromPackageLine(line, appBundleId)) { - pids.add(candidate); - } - } - return [...pids]; -} - -function extractAndroidPidsFromPackageLine(line: string, appBundleId: string): string[] { - const escapedPackage = escapeRegExp(appBundleId); - const patterns = [ - new RegExp(`\\bStart proc\\s+(\\d+):${escapedPackage}(?:\\b|/)`, 'i'), - new RegExp(`\\b(\\d+):${escapedPackage}(?:\\b|/)`, 'i'), - new RegExp(`${escapedPackage}.*?\\bpid\\s*[=:]?\\s*(\\d+)\\b`, 'i'), - new RegExp(`\\bpid\\s*[=:]?\\s*(\\d+)\\b.*${escapedPackage}`, 'i'), - ]; - const results: string[] = []; - for (const pattern of patterns) { - const pid = pattern.exec(line)?.[1]; - if (pid && /^\d+$/.test(pid)) results.push(pid); - } - return results; -} - -function filterAndroidLogcatToPids(content: string, appBundleId: string, pids: string[]): string { - const pidSet = new Set(pids); - return content - .split('\n') - .filter((line) => { - if (!line.trim()) return false; - if (line.includes(appBundleId)) return true; - const linePid = parseAndroidThreadtimePid(line); - return linePid ? pidSet.has(linePid) : false; - }) - .join('\n'); -} - -function parseAndroidThreadtimePid(line: string): string | null { - return /\(\s*(\d+)\)\s*:/.exec(line)?.[1] ?? null; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} diff --git a/src/daemon/network-log-ios-simulator-recovery.ts b/src/daemon/network-log-ios-simulator-recovery.ts deleted file mode 100644 index 10e39f0177..0000000000 --- a/src/daemon/network-log-ios-simulator-recovery.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { buildSimctlArgs } from '../platforms/apple/core/simctl.ts'; -import { runXcrun } from '../platforms/apple/core/tool-provider.ts'; - -export async function readRecentIosSimulatorLogShowForBundle(params: { - deviceId: string; - appBundleId: string; - executableName?: string; - startedAt?: number; - simulatorSetPath?: string; -}): Promise<{ text: string; recoveredLineCount: number } | null> { - const args = buildSimctlArgs( - [ - 'spawn', - params.deviceId, - 'log', - 'show', - '--style', - 'compact', - '--info', - '--predicate', - buildNetworkLogPredicate(params.appBundleId, params.executableName), - ], - { simulatorSetPath: params.simulatorSetPath }, - ); - if ( - typeof params.startedAt === 'number' && - Number.isFinite(params.startedAt) && - params.startedAt > 0 - ) { - args.push('--start', `@${Math.floor(params.startedAt / 1000)}`); - } else { - args.push('--last', '5m'); - } - const result = await runXcrun(args, { allowFailure: true, timeoutMs: 4_000 }); - if (result.exitCode !== 0 || result.stdout.trim().length === 0) return null; - const lines = result.stdout - .split('\n') - .map((line) => line.trimEnd()) - .filter((line) => { - const trimmed = line.trim(); - return ( - trimmed.length > 0 && !trimmed.startsWith('Timestamp Ty Process[PID:TID]') - ); - }); - return lines.length === 0 - ? null - : { text: `${lines.join('\n')}\n`, recoveredLineCount: lines.length }; -} - -function buildNetworkLogPredicate(appBundleId: string, executableName?: string): string { - const escapedBundleId = escapePredicateString(appBundleId); - const clauses = [ - `subsystem == "${escapedBundleId}"`, - `subsystem CONTAINS "${escapedBundleId}"`, - ...imagePathPredicateClauses(escapedBundleId, false), - ]; - if (executableName) { - const escapedExecutable = escapePredicateString(executableName); - clauses.push( - `process == "${escapedExecutable}"`, - ...imagePathPredicateClauses(escapedExecutable, true), - ); - } - return clauses.join(' OR '); -} - -function imagePathPredicateClauses(value: string, includeAppContainer: boolean): string[] { - return ['processImagePath', 'senderImagePath'].flatMap((field) => [ - `${field} ENDSWITH[c] "/${value}"`, - ...(includeAppContainer ? [`${field} CONTAINS[c] "/${value}.app/"`] : []), - ]); -} - -function escapePredicateString(value: string): string { - return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); -} diff --git a/src/daemon/network-log.ts b/src/daemon/network-log.ts deleted file mode 100644 index 3eb1633afa..0000000000 --- a/src/daemon/network-log.ts +++ /dev/null @@ -1,372 +0,0 @@ -import fs from 'node:fs'; - -const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const; -const METHOD_WITH_URL_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b\\s+https?:\\/\\/`, 'i'); -const URL_REGEX = /https?:\/\/[^\s"'<>\])]+/i; -const STATUS_PATTERNS = [ - /\bstatus(?:Code)?["'=: ]+([1-5]\d{2})\b/i, - /\bresponse(?:\s+code)?["'=: ]+([1-5]\d{2})\b/i, - /\bHTTP\/[0-9.]+\s+([1-5]\d{2})\b/i, -]; -// When enriching an Android network entry, scan ±5 lines around the match for -// metadata like timestamps, status codes, and packet IDs that Android logcat -// often splits across adjacent log lines. -const ANDROID_NEARBY_LINE_RADIUS = 5; -// For packet-ID correlation, scan a wider ±12-line window because Android's -// OkHttp/Retrofit interceptor logs can spread request→response pairs across -// many interleaved logcat lines. -const ANDROID_PACKET_SCAN_RADIUS = 12; -const NETWORK_LOG_MEMORY_PATH = ''; - -import type { LogBackend, NetworkEntry } from '@agent-device/contracts/observability'; -import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; -export type { NetworkIncludeMode }; - -export type NetworkDump = { - path: string; - exists: boolean; - scannedLines: number; - matchedLines: number; - entries: NetworkEntry[]; - include: NetworkIncludeMode; - limits: { - maxEntries: number; - maxPayloadChars: number; - maxScanLines: number; - }; -}; - -export function mergeNetworkDumps( - primary: NetworkDump, - secondary: NetworkDump, - maxEntries = primary.limits.maxEntries, -): NetworkDump { - const entries = [...primary.entries]; - const seen = new Set(entries.map((entry) => networkEntryKey(entry))); - for (const entry of secondary.entries) { - const key = networkEntryKey(entry); - if (seen.has(key)) continue; - seen.add(key); - entries.push(entry); - if (entries.length >= maxEntries) break; - } - return { - ...primary, - matchedLines: entries.length, - entries, - }; -} - -export function readRecentNetworkTraffic( - logPath: string, - options?: { - backend?: LogBackend; - maxEntries?: number; - include?: NetworkIncludeMode; - maxPayloadChars?: number; - maxScanLines?: number; - }, -): NetworkDump { - const maxEntries = clampInt(options?.maxEntries, 25, 1, 200); - const include = options?.include ?? 'summary'; - const maxPayloadChars = clampInt(options?.maxPayloadChars, 2048, 64, 16_384); - const maxScanLines = clampInt(options?.maxScanLines, 4000, 100, 20_000); - if (!fs.existsSync(logPath)) { - return { - path: logPath, - exists: false, - scannedLines: 0, - matchedLines: 0, - entries: [], - include, - limits: { maxEntries, maxPayloadChars, maxScanLines }, - }; - } - - const content = fs.readFileSync(logPath, 'utf8'); - return readRecentNetworkTrafficFromText(content, { - ...options, - path: logPath, - }); -} - -export function readRecentNetworkTrafficFromText( - content: string, - options?: { - path?: string; - backend?: LogBackend; - maxEntries?: number; - include?: NetworkIncludeMode; - maxPayloadChars?: number; - maxScanLines?: number; - }, -): NetworkDump { - const maxEntries = clampInt(options?.maxEntries, 25, 1, 200); - const backend = options?.backend; - const include = options?.include ?? 'summary'; - const maxPayloadChars = clampInt(options?.maxPayloadChars, 2048, 64, 16_384); - const maxScanLines = clampInt(options?.maxScanLines, 4000, 100, 20_000); - const allLines = content.split('\n'); - const startIndex = Math.max(0, allLines.length - maxScanLines); - const lines = allLines.slice(startIndex); - const entries: NetworkEntry[] = []; - - for (let i = lines.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) { - const rawLine = lines[i]; - const trimmedLine = rawLine?.trim(); - if (!trimmedLine) continue; - const parsed = parseNetworkLine( - lines, - i, - startIndex + i + 1, - backend, - include, - maxPayloadChars, - ); - if (!parsed) continue; - entries.push(parsed); - } - - return { - path: options?.path ?? NETWORK_LOG_MEMORY_PATH, - exists: true, - scannedLines: lines.length, - matchedLines: entries.length, - entries, - include, - limits: { maxEntries, maxPayloadChars, maxScanLines }, - }; -} - -function parseNetworkLine( - lines: string[], - lineIndex: number, - lineNumber: number, - backend: LogBackend | undefined, - include: NetworkIncludeMode, - maxPayloadChars: number, -): NetworkEntry | null { - const line = lines[lineIndex]?.trim(); - if (!line) return null; - const maybeJson = parseEmbeddedJson(line); - const jsonMethod = readJsonString(maybeJson, ['method', 'httpMethod']); - const jsonUrl = readJsonString(maybeJson, ['url', 'requestUrl']); - const jsonStatus = readJsonNumber(maybeJson, ['status', 'statusCode', 'responseCode']); - - const methodWithUrlMatch = METHOD_WITH_URL_REGEX.exec(line); - const methodFieldMatch = /\bmethod["'=: ]+([A-Z]+)\b/i.exec(line); - const method = (jsonMethod ?? methodFieldMatch?.[1] ?? methodWithUrlMatch?.[1])?.toUpperCase(); - - const urlMatch = URL_REGEX.exec(line); - const url = jsonUrl ?? urlMatch?.[0]; - if (!url) return null; - const inlineStatus = jsonStatus ?? parseStatusCode(line) ?? undefined; - const hasExplicitNetworkSignal = - Boolean(jsonMethod) || - Boolean(methodFieldMatch?.[1]) || - Boolean(methodWithUrlMatch?.[1]) || - inlineStatus !== undefined || - /\bURL["'=: ]+https?:\/\//i.test(line) || - /\bheaders?["'=: ]+/i.test(line) || - /\b(?:requestBody|responseBody|payload|request|response)["'=: ]+/i.test(line); - if (!hasExplicitNetworkSignal) { - return null; - } - - const result: NetworkEntry = { - method, - url, - status: inlineStatus, - timestamp: parseTimestamp(line), - packetId: parseAndroidPacketId(line) ?? undefined, - durationMs: parseAndroidDurationMs(line) ?? undefined, - raw: truncate(line, maxPayloadChars), - line: lineNumber, - }; - - if (backend === 'android') { - enrichFromAndroidAdjacentLines(result, lines, lineIndex); - } - - if (include === 'headers' || include === 'all') { - const headers = readHeaders(line, maybeJson); - if (headers) { - result.headers = truncate(headers, maxPayloadChars); - } - } - - if (include === 'body' || include === 'all') { - const requestBody = readBody(line, maybeJson, ['requestBody', 'body', 'payload', 'request']); - const responseBody = readBody(line, maybeJson, ['responseBody', 'response']); - if (requestBody) result.requestBody = truncate(requestBody, maxPayloadChars); - if (responseBody) result.responseBody = truncate(responseBody, maxPayloadChars); - } - - return result; -} - -function networkEntryKey(entry: NetworkEntry): string { - return `${entry.timestamp ?? ''}|${entry.method ?? ''}|${entry.url}|${entry.status ?? ''}|${entry.raw}`; -} - -function enrichFromAndroidAdjacentLines( - result: NetworkEntry, - lines: string[], - lineIndex: number, -): void { - const nearbyLines = collectNearbyLines(lines, lineIndex, ANDROID_NEARBY_LINE_RADIUS); - const packetId = - result.packetId ?? - nearbyLines - .map((line) => parseAndroidPacketId(line)) - .find((value): value is string => typeof value === 'string' && value.length > 0); - if (packetId) { - result.packetId = packetId; - } - - const relatedLines = packetId - ? collectNearbyLines(lines, lineIndex, ANDROID_PACKET_SCAN_RADIUS).filter( - (line) => parseAndroidPacketId(line) === packetId, - ) - : nearbyLines; - if (!result.timestamp) { - result.timestamp = relatedLines - .map((line) => parseTimestamp(line)) - .find((value): value is string => typeof value === 'string' && value.length > 0); - } - if (result.status === undefined) { - result.status = relatedLines - .map((line) => parseStatusCode(line)) - .find((value): value is number => typeof value === 'number'); - } - if (result.durationMs === undefined) { - result.durationMs = relatedLines - .map((line) => parseAndroidDurationMs(line)) - .find((value): value is number => typeof value === 'number'); - } -} - -function collectNearbyLines(lines: string[], lineIndex: number, radius: number): string[] { - const collected: string[] = []; - const start = Math.max(0, lineIndex - radius); - const end = Math.min(lines.length - 1, lineIndex + radius); - for (let i = start; i <= end; i += 1) { - const line = lines[i]?.trim(); - if (!line) continue; - collected.push(line); - } - return collected; -} - -function parseStatusCode(line: string): number | null { - for (const pattern of STATUS_PATTERNS) { - const match = pattern.exec(line); - if (!match) continue; - const value = Number.parseInt(match[1] ?? '', 10); - if (Number.isInteger(value)) return value; - } - return null; -} - -function parseTimestamp(line: string): string | undefined { - const isoMatch = /\b\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z)?\b/.exec(line); - if (isoMatch) return isoMatch[0]; - const androidMatch = /\b\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+\b/.exec(line); - return androidMatch?.[0]; -} - -function parseAndroidPacketId(line: string): string | null { - const match = /\bpacket id (\d+)\b/i.exec(line); - return match?.[1] ?? null; -} - -function parseAndroidDurationMs(line: string): number | null { - const match = /\b(?:duration|elapsed request\/response time, ms)[:= ]+(\d+)\b/i.exec(line); - if (!match) return null; - const value = Number.parseInt(match[1] ?? '', 10); - return Number.isInteger(value) ? value : null; -} - -function parseEmbeddedJson(line: string): Record | null { - const start = line.indexOf('{'); - if (start < 0) return null; - const end = line.lastIndexOf('}'); - if (end <= start) return null; - const candidate = line.slice(start, end + 1); - try { - const parsed = JSON.parse(candidate); - return parsed && typeof parsed === 'object' ? (parsed as Record) : null; - } catch { - return null; - } -} - -function readJsonString(value: Record | null, keys: string[]): string | undefined { - if (!value) return undefined; - for (const key of keys) { - const next = value[key]; - if (typeof next === 'string' && next.trim().length > 0) { - return next.trim(); - } - } - return undefined; -} - -function readJsonNumber(value: Record | null, keys: string[]): number | null { - if (!value) return null; - for (const key of keys) { - const next = value[key]; - if (typeof next === 'number' && Number.isInteger(next)) return next; - if (typeof next === 'string' && /^\d{3}$/.test(next.trim())) { - return Number.parseInt(next.trim(), 10); - } - } - return null; -} - -function readHeaders(line: string, json: Record | null): string | undefined { - if (json) { - const headers = json.headers ?? json.requestHeaders ?? json.responseHeaders; - if (headers !== undefined) return stringifyValue(headers); - } - const match = /\bheaders?["'=: ]+(\{.*\})/i.exec(line); - return match?.[1]?.trim(); -} - -function readBody( - line: string, - json: Record | null, - jsonKeys: string[], -): string | undefined { - if (json) { - for (const key of jsonKeys) { - if (json[key] !== undefined) return stringifyValue(json[key]); - } - } - for (const key of jsonKeys) { - const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(`\\b${escaped}["'=: ]+(.+)$`, 'i'); - const match = regex.exec(line); - if (match?.[1]) return match[1].trim(); - } - return undefined; -} - -function stringifyValue(value: unknown): string { - if (typeof value === 'string') return value; - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function truncate(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}...`; -} - -function clampInt(value: number | undefined, fallback: number, min: number, max: number): number { - if (value === undefined || !Number.isInteger(value)) return fallback; - return Math.max(min, Math.min(max, value)); -} diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 4d90744aa5..95ac487ea5 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -41,8 +41,8 @@ import { import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; import { teardownSessionResources } from './session-teardown.ts'; import type { - AppLogRuntimeOperations, DeviceRuntimeGateway, + PlatformRuntimeOperations, PlatformRequestScope, } from '@agent-device/contracts/platform'; import { createRequestRuntimeBindings, type BindDeviceRuntime } from './request-runtime-binding.ts'; @@ -97,7 +97,7 @@ export async function createRequestExecutionScope(params: { req: DaemonRequest; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; - deviceRuntimeGateway?: DeviceRuntimeGateway; + deviceRuntimeGateway?: DeviceRuntimeGateway; platformRequestScope?: PlatformRequestScope; }): Promise { const { sessionStore, leaseRegistry } = params; diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 6d4af35174..2b71e8b52a 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -2,9 +2,9 @@ import { withResolveTargetDeviceCacheScope } from '../core/dispatch-resolve.ts'; import { withDeviceInventoryContext } from '../core/device-inventory-context.ts'; import type { LeaseLifecycleProvider } from '@agent-device/contracts/device'; import type { - AppLogRuntimeOperations, ComposedDeviceInventoryGateways, DeviceRuntimeGateway, + PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import { AppError, @@ -80,7 +80,7 @@ export type RequestRouterDeps = { webProvider?: WebProviderResolver; recordingProvider?: RecordingProviderResolver; deviceInventoryGateways: ComposedDeviceInventoryGateways; - deviceRuntimeGateway: DeviceRuntimeGateway; + deviceRuntimeGateway: DeviceRuntimeGateway; appLogAdmissionLedger?: AppLogAdmissionLedger; providerRuntimeIds?: readonly string[]; providerRuntimeRequiredIds?: readonly string[]; diff --git a/src/daemon/request-runtime-binding.ts b/src/daemon/request-runtime-binding.ts index 8502190fa5..fe0da1d053 100644 --- a/src/daemon/request-runtime-binding.ts +++ b/src/daemon/request-runtime-binding.ts @@ -2,25 +2,25 @@ import { deviceIdentity, deviceIdentityKey, type DeviceInfo } from '@agent-devic import { AsyncCleanupStack, narrowDeviceBinding, - type AppLogRuntimeOperations, type BoundDeviceRuntime, type DeviceBinding, type DeviceRuntimeGateway, + type PlatformRuntimeOperations, type PlatformRequestScope, type RuntimeOperationKey, type RuntimeUse, } from '@agent-device/contracts/platform'; export type BindDeviceRuntime = < - const Required extends readonly RuntimeOperationKey[], + const Required extends readonly RuntimeOperationKey[], const Preferred extends readonly Exclude< - RuntimeOperationKey, + RuntimeOperationKey, Required[number] >[], >( device: DeviceInfo, - use: RuntimeUse, -) => Promise>>; + use: RuntimeUse, +) => Promise>>; export type RequestRuntimeBindings = AsyncDisposable & Readonly<{ @@ -29,11 +29,11 @@ export type RequestRuntimeBindings = AsyncDisposable & /** Private broad-binding cache; handlers receive only the selected projection. */ export function createRequestRuntimeBindings(params: { - gateway: DeviceRuntimeGateway; + gateway: DeviceRuntimeGateway; scope: PlatformRequestScope; }): RequestRuntimeBindings { const cleanups = new AsyncCleanupStack(); - const bindings = new Map>>(); + const bindings = new Map>>(); const bindDevice: BindDeviceRuntime = async (device, use) => { const key = deviceIdentityKey(deviceIdentity(device)); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 9704435580..a20f69c3ca 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -6,7 +6,7 @@ import { createDaemonHttpServer } from './http-server.ts'; import { trackDownloadableArtifact } from '../artifact-tracking.ts'; import { createProviderDeviceRuntimeRequestProviders } from '../../provider-device-runtime.ts'; import { - createPlatformAppLogRuntimeGateway, + createPlatformRuntimeGateway, createPlatformDeviceInventoryGateways, } from '../../platform-runtime.ts'; import { @@ -210,9 +210,9 @@ export async function startDaemonRuntime( const daemonCodeSignature = resolveDaemonCodeSignature(); const providerComposition = await createDefaultProviderRuntimeComposition(env); const providerDeviceRuntimes = [...providerComposition.runtimes]; - const deviceRuntimeGateway = createPlatformAppLogRuntimeGateway({ + const deviceRuntimeGateway = createPlatformRuntimeGateway({ providerRuntimes: providerDeviceRuntimes, - providerModules: providerComposition.appLogModules, + providerModules: providerComposition.platformModules, sessionsDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: sessionStore.resolveAppLogPath(sessionId), @@ -396,7 +396,7 @@ export async function startDaemonRuntime( const startupAppLogDiagnostics: AppLogRecoveryDiagnostic[] = []; try { const { recoverLegacyAppLogMarkersAfterDaemonLock } = - await import('../../platform-runtime-app-log-host.ts'); + await import('../../platform-runtime-operation-host.ts'); const legacyMarkerRecovery = await recoverLegacyAppLogMarkersAfterDaemonLock(sessionsDir); appLogAdmissionLedger.retainLegacyMarkers(legacyMarkerRecovery.retained); for (const markerPath of legacyMarkerRecovery.recovered) { diff --git a/src/platform-runtime-app-log.test.ts b/src/platform-runtime-gateway.test.ts similarity index 88% rename from src/platform-runtime-app-log.test.ts rename to src/platform-runtime-gateway.test.ts index fe31b7f10f..75517a392b 100644 --- a/src/platform-runtime-app-log.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -1,9 +1,9 @@ import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { - AppLogRuntimeHost, - AppLogRuntimeOperations, DeviceBinding, - DeviceRuntimeOwner, + PlatformRuntimeHost, + PlatformRuntimeOperations, + PlatformRuntimeOwner, PlatformRequestScope, RuntimeOwnerRef, } from '@agent-device/contracts/platform'; @@ -11,9 +11,9 @@ import { providerRuntimeOwner } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { describe, expect, test, vi } from 'vitest'; import { - createComposedAppLogRuntimeGateway, - type AppLogRuntimeProviderRegistration, -} from './platform-runtime-app-log.ts'; + createComposedPlatformRuntimeGateway, + type PlatformRuntimeProviderRegistration, +} from './platform-runtime-gateway.ts'; const device: DeviceInfo = { platform: 'apple', @@ -30,7 +30,7 @@ const scope: PlatformRequestScope = { progress: { report: () => {} }, }; -describe('composed app-log runtime gateway', () => { +describe('composed platform runtime gateway', () => { test('selects an exact provider owner without ordinary ownsDevice arbitration', async () => { const ownsDevice = vi.fn(() => false); const ref = providerRuntimeOwner('limrun', 'stable'); @@ -65,7 +65,7 @@ describe('composed app-log runtime gateway', () => { test('rejects duplicate stable provider owner refs', async () => { const ref = providerRuntimeOwner('limrun', 'stable'); expect(() => gateway([providerRuntime({ ref }), providerRuntime({ ref })])).toThrow( - 'Duplicate app-log runtime owner', + 'Duplicate platform runtime owner', ); }); @@ -89,11 +89,11 @@ describe('composed app-log runtime gateway', () => { }); test('does not fall back to a local runtime for a provider without an app-log module', async () => { - const hostLoad = vi.fn(async () => ({}) as AppLogRuntimeHost); + const hostLoad = vi.fn(async () => ({}) as PlatformRuntimeHost); const localLoad = vi.fn(async () => runtimeOwner({ ref: { kind: 'local-family', family: 'apple' } }), ); - const runtimeGateway = createComposedAppLogRuntimeGateway({ + const runtimeGateway = createComposedPlatformRuntimeGateway({ modules: new Map([['apple', { family: 'apple', loadRuntime: localLoad }]]), loadHost: hostLoad, providerRuntimes: [ @@ -117,8 +117,8 @@ describe('composed app-log runtime gateway', () => { }); test('rejects a swapped local module before loading host mechanics', async () => { - const hostLoad = vi.fn(async () => ({}) as AppLogRuntimeHost); - const runtimeGateway = createComposedAppLogRuntimeGateway({ + const hostLoad = vi.fn(async () => ({}) as PlatformRuntimeHost); + const runtimeGateway = createComposedPlatformRuntimeGateway({ modules: new Map([ [ 'apple', @@ -139,7 +139,7 @@ describe('composed app-log runtime gateway', () => { test('accepts transport-composed facts from the selected local family owner', async () => { const ref = { kind: 'local-family', family: 'apple' } as const; - const runtimeGateway = createComposedAppLogRuntimeGateway({ + const runtimeGateway = createComposedPlatformRuntimeGateway({ modules: new Map([ [ 'apple', @@ -149,7 +149,7 @@ describe('composed app-log runtime gateway', () => { }, ], ]), - loadHost: async () => ({}) as AppLogRuntimeHost, + loadHost: async () => ({}) as PlatformRuntimeHost, }); await expect( @@ -178,10 +178,10 @@ describe('composed app-log runtime gateway', () => { ); }); -function gateway(registrations: readonly AppLogRuntimeProviderRegistration[]) { - return createComposedAppLogRuntimeGateway({ +function gateway(registrations: readonly PlatformRuntimeProviderRegistration[]) { + return createComposedPlatformRuntimeGateway({ modules: new Map(), - loadHost: async () => ({}) as AppLogRuntimeHost, + loadHost: async () => ({}) as PlatformRuntimeHost, providerRuntimes: registrations.map(({ runtime }) => runtime), providerModules: registrations, }); @@ -193,8 +193,8 @@ function providerRuntime(options: { ownsDevice?: (device: DeviceInfo) => boolean; mismatch?: 'owner' | 'device' | 'facts'; disposed?: () => Promise; - load?: () => Promise>; -}): AppLogRuntimeProviderRegistration { + load?: () => Promise; +}): PlatformRuntimeProviderRegistration { const owner = runtimeOwner(options); const runtime: ProviderDeviceRuntime = { provider: options.provider ?? 'limrun', @@ -218,7 +218,7 @@ function runtimeOwner(options: { mismatch?: 'owner' | 'device' | 'facts'; providerMode?: 'local' | 'transport-composed' | 'provider-runtime'; disposed?: () => Promise; -}): DeviceRuntimeOwner { +}): PlatformRuntimeOwner { return { owner: options.ref, ownsDevice: () => true, @@ -232,7 +232,7 @@ function binding(options: { mismatch?: 'owner' | 'device' | 'facts'; providerMode?: 'local' | 'transport-composed' | 'provider-runtime'; disposed?: () => Promise; -}): DeviceBinding { +}): DeviceBinding { const bindingDevice = options.mismatch === 'device' ? { ...device, id: 'wrong' } : device; const bindingOwner = options.mismatch === 'owner' ? providerRuntimeOwner('limrun', 'wrong') : options.ref; @@ -265,5 +265,6 @@ function unavailableFacts() { appLogStart: unavailable, appLogReattach: unavailable, appLogCleanup: unavailable, + networkDump: unavailable, }; } diff --git a/src/platform-runtime-app-log.ts b/src/platform-runtime-gateway.ts similarity index 66% rename from src/platform-runtime-app-log.ts rename to src/platform-runtime-gateway.ts index 19efd342a5..65a1b8c699 100644 --- a/src/platform-runtime-app-log.ts +++ b/src/platform-runtime-gateway.ts @@ -1,16 +1,16 @@ import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { - AppLogRuntimeHost, - AppLogRuntimeOperations, - AppLogRuntimePlatformModule, - AppLogRuntimeProviderModule, DeviceBinding, DeviceBindingRequest, DeviceRuntimeGateway, - DeviceRuntimeOwner, + PlatformRuntimeHost, + PlatformRuntimeModule, + PlatformRuntimeOperations, + PlatformRuntimeOwner, + PlatformRuntimeProviderModule, RuntimeOwnerRef, } from '@agent-device/contracts/platform'; -import { createUnavailableAppLogBinding } from '@agent-device/capture-kit'; +import { createUnavailablePlatformRuntimeBinding } from '@agent-device/capture-kit'; import { providerRuntimeOwner, runtimeOwnerKey, @@ -26,41 +26,38 @@ import { } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -export type AppLogRuntimeProviderRegistration = Readonly<{ +export type PlatformRuntimeProviderRegistration = Readonly<{ runtime: ProviderDeviceRuntime; - module: AppLogRuntimeProviderModule; + module: PlatformRuntimeProviderModule; }>; -export function createComposedAppLogRuntimeGateway(options: { - modules: ReadonlyMap; - loadHost: () => Promise; +export function createComposedPlatformRuntimeGateway(options: { + modules: ReadonlyMap; + loadHost: () => Promise; providerRuntimes?: readonly ProviderDeviceRuntime[]; - providerModules?: readonly AppLogRuntimeProviderRegistration[]; -}): DeviceRuntimeGateway { - const providersByOwner = new Map(); - const modulesByRuntime = new Map(); + providerModules?: readonly PlatformRuntimeProviderRegistration[]; +}): DeviceRuntimeGateway { + const providersByOwner = new Map(); + const modulesByRuntime = new Map(); for (const registration of options.providerModules ?? []) { const { runtime, module } = registration; const key = runtimeOwnerKey(module.owner); if (module.owner.provider !== runtime.provider) { - throw runtimeContractError(`Provider app-log runtime metadata is invalid: ${key}`); + throw runtimeContractError(`Provider platform runtime metadata is invalid: ${key}`); } if (providersByOwner.has(key)) { - throw runtimeContractError(`Duplicate app-log runtime owner: ${key}`); + throw runtimeContractError(`Duplicate platform runtime owner: ${key}`); } if (modulesByRuntime.has(runtime)) { - throw runtimeContractError(`Duplicate app-log module registration for ${runtime.provider}`); + throw runtimeContractError(`Duplicate platform module registration for ${runtime.provider}`); } providersByOwner.set(key, registration); modulesByRuntime.set(runtime, module); } - const localLoads = new Map>>(); - const providerLoads = new Map< - AppLogRuntimeProviderModule, - Promise> - >(); - const loadedOwners = new Map>(); - let hostLoad: Promise | undefined; + const localLoads = new Map>(); + const providerLoads = new Map>(); + const loadedOwners = new Map(); + let hostLoad: Promise | undefined; const loadHost = async () => { hostLoad ??= options.loadHost(); try { @@ -70,11 +67,11 @@ export function createComposedAppLogRuntimeGateway(options: { throw error; } }; - const registerOwner = (owner: DeviceRuntimeOwner) => { + const registerOwner = (owner: PlatformRuntimeOwner) => { const key = runtimeOwnerKey(owner.owner); const existing = loadedOwners.get(key); if (existing && existing !== owner) { - throw runtimeContractError(`Duplicate app-log runtime owner: ${key}`); + throw runtimeContractError(`Duplicate platform runtime owner: ${key}`); } loadedOwners.set(key, owner); return owner; @@ -83,16 +80,18 @@ export function createComposedAppLogRuntimeGateway(options: { const existing = localLoads.get(family); if (existing) return await existing; const module = options.modules.get(family); - if (!module) throw runtimeContractError(`Missing app-log module for ${family}`); + if (!module) throw runtimeContractError(`Missing platform runtime module for ${family}`); if (module.family !== family) { throw runtimeContractError( - `App-log module registered for ${family} declares ${module.family}`, + `Platform runtime module registered for ${family} declares ${module.family}`, ); } const pending = loadHost().then(async (host) => { const owner = await module.loadRuntime(host); if (owner.owner.kind !== 'local-family' || owner.owner.family !== family) { - throw runtimeContractError(`App-log module for ${family} returned a different local owner`); + throw runtimeContractError( + `Platform runtime module for ${family} returned a different local owner`, + ); } return registerOwner(owner); }); @@ -104,14 +103,14 @@ export function createComposedAppLogRuntimeGateway(options: { throw error; } }; - const loadProvider = async (module: AppLogRuntimeProviderModule) => { + const loadProvider = async (module: PlatformRuntimeProviderModule) => { const existing = providerLoads.get(module); if (existing) return await existing; const pending = loadHost().then(async (host) => { const owner = await module.loadRuntime(host); if (!sameRuntimeOwner(owner.owner, module.owner)) { throw runtimeContractError( - 'Provider app-log runtime returned a different advertised owner', + 'Provider platform runtime returned a different advertised owner', ); } return registerOwner(owner); @@ -164,9 +163,9 @@ export function createComposedAppLogRuntimeGateway(options: { } async function bindAndValidate( - owner: DeviceRuntimeOwner, + owner: PlatformRuntimeOwner, request: DeviceBindingRequest, -): Promise> { +): Promise> { const binding = await owner.bind(request); const failure = bindingContractFailure(owner, binding, request); if (!failure) return binding; @@ -180,21 +179,21 @@ async function bindAndValidate( } function bindingContractFailure( - selected: DeviceRuntimeOwner, - binding: DeviceBinding, + selected: PlatformRuntimeOwner, + binding: DeviceBinding, request: DeviceBindingRequest, ): string | undefined { if (!sameRuntimeOwner(binding.owner, selected.owner)) { - return 'App-log binding returned a different owner than the selected runtime'; + return 'Platform binding returned a different owner than the selected runtime'; } if (!matchesRequestedOwner(binding.owner, request)) { - return 'Exact app-log binding returned a different persisted owner'; + return 'Exact platform binding returned a different persisted owner'; } if (!sameDeviceIdentity(deviceIdentity(binding.device), deviceIdentity(request.device))) { - return 'App-log binding returned a different device identity'; + return 'Platform binding returned a different device identity'; } if (!factsMatchBindingIdentity(binding)) { - return 'App-log binding facts do not match its device and owner'; + return 'Platform binding facts do not match its device and owner'; } return undefined; } @@ -203,7 +202,7 @@ function matchesRequestedOwner(owner: RuntimeOwnerRef, request: DeviceBindingReq return request.intent.kind !== 'exact-owner' || sameRuntimeOwner(owner, request.intent.owner); } -function factsMatchBindingIdentity(binding: DeviceBinding): boolean { +function factsMatchBindingIdentity(binding: DeviceBinding): boolean { const { device, owner, facts } = binding; return ( sameDeviceShape(facts.device, deviceShape(device)) && @@ -212,7 +211,7 @@ function factsMatchBindingIdentity(binding: DeviceBinding['facts']['device']['providerMode'], + mode: DeviceBinding['facts']['device']['providerMode'], owner: RuntimeOwnerRef, ): boolean { return owner.kind === 'local-family' @@ -222,12 +221,10 @@ function providerModeMatchesOwner( async function selectExactOwner( ref: RuntimeOwnerRef, - providersByOwner: ReadonlyMap, - loadProvider: ( - module: AppLogRuntimeProviderModule, - ) => Promise>, - loadLocal: (family: Platform) => Promise>, -): Promise> { + providersByOwner: ReadonlyMap, + loadProvider: (module: PlatformRuntimeProviderModule) => Promise, + loadLocal: (family: Platform) => Promise, +): Promise { if (ref.kind === 'local-family') { const owner = await loadLocal(ref.family); if (sameRuntimeOwner(owner.owner, ref)) return owner; @@ -241,16 +238,20 @@ async function selectExactOwner( function unavailableProviderBinding( runtime: ProviderDeviceRuntime, device: DeviceInfo, -): DeviceBinding { +): DeviceBinding { const owner = providerRuntimeOwner(runtime.provider, 'default'); - return createUnavailableAppLogBinding(device, owner, { + const unavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode', }); + return createUnavailablePlatformRuntimeBinding(device, owner, { + appLog: unavailable, + network: unavailable, + }); } function ownerUnavailable(owner: RuntimeOwnerRef): AppError { - return new AppError('UNSUPPORTED_OPERATION', 'The exact app-log runtime owner is unavailable.', { + return new AppError('UNSUPPORTED_OPERATION', 'The exact platform runtime owner is unavailable.', { reason: 'owner-unavailable', owner: runtimeOwnerKey(owner), }); diff --git a/src/platform-runtime-network-host.test.ts b/src/platform-runtime-network-host.test.ts new file mode 100644 index 0000000000..5d6214b7a5 --- /dev/null +++ b/src/platform-runtime-network-host.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; +import { afterEach, test } from 'vitest'; +import { readRecentAppLogLines } from './platform-runtime-network-host.ts'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('reads the exact newest line-bounded app-log suffix', () => { + const directory = createTemporaryDirectory(); + const pathname = path.join(directory, 'app.log'); + fs.writeFileSync(pathname, 'one\ntwo\nthree\nfour\n'); + + assert.deepEqual(readRecentAppLogLines(pathname, 3), { + path: pathname, + exists: true, + text: 'three\nfour\n', + skippedLines: 2, + }); +}); + +test('distinguishes missing files and rejects a final-component symbolic link', () => { + const directory = createTemporaryDirectory(); + const outside = path.join(directory, 'outside.log'); + const linked = path.join(directory, 'app.log'); + fs.writeFileSync(outside, 'GET https://outside.example.test'); + + assert.deepEqual(readRecentAppLogLines(linked, 100), { + path: linked, + exists: false, + text: '', + skippedLines: 0, + }); + + fs.symlinkSync(outside, linked); + assert.throws(() => readRecentAppLogLines(linked, 100), /regular file/); +}); + +test('preserves absolute source line numbers after selecting a bounded suffix', () => { + const directory = createTemporaryDirectory(); + const pathname = path.join(directory, 'app.log'); + const text = [ + ...Array.from({ length: 5000 }, (_, index) => `filler ${index + 1}`), + 'GET https://example.test status=200', + ].join('\n'); + fs.writeFileSync(pathname, `${text}\n`); + + const recent = readRecentAppLogLines(pathname, 4000); + const dump = readRecentNetworkTrafficFromText(recent.text, { + path: recent.path, + exists: recent.exists, + backend: 'android', + maxEntries: 25, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 4000, + lineNumberOffset: recent.skippedLines, + }); + + assert.equal(dump.entries[0]?.line, 5001); +}); + +function createTemporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-network-host-')); + temporaryDirectories.push(directory); + return directory; +} diff --git a/src/platform-runtime-network-host.ts b/src/platform-runtime-network-host.ts new file mode 100644 index 0000000000..3a70834551 --- /dev/null +++ b/src/platform-runtime-network-host.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import type { + AppLogProcessMarkerReadOutcome, + AppLogSessionArtifacts, + NetworkAppLogRead, + NetworkRuntimeHost, + NetworkTransport, +} from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { openVerifiedFileForRead } from './utils/verified-file.ts'; + +type ProcessMarkerReader = Readonly<{ + readMarker(pathname: string): Promise; +}>; + +export function createNetworkRuntimeHost( + options: Readonly<{ + resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; + processes: ProcessMarkerReader; + resolveTransport(device: DeviceInfo): Promise; + }>, +): Pick { + return Object.freeze({ + appLogs: Object.freeze({ + resolveSession: options.resolveSessionArtifacts, + readRecent: async (sessionId: string, maxScanLines: number) => { + const { outputPath } = options.resolveSessionArtifacts(sessionId); + return readRecentAppLogLines(outputPath, maxScanLines); + }, + readProcessMarker: async (sessionId: string) => { + const { pidPath } = options.resolveSessionArtifacts(sessionId); + return await options.processes.readMarker(pidPath); + }, + }), + networkTransports: Object.freeze({ resolve: options.resolveTransport }), + }); +} + +export function readRecentAppLogLines(pathname: string, maxScanLines: number): NetworkAppLogRead { + if (!Number.isInteger(maxScanLines) || maxScanLines < 1 || maxScanLines > 20_000) { + throw new TypeError('Network app-log scan lines must be between 1 and 20000'); + } + const descriptor = openVerifiedFileForRead(pathname); + if (descriptor === undefined) { + return Object.freeze({ path: pathname, exists: false, text: '', skippedLines: 0 }); + } + try { + const text = fs.readFileSync(descriptor, 'utf8'); + const recent = selectRecentLines(text, maxScanLines); + return Object.freeze({ + path: pathname, + exists: true, + ...recent, + }); + } finally { + fs.closeSync(descriptor); + } +} + +function selectRecentLines( + text: string, + maxScanLines: number, +): Readonly<{ text: string; skippedLines: number }> { + const lines = text.split('\n'); + const skippedLines = Math.max(0, lines.length - maxScanLines); + return Object.freeze({ + text: lines.slice(skippedLines).join('\n'), + skippedLines, + }); +} diff --git a/src/platform-runtime-network-web-transport.ts b/src/platform-runtime-network-web-transport.ts new file mode 100644 index 0000000000..0a3c526f28 --- /dev/null +++ b/src/platform-runtime-network-web-transport.ts @@ -0,0 +1,22 @@ +import type { NetworkTransport } from '@agent-device/contracts/platform'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +export async function resolveWebNetworkTransport(device: DeviceInfo): Promise { + if (device.platform !== 'web') return Object.freeze({ mode: 'local' }); + const { hasScopedWebProvider, resolveWebProvider } = await import('./platforms/web/provider.ts'); + const provider = resolveWebProvider(); + const dumpNetwork = provider.dumpNetwork; + const mode = hasScopedWebProvider() ? 'transport-composed' : 'local'; + if (!dumpNetwork) return Object.freeze({ mode }); + return Object.freeze({ + mode, + dump: async (input) => { + const result = await dumpNetwork({ limit: input.maxEntries, include: input.include }); + return Object.freeze({ + backend: result.backend ?? 'agent-browser', + entries: result.entries, + ...(result.notes === undefined ? {} : { notes: result.notes }), + }); + }, + }); +} diff --git a/src/platform-runtime-app-log-host.test.ts b/src/platform-runtime-operation-host.test.ts similarity index 78% rename from src/platform-runtime-app-log-host.test.ts rename to src/platform-runtime-operation-host.test.ts index d65e1da714..3b1ac6678e 100644 --- a/src/platform-runtime-app-log-host.test.ts +++ b/src/platform-runtime-operation-host.test.ts @@ -15,10 +15,10 @@ vi.mock('./platform-runtime-toolchain-host.ts', () => ({ createHostToolchainPreparer: () => capabilities.toolchains, })); -import { createAppLogRuntimeHost } from './platform-runtime-app-log-host.ts'; +import { createPlatformRuntimeHost } from './platform-runtime-operation-host.ts'; -test('app-log host composes the shared lazy Apple-tool and toolchain capabilities', () => { - const host = createAppLogRuntimeHost({ +test('operation host composes the shared lazy Apple-tool and toolchain capabilities', () => { + const host = createPlatformRuntimeHost({ sessionsDir: '/tmp/sessions', resolveSessionArtifacts: () => ({ outputPath: '/tmp/sessions/one/app.log', diff --git a/src/platform-runtime-app-log-host.ts b/src/platform-runtime-operation-host.ts similarity index 63% rename from src/platform-runtime-app-log-host.ts rename to src/platform-runtime-operation-host.ts index 094b109c21..acbe534b70 100644 --- a/src/platform-runtime-app-log-host.ts +++ b/src/platform-runtime-operation-host.ts @@ -1,7 +1,7 @@ import type { - AppLogRuntimeHost, AppLogSessionArtifacts, HostCommandRequest, + PlatformRuntimeHost, } from '@agent-device/contracts/platform'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { setTimeout as sleep } from 'node:timers/promises'; @@ -10,27 +10,39 @@ import { createHostToolchainPreparer } from './platform-runtime-toolchain-host.t import { runCmd, whichCmd } from './utils/exec.ts'; import { openAppLogOutput, readAppLogOutputTail } from './platform-runtime-app-log-output.ts'; import { createManagedAppLogProcesses } from './platform-runtime-app-log-process.ts'; +import { createNetworkRuntimeHost } from './platform-runtime-network-host.ts'; -export function createAppLogRuntimeHost(options: { +export function createPlatformRuntimeHost(options: { sessionsDir: string; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; -}): AppLogRuntimeHost { +}): PlatformRuntimeHost { const processes = createManagedAppLogProcesses(options.sessionsDir); const localProcessTransport = Object.freeze({ mode: 'local' as const, start: processes.start }); + const commands = Object.freeze({ + which: async (executable: string) => ((await whichCmd(executable)) ? executable : undefined), + run: async (request: HostCommandRequest, signal?: AbortSignal) => { + const result = await runCmd(request.executable, [...request.args], { + allowFailure: request.allowFailure, + cwd: request.cwd, + env: request.env ? { ...process.env, ...request.env } : undefined, + signal, + timeoutMs: request.timeoutMs, + }); + return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }; + }, + }); + const network = createNetworkRuntimeHost({ + resolveSessionArtifacts: options.resolveSessionArtifacts, + processes, + resolveTransport: async (device) => { + if (device.platform !== 'web') return Object.freeze({ mode: 'local' as const }); + const { resolveWebNetworkTransport } = + await import('./platform-runtime-network-web-transport.ts'); + return await resolveWebNetworkTransport(device); + }, + }); return Object.freeze({ - commands: Object.freeze({ - which: async (executable: string) => ((await whichCmd(executable)) ? executable : undefined), - run: async (request: HostCommandRequest, signal?: AbortSignal) => { - const result = await runCmd(request.executable, [...request.args], { - allowFailure: request.allowFailure, - cwd: request.cwd, - env: request.env ? { ...process.env, ...request.env } : undefined, - signal, - timeoutMs: request.timeoutMs, - }); - return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }; - }, - }), + commands, appleTools: createAppleToolHost(), toolchains: createHostToolchainPreparer(), artifacts: Object.freeze({ resolveSession: options.resolveSessionArtifacts }), @@ -52,6 +64,7 @@ export function createAppLogRuntimeHost(options: { ); }, }), + ...network, clock: Object.freeze({ now: () => Date.now(), sleep: async (milliseconds: number, signal?: AbortSignal) => { diff --git a/src/platform-runtime.ts b/src/platform-runtime.ts index ea9403f887..10a3d2dd6d 100644 --- a/src/platform-runtime.ts +++ b/src/platform-runtime.ts @@ -4,11 +4,11 @@ import type { } from '@agent-device/contracts/device'; import { createPlatformModuleRegistry, - type AppLogRuntimeOperations, - type AppLogRuntimePlatformModule, type AppLogSessionArtifacts, type ComposedDeviceInventoryGateways, type DeviceRuntimeGateway, + type PlatformRuntimeModule, + type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import { inventoryModule as appleInventoryModule, @@ -34,10 +34,8 @@ import { inventoryModule as webInventoryModule, runtimeModule as webRuntimeModule, } from '@agent-device/platform-web'; -import { - createComposedAppLogRuntimeGateway, - type AppLogRuntimeProviderRegistration, -} from './platform-runtime-app-log.ts'; +import { createComposedPlatformRuntimeGateway } from './platform-runtime-gateway.ts'; +import type { PlatformRuntimeProviderRegistration } from './platform-runtime-gateway.ts'; import { createComposedDeviceInventoryGateways } from './platform-runtime-device-inventory.ts'; const androidInventoryModule = createAndroidInventoryModule({ @@ -72,9 +70,9 @@ export function createPlatformDeviceInventoryGateways( }); } -const appLogRuntimeModules: ReadonlyMap = new Map< +const platformRuntimeModules: ReadonlyMap = new Map< Platform, - AppLogRuntimePlatformModule + PlatformRuntimeModule >([ ['apple', appleRuntimeModule], ['android', androidRuntimeModule], @@ -84,21 +82,21 @@ const appLogRuntimeModules: ReadonlyMap = ['web', webRuntimeModule], ]); -type Platform = AppLogRuntimePlatformModule['family']; +type Platform = PlatformRuntimeModule['family']; -export function createPlatformAppLogRuntimeGateway( +export function createPlatformRuntimeGateway( options: Readonly<{ providerRuntimes?: readonly ProviderDeviceRuntime[]; - providerModules?: readonly AppLogRuntimeProviderRegistration[]; + providerModules?: readonly PlatformRuntimeProviderRegistration[]; resolveSessionArtifacts(sessionId: string): AppLogSessionArtifacts; sessionsDir: string; }>, -): DeviceRuntimeGateway { - return createComposedAppLogRuntimeGateway({ - modules: appLogRuntimeModules, +): DeviceRuntimeGateway { + return createComposedPlatformRuntimeGateway({ + modules: platformRuntimeModules, loadHost: async () => { - const { createAppLogRuntimeHost } = await import('./platform-runtime-app-log-host.ts'); - return createAppLogRuntimeHost({ + const { createPlatformRuntimeHost } = await import('./platform-runtime-operation-host.ts'); + return createPlatformRuntimeHost({ sessionsDir: options.sessionsDir, resolveSessionArtifacts: options.resolveSessionArtifacts, }); diff --git a/src/platforms/web/provider.ts b/src/platforms/web/provider.ts index db00ab3966..cda2edc021 100644 --- a/src/platforms/web/provider.ts +++ b/src/platforms/web/provider.ts @@ -74,6 +74,11 @@ export function resolveWebProvider(provider?: WebProvider): WebProvider { return webProviderScope.resolve(provider); } +/** True only for an explicitly request-scoped provider, never the local fallback. */ +export function hasScopedWebProvider(): boolean { + return webProviderScope.hasScope(); +} + export async function withWebProvider( provider: WebProvider | undefined, fn: () => Promise, diff --git a/src/provider-device-runtimes.ts b/src/provider-device-runtimes.ts index 31f5ae37e9..a36a42b84d 100644 --- a/src/provider-device-runtimes.ts +++ b/src/provider-device-runtimes.ts @@ -1,7 +1,7 @@ import type { DefaultCloudWebDriverProviderRuntimeEnv } from '@agent-device/provider-webdriver'; import type { ProviderDeviceRuntime } from '@agent-device/contracts/device'; import type { LIMRUN_PROVIDER } from '@agent-device/provider-limrun'; -import type { AppLogRuntimeProviderRegistration } from './platform-runtime-app-log.ts'; +import type { PlatformRuntimeProviderRegistration } from './platform-runtime-gateway.ts'; import { providerWebDriver } from './provider-webdriver.ts'; export type DefaultProviderDeviceRuntimeEnv = DefaultCloudWebDriverProviderRuntimeEnv & @@ -14,7 +14,7 @@ export const DEFAULT_PROVIDER_RUNTIME_REQUIRED_IDS = [ export type DefaultProviderRuntimeComposition = Readonly<{ runtimes: readonly ProviderDeviceRuntime[]; - appLogModules: readonly AppLogRuntimeProviderRegistration[]; + platformModules: readonly PlatformRuntimeProviderRegistration[]; }>; export async function createDefaultProviderRuntimeComposition( @@ -22,7 +22,7 @@ export async function createDefaultProviderRuntimeComposition( ): Promise { const runtimes = providerWebDriver.createDefaultRuntimes(env); const apiKey = env.LIMRUN_API_KEY?.trim(); - if (!apiKey) return Object.freeze({ runtimes, appLogModules: [] }); + if (!apiKey) return Object.freeze({ runtimes, platformModules: [] }); const [limrunRuntime, dependencies] = await Promise.all([ import('@agent-device/provider-limrun'), @@ -34,12 +34,12 @@ export async function createDefaultProviderRuntimeComposition( region: env.LIMRUN_REGION?.trim() || undefined, }, dependencies.createLimrunRuntimeDependencies(), - { includeAppLogModule: true }, + { includePlatformModule: true }, ); return Object.freeze({ runtimes: Object.freeze([...runtimes, registration.runtime]), - appLogModules: Object.freeze([ - { runtime: registration.runtime, module: registration.appLogModule }, + platformModules: Object.freeze([ + { runtime: registration.runtime, module: registration.platformModule }, ]), }); } diff --git a/test/integration/ios-simulator-e2e/coverage-manifest.ts b/test/integration/ios-simulator-e2e/coverage-manifest.ts index 2a512baee2..e69661ac04 100644 --- a/test/integration/ios-simulator-e2e/coverage-manifest.ts +++ b/test/integration/ios-simulator-e2e/coverage-manifest.ts @@ -116,8 +116,8 @@ export const IOS_SIMULATOR_E2E_COVERAGE = { 'an 800ms hold increments the durable long-press counter', ), [C.network]: contract( - 'src/daemon/handlers/__tests__/session-network.test.ts', - 'network dump recovers iOS simulator entries from simctl log show when the live stream is empty', + 'packages/platform-apple/src/network/runtime.test.ts', + 'recovers an empty iOS simulator dump from bounded simctl log history', 'iOS simulator recovery parses HTTP status, duration, and URL from bounded logs', ), [C.open]: live('smoke:automation-input', 'fixture cold launch and deep route become visible'), diff --git a/test/integration/provider-scenarios/android-world.ts b/test/integration/provider-scenarios/android-world.ts index c7c8e77bf1..0c3bafe993 100644 --- a/test/integration/provider-scenarios/android-world.ts +++ b/test/integration/provider-scenarios/android-world.ts @@ -130,7 +130,7 @@ export async function createAndroidSettingsWorld(options?: { }; } const daemon = await createProviderScenarioHarness({ - platformAppLogRuntime: true, + platformRuntime: true, androidAdbProvider: () => adbProvider, deviceInventoryProvider: async (request) => { inventoryRequests.push({ ...request }); diff --git a/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts b/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts index 44480bd633..61b8d33646 100644 --- a/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts +++ b/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts @@ -12,7 +12,7 @@ test('provider-inventory Apple logs doctor stays on the scoped Apple tool provid }, }); const daemon = await createProviderScenarioHarness({ - platformAppLogRuntime: true, + platformRuntime: true, appleToolProvider: () => appleTool.provider, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 0872c7b4af..182be00c93 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -23,7 +23,7 @@ import { createTestDeviceInventoryGateways, createTestDeviceInventoryGatewaysFromProvider, } from '../../../src/__tests__/test-utils/device-inventory-gateways.ts'; -import { createPlatformAppLogRuntimeGateway } from '../../../src/platform-runtime.ts'; +import { createPlatformRuntimeGateway } from '../../../src/platform-runtime.ts'; import { unavailableDeviceRuntimeGateway } from '../../../src/daemon/__tests__/test-device-runtime-gateway.ts'; const PROVIDER_SCENARIO_TOKEN = 'provider-scenario-token'; @@ -63,7 +63,7 @@ export async function createProviderScenarioHarness( ( | { deviceInventoryProvider: DeviceInventoryProvider; deviceInventorySource?: never } | { deviceInventorySource: ProviderDeviceInventorySource; deviceInventoryProvider?: never } - ) & { platformAppLogRuntime?: boolean }, + ) & { platformRuntime?: boolean }, ): Promise { const sessionDir = fs.mkdtempSync( path.join(os.tmpdir(), 'agent-device-provider-scenario-session-'), @@ -73,13 +73,13 @@ export async function createProviderScenarioHarness( deviceInventoryProvider, deviceInventorySource, deviceRuntimeGateway: configuredDeviceRuntimeGateway, - platformAppLogRuntime, + platformRuntime, ...routerDeps } = deps; const deviceRuntimeGateway = configuredDeviceRuntimeGateway ?? - (platformAppLogRuntime - ? createPlatformAppLogRuntimeGateway({ + (platformRuntime + ? createPlatformRuntimeGateway({ sessionsDir: sessionDir, resolveSessionArtifacts: (sessionId) => ({ outputPath: sessionStore.resolveAppLogPath(sessionId), diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index 7268c553b6..21596c184e 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -4,8 +4,8 @@ import path from 'node:path'; import { test } from 'vitest'; import { providerRuntimeOwner, - type AppLogRuntimeOperations, type DeviceRuntimeGateway, + type PlatformRuntimeOperations, } from '@agent-device/contracts/platform'; import { createAppLogStartResult, createDurableResourceEnvelope } from '@agent-device/capture-kit'; import { createTestAppLogLiveHandle } from '../../../src/__tests__/test-utils/app-log-live-handle.ts'; @@ -92,7 +92,7 @@ test('Provider-backed integration iOS Settings permission and alert flow uses pr }); let appLogStopCount = 0; const appLogStarts: Array<{ appBundleId: string; outPath: string }> = []; - const deviceRuntimeGateway = createRecordingAppLogRuntimeGateway({ + const deviceRuntimeGateway = createRecordingPlatformRuntimeGateway({ starts: appLogStarts, stopped: () => { appLogStopCount += 1; @@ -193,10 +193,10 @@ test('Provider-backed integration iOS Settings permission and alert flow uses pr } }); -function createRecordingAppLogRuntimeGateway(params: { +function createRecordingPlatformRuntimeGateway(params: { starts: Array<{ appBundleId: string; outPath: string }>; stopped(): void; -}): DeviceRuntimeGateway { +}): DeviceRuntimeGateway { const owner = providerRuntimeOwner('provider-scenario', 'ios-settings'); return { bind: async ({ device }) => { @@ -221,6 +221,10 @@ function createRecordingAppLogRuntimeGateway(params: { appLogStart: { available: true }, appLogReattach: { available: true }, appLogCleanup: { available: true }, + networkDump: { + available: false, + reason: 'unsupported-provider-mode', + }, }, }, operations: { diff --git a/test/integration/provider-scenarios/macos-world.ts b/test/integration/provider-scenarios/macos-world.ts index 7930cad1cb..89c178ff73 100644 --- a/test/integration/provider-scenarios/macos-world.ts +++ b/test/integration/provider-scenarios/macos-world.ts @@ -47,7 +47,7 @@ export async function createMacOsDesktopWorld( }, }); const daemon = await createProviderScenarioHarness({ - platformAppLogRuntime: true, + platformRuntime: true, appleRunnerProvider: options.appleRunnerProvider ? () => options.appleRunnerProvider : undefined, diff --git a/test/integration/provider-scenarios/web-provider.test.ts b/test/integration/provider-scenarios/web-provider.test.ts index 746e7241d4..c626795708 100644 --- a/test/integration/provider-scenarios/web-provider.test.ts +++ b/test/integration/provider-scenarios/web-provider.test.ts @@ -98,6 +98,7 @@ test('web provider is scoped through the request router and dispatch path', asyn const harness = await createProviderScenarioHarness({ deviceInventoryProvider: async () => [WEB_DESKTOP_DEVICE], + platformRuntime: true, webProvider: ({ device, session }) => { calls.push(`scope:${session?.name ?? 'none'}:${device.id}`); return webProvider; From 1bdd38236c197acbe62697c42a764567a583aad9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 10 Aug 2026 08:18:31 +0200 Subject: [PATCH 2/3] fix: preserve platform network recovery routes --- packages/capture-kit/src/index.ts | 1 - packages/contracts/src/app-log-runtime.ts | 5 +- .../contracts/src/network-runtime.test.ts | 7 +++ packages/contracts/src/network-runtime.ts | 3 +- .../src/network/runtime.test.ts | 53 +++++++++++++++-- .../platform-android/src/network/runtime.ts | 28 ++++++++- .../src/network/runtime.test.ts | 34 ++++++----- .../platform-apple/src/network/runtime.ts | 5 +- packages/platform-web/package.json | 1 - pnpm-lock.yaml | 3 - scripts/layering/package-boundaries.test.ts | 1 + .../network-runtime-provider.test.ts | 58 +++++++++++++++++++ 12 files changed, 161 insertions(+), 38 deletions(-) create mode 100644 test/integration/provider-scenarios/network-runtime-provider.test.ts diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts index f35c2334c4..8eb8eda266 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -26,5 +26,4 @@ export { createUnavailablePlatformRuntimeBinding, createUnavailablePlatformRuntimeOwner, } from './platform-runtime-unavailable.ts'; -export type { UnavailablePlatformRuntimeFacts } from './platform-runtime-unavailable.ts'; export { mergeNetworkDumps, readRecentNetworkTrafficFromText } from './network-traffic.ts'; diff --git a/packages/contracts/src/app-log-runtime.ts b/packages/contracts/src/app-log-runtime.ts index a305734d52..bb657c903b 100644 --- a/packages/contracts/src/app-log-runtime.ts +++ b/packages/contracts/src/app-log-runtime.ts @@ -7,10 +7,7 @@ import type { HostCommandRunner, HostToolchainPreparer, } from './platform-runtime-host.ts'; -import type { - ResourceOwnershipFence, - RuntimeProviderMode, -} from './platform-runtime.ts'; +import type { ResourceOwnershipFence, RuntimeProviderMode } from './platform-runtime.ts'; import type { PendingTransferGuard } from './async-lifecycle.ts'; import { type CleanupOutcome, diff --git a/packages/contracts/src/network-runtime.test.ts b/packages/contracts/src/network-runtime.test.ts index 8fc9e92abc..f7f020a194 100644 --- a/packages/contracts/src/network-runtime.test.ts +++ b/packages/contracts/src/network-runtime.test.ts @@ -21,6 +21,13 @@ function compileTimeNetworkProjectionProof( void compileTimeNetworkProjectionProof; function compileTimeCanonicalHostProof(host: NetworkRuntimeHost): void { + void host.appleTools.run({ tool: 'simctl', args: ['spawn', 'sim-1', 'log', 'show'] }); + void host.appleTools.run({ + tool: 'simctl', + // @ts-expect-error The focused Apple port cannot execute an arbitrary binary. + executable: 'xcrun', + args: ['simctl', 'list'], + }); void host.appLogs.readRecent('session-id', 4000); void host.appLogs.readProcessMarker('session-id'); // @ts-expect-error Callers cannot supply an arbitrary app-log path. diff --git a/packages/contracts/src/network-runtime.ts b/packages/contracts/src/network-runtime.ts index 8d185616a4..6a8cc7403a 100644 --- a/packages/contracts/src/network-runtime.ts +++ b/packages/contracts/src/network-runtime.ts @@ -2,7 +2,7 @@ import type { NetworkIncludeMode } from '@agent-device/kernel/contracts'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AppLogLiveState, AppLogProcessMarkerReadOutcome } from './app-log-runtime.ts'; import type { LogBackend } from './logs.ts'; -import type { HostCommandRunner } from './platform-runtime-host.ts'; +import type { AppleToolHost, HostCommandRunner } from './platform-runtime-host.ts'; import type { RuntimeProviderMode } from './platform-runtime.ts'; import type { NetworkDump } from './network-traffic.ts'; @@ -75,6 +75,7 @@ export type NetworkAppLogRead = Readonly<{ export type NetworkRuntimeHost = Readonly<{ commands: HostCommandRunner; + appleTools: AppleToolHost; appLogs: Readonly<{ readRecent(sessionId: string, maxScanLines: number): Promise; readProcessMarker(sessionId: string): Promise; diff --git a/packages/platform-android/src/network/runtime.test.ts b/packages/platform-android/src/network/runtime.test.ts index c847a42970..2317a5e700 100644 --- a/packages/platform-android/src/network/runtime.test.ts +++ b/packages/platform-android/src/network/runtime.test.ts @@ -69,6 +69,44 @@ test('detects an active stream still bound to the previous Android pid', async ( expect(command.mock.calls.some(([request]) => request.args.includes('logcat'))).toBe(true); }); +test('keeps canonical traffic when optional logcat recovery fails', async () => { + const result = await dumpAndroidNetworkTraffic( + host({ + marker: { status: 'missing' }, + command: (request) => commandResult(request, '222'), + appLogText: '01-01 00:00:00.000 D/App (222): GET https://canonical.example.test status=200', + logcat: new Error('adb transport unavailable'), + }), + device, + input({ appLogSnapshot: { state: 'ended', startedAt: 1 } }), + new AbortController().signal, + ); + + expect(result).toMatchObject({ + source: 'app-log', + dump: { entries: [{ url: 'https://canonical.example.test', status: 200 }] }, + }); +}); + +test('preserves the exact request cancellation reason during optional recovery', async () => { + const controller = new AbortController(); + const reason = new Error('request cancelled'); + controller.abort(reason); + + await expect( + dumpAndroidNetworkTraffic( + host({ + marker: { status: 'missing' }, + command: (request) => commandResult(request, '222'), + logcat: new Error('transport error after cancellation'), + }), + device, + input({ appLogSnapshot: { state: 'ended', startedAt: 1 } }), + controller.signal, + ), + ).rejects.toBe(reason); +}); + function input(overrides: Partial = {}): NetworkDumpInput { return { sessionId: 'one', @@ -85,7 +123,7 @@ function host(options: { marker: Awaited>; command(request: HostCommandRequest): { stdout: string; stderr: string; exitCode: number }; appLogText?: string; - logcat: string; + logcat: string | Error; }): PlatformRuntimeHost { return { ...unusedAppLogHost(), @@ -94,9 +132,16 @@ function host(options: { run: async (request) => { if (!request.args.includes('logcat')) return options.command(request); options.command(request); + if (options.logcat instanceof Error) throw options.logcat; return { stdout: options.logcat, stderr: '', exitCode: 0 }; }, }, + appleTools: { + isXcrunAvailable: async () => false, + run: async () => { + throw new Error('unused'); + }, + }, appLogs: { readRecent: async () => ({ path: '/sessions/one/app.log', @@ -118,13 +163,9 @@ function commandResult(request: HostCommandRequest, pid: string) { function unusedAppLogHost(): Omit< PlatformRuntimeHost, - 'commands' | 'appLogs' | 'networkTransports' + 'appleTools' | 'commands' | 'appLogs' | 'networkTransports' > { return { - appleTools: { - isXcrunAvailable: async () => false, - run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - }, toolchains: { prepare: async () => {} }, artifacts: { resolveSession: () => ({ diff --git a/packages/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts index ee0c13f417..2895523b5e 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -1,4 +1,6 @@ import type { + HostCommandRequest, + HostCommandResult, NetworkDumpInput, NetworkDumpResult, PlatformRuntimeHost, @@ -29,6 +31,7 @@ export async function dumpAndroidNetworkTraffic( const notes: string[] = []; const context = await recoveryContext(host, device, input, signal); if (context && input.appBundleId) { + assertAndroidLogPackageSafe(input.appBundleId); const recovered = await recoverPackageTraffic(host, device, input.appBundleId, signal); if (recovered) { const recoveryDump = readRecentNetworkTrafficFromText(recovered.text, { @@ -67,6 +70,22 @@ export async function dumpAndroidNetworkTraffic( }); } +async function runOptionalRecoveryCommand( + host: PlatformRuntimeHost, + request: HostCommandRequest, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + try { + const result = await host.commands.run(request, signal); + signal.throwIfAborted(); + return result; + } catch { + signal.throwIfAborted(); + return undefined; + } +} + async function recoveryContext( host: PlatformRuntimeHost, device: DeviceInfo, @@ -97,9 +116,9 @@ async function recoverPackageTraffic( appBundleId: string, signal: AbortSignal, ): Promise<{ text: string; pids: readonly string[] } | undefined> { - assertAndroidLogPackageSafe(appBundleId); const currentPid = await resolveAndroidPid(host, device.id, appBundleId, signal); - const result = await host.commands.run( + const result = await runOptionalRecoveryCommand( + host, { executable: 'adb', args: ['-s', device.id, 'logcat', '-d', '-v', 'time', '-t', '4000'], @@ -108,6 +127,7 @@ async function recoverPackageTraffic( }, signal, ); + if (!result) return undefined; if (result.exitCode !== 0 || !result.stdout.trim()) return undefined; const pids = collectPackagePids(result.stdout, appBundleId, currentPid); if (pids.length === 0) return undefined; @@ -130,7 +150,8 @@ async function resolveAndroidPid( appBundleId: string, signal: AbortSignal, ): Promise { - const result = await host.commands.run( + const result = await runOptionalRecoveryCommand( + host, { executable: 'adb', args: ['-s', deviceId, 'shell', 'pidof', appBundleId], @@ -138,6 +159,7 @@ async function resolveAndroidPid( }, signal, ); + if (!result) return undefined; const pid = result.stdout.trim().split(/\s+/)[0]; return pid && /^\d+$/.test(pid) ? pid : undefined; } diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 09f497f2ad..691a518174 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -14,7 +14,7 @@ const simulator: DeviceInfo = { }; test('recovers an empty iOS simulator dump from bounded simctl log history', async () => { - const commandRun = vi.fn(async () => ({ + const runSimctl = vi.fn(async () => ({ stdout: [ 'Timestamp Ty Process[PID:TID]', '2026-08-10T10:00:00Z GET https://recovered.example.test status=200', @@ -23,7 +23,7 @@ test('recovers an empty iOS simulator dump from bounded simctl log history', asy exitCode: 0, })); const result = await dumpAppleNetworkTraffic( - host({ commandRun }), + host({ runSimctl }), simulator, input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), new AbortController().signal, @@ -37,10 +37,10 @@ test('recovers an empty iOS simulator dump from bounded simctl log history', asy expect(result.source).toBe('app-log'); if (result.source !== 'app-log') throw new Error('expected app-log result'); expect(result.notes[0]).toContain('Recovered 1 iOS simulator HTTP entry'); - expect(commandRun).toHaveBeenCalledWith( + expect(runSimctl).toHaveBeenCalledWith( expect.objectContaining({ - executable: 'xcrun', - args: expect.arrayContaining(['simctl', 'spawn', 'sim-1', '--start', '@1']), + tool: 'simctl', + args: expect.arrayContaining(['spawn', 'sim-1', '--start', '@1']), timeoutMs: 4_000, }), expect.any(AbortSignal), @@ -50,7 +50,7 @@ test('recovers an empty iOS simulator dump from bounded simctl log history', asy test('preserves the simulator no-HTTP explanation after recovery returns app lines', async () => { const result = await dumpAppleNetworkTraffic( host({ - commandRun: async () => ({ + runSimctl: async () => ({ stdout: '2026-08-10T10:00:00Z application diagnostic only\n', stderr: '', exitCode: 0, @@ -69,10 +69,10 @@ test('preserves the simulator no-HTTP explanation after recovery returns app lin }); test('parses macOS session app-log traffic without loading simulator recovery', async () => { - const commandRun = vi.fn(); + const runSimctl = vi.fn(); const result = await dumpAppleNetworkTraffic( host({ - commandRun, + runSimctl, text: '2026-08-10T10:00:00Z GET https://mac.example.test status=204\n', }), { ...simulator, appleOs: 'macos', kind: 'device', target: 'desktop' }, @@ -84,7 +84,7 @@ test('parses macOS session app-log traffic without loading simulator recovery', backend: 'macos', dump: { entries: [{ url: 'https://mac.example.test', status: 204 }] }, }); - expect(commandRun).not.toHaveBeenCalled(); + expect(runSimctl).not.toHaveBeenCalled(); }); function input(overrides: Partial = {}): NetworkDumpInput { @@ -101,11 +101,17 @@ function input(overrides: Partial = {}): NetworkDumpInput { function host(options: { text?: string; - commandRun: PlatformRuntimeHost['commands']['run']; + runSimctl: PlatformRuntimeHost['appleTools']['run']; }): PlatformRuntimeHost { return { ...unusedAppLogHost(), - commands: { which: async () => undefined, run: options.commandRun }, + commands: { + which: async () => undefined, + run: async () => { + throw new Error('generic command runner must not own Apple network recovery'); + }, + }, + appleTools: { isXcrunAvailable: async () => true, run: options.runSimctl }, appLogs: { readRecent: async () => ({ path: '/sessions/one/app.log', @@ -121,13 +127,9 @@ function host(options: { function unusedAppLogHost(): Omit< PlatformRuntimeHost, - 'commands' | 'appLogs' | 'networkTransports' + 'appleTools' | 'commands' | 'appLogs' | 'networkTransports' > { return { - appleTools: { - isXcrunAvailable: async () => false, - run: async () => ({ stdout: '', stderr: '', exitCode: 0 }), - }, toolchains: { prepare: async () => {} }, artifacts: { resolveSession: () => ({ diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index 496aa3e25d..afad2d612e 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -65,7 +65,6 @@ async function recoverSimulatorTraffic( signal: AbortSignal, ): Promise<{ dump: NetworkDump; lineCount: number } | undefined> { const args = [ - 'simctl', ...(device.simulatorSetPath ? ['--set', device.simulatorSetPath] : []), 'spawn', device.id, @@ -83,8 +82,8 @@ async function recoverSimulatorTraffic( ? ['--start', `@${Math.floor(startedAt / 1000)}`] : ['--last', '5m']), ); - const result = await host.commands.run( - { executable: 'xcrun', args, allowFailure: true, timeoutMs: 4_000 }, + const result = await host.appleTools.run( + { tool: 'simctl', args, allowFailure: true, timeoutMs: 4_000 }, signal, ); if (result.exitCode !== 0 || !result.stdout.trim()) return undefined; diff --git a/packages/platform-web/package.json b/packages/platform-web/package.json index b2ad8335e4..e6c96dfcdb 100644 --- a/packages/platform-web/package.json +++ b/packages/platform-web/package.json @@ -5,7 +5,6 @@ "type": "module", "description": "Web-family platform runtime metadata and implementations for agent-device.", "dependencies": { - "@agent-device/capture-kit": "workspace:*", "@agent-device/contracts": "workspace:*", "@agent-device/kernel": "workspace:*" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e63a9d25a8..9e068b7e98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,9 +263,6 @@ importers: packages/platform-web: dependencies: - '@agent-device/capture-kit': - specifier: workspace:* - version: link:../capture-kit '@agent-device/contracts': specifier: workspace:* version: link:../contracts diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 0aee8f1fb1..b01d629df4 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -451,6 +451,7 @@ test('the real tree parses, declares, and passes R11', () => { ['@agent-device/provider-webdriver'], ); assert.deepEqual([...providerWebDriverPackage.workspaceDependencies].sort(), [ + '@agent-device/capture-kit', '@agent-device/contracts', '@agent-device/kernel', '@agent-device/xml', diff --git a/test/integration/provider-scenarios/network-runtime-provider.test.ts b/test/integration/provider-scenarios/network-runtime-provider.test.ts new file mode 100644 index 0000000000..3172f7d8e2 --- /dev/null +++ b/test/integration/provider-scenarios/network-runtime-provider.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { PROVIDER_SCENARIO_IOS_SIMULATOR } from './fixtures.ts'; +import { createProviderScenarioHarness } from './harness.ts'; +import { createRecordingAppleToolProvider } from './providers.ts'; + +test('iOS simulator network recovery stays on the request-scoped Apple tool provider', async () => { + const appleTool = createRecordingAppleToolProvider({ + simctl: async (args) => { + assert.deepEqual(args.slice(0, 4), ['spawn', 'sim-1', 'log', 'show']); + return { + stdout: '2026-08-10T10:00:00Z GET https://provider-recovery.example.test status=200\n', + stderr: '', + exitCode: 0, + }; + }, + }); + const daemon = await createProviderScenarioHarness({ + platformRuntime: true, + appleToolProvider: () => appleTool.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + }); + daemon.setSession('default', { + name: 'default', + device: PROVIDER_SCENARIO_IOS_SIMULATOR, + appBundleId: 'com.example.app', + createdAt: Date.now(), + actions: [], + }); + + try { + const result = await daemon.client().observability.network({ + action: 'dump', + limit: 5, + include: 'summary', + platform: 'ios', + udid: PROVIDER_SCENARIO_IOS_SIMULATOR.id, + }); + + assert.equal(result.backend, 'ios-simulator'); + const entries = Array.isArray(result.entries) ? result.entries : []; + assert.equal( + (entries[0] as Record | undefined)?.url, + 'https://provider-recovery.example.test', + ); + assert.ok( + appleTool.calls.some( + (call) => + call[0] === 'simctl' && + call[1] === 'spawn' && + call.includes(PROVIDER_SCENARIO_IOS_SIMULATOR.id), + ), + JSON.stringify(appleTool.calls), + ); + } finally { + await daemon.close(); + } +}); From 457fafe6399a95a4ddbfac57f02b3a7fe4157a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 10 Aug 2026 16:57:47 +0200 Subject: [PATCH 3/3] test: guard network parser placement --- .../contracts-implementation-policy.test.ts | 35 +++++++++++++++ .../contracts-implementation-policy.ts | 43 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/scripts/layering/contracts-implementation-policy.test.ts b/scripts/layering/contracts-implementation-policy.test.ts index b7e0d67846..652fcb0025 100644 --- a/scripts/layering/contracts-implementation-policy.test.ts +++ b/scripts/layering/contracts-implementation-policy.test.ts @@ -39,3 +39,38 @@ test('contracts policy ignores type vocabulary, prose, tests, and capture-kit me [], ); }); + +test('network traffic vocabulary cannot grow parser implementation inside contracts', () => { + assert.deepEqual( + messages( + [ + "import type { NetworkEntry } from './network-log.ts';", + 'export type NetworkDump = Readonly<{ entries: readonly NetworkEntry[] }>;', + 'export interface NetworkParserOptions { readonly maxEntries: number }', + ].join('\n'), + 'packages/contracts/src/network-traffic.ts', + ), + [], + ); + assert.match( + messages( + 'export function readRecentNetworkTrafficFromText() { return []; }', + 'packages/contracts/src/network-traffic.ts', + )[0]!, + /runtime implementation/, + ); + assert.match( + messages( + 'export const parseNetworkTimestamp = (value: string) => value;', + 'packages/contracts/src/network-traffic-value.ts', + )[0]!, + /parser modules belong in @agent-device\/capture-kit/, + ); + assert.deepEqual( + messages( + 'export function readRecentNetworkTrafficFromText() { return []; }', + 'packages/capture-kit/src/network-traffic.ts', + ), + [], + ); +}); diff --git a/scripts/layering/contracts-implementation-policy.ts b/scripts/layering/contracts-implementation-policy.ts index 8d4aaea169..09424eefbb 100644 --- a/scripts/layering/contracts-implementation-policy.ts +++ b/scripts/layering/contracts-implementation-policy.ts @@ -22,6 +22,12 @@ export function contractsImplementationAuthorityViolations( for (const file of sources) { if (!isContractsProduction(file.path)) continue; const parsed = parseSync(file.path, file.source); + const networkTrafficViolation = networkTrafficImplementationViolation( + file.path, + file.source, + parsed.program.body, + ); + if (networkTrafficViolation) violations.push(networkTrafficViolation); for (const site of moduleSpecifiers(parsed.module, file.source)) { if (!FORBIDDEN_HOST_MODULES.test(site.spec)) continue; violations.push( @@ -48,6 +54,43 @@ export function contractsImplementationAuthorityViolations( return violations; } +function networkTrafficImplementationViolation( + file: string, + source: string, + body: readonly unknown[], +): LayeringViolation | undefined { + if (!file.startsWith('packages/contracts/src/network-traffic')) return undefined; + if (file !== 'packages/contracts/src/network-traffic.ts') { + return violation( + file, + 1, + 'contracts may own only the neutral network-traffic vocabulary; parser modules belong in @agent-device/capture-kit', + ); + } + const implementation = body.find((statement) => !isTypeOnlyStatement(statement)); + if (!implementation || typeof implementation !== 'object') return undefined; + return violation( + file, + lineAt(source, Number((implementation as Record).start ?? 0)), + 'contracts network-traffic vocabulary contains runtime implementation; move parser mechanics to @agent-device/capture-kit', + ); +} + +function isTypeOnlyStatement(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false; + const statement = value as Record; + if (statement.type === 'ImportDeclaration') return statement.importKind === 'type'; + if (statement.type === 'TSTypeAliasDeclaration' || statement.type === 'TSInterfaceDeclaration') { + return true; + } + if (statement.type !== 'ExportNamedDeclaration') return false; + if (statement.exportKind === 'type') return true; + const declaration = statement.declaration as Record | undefined; + return ( + declaration?.type === 'TSTypeAliasDeclaration' || declaration?.type === 'TSInterfaceDeclaration' + ); +} + function moduleSpecifiers( module: ReturnType['module'], source: string,