Skip to content
2 changes: 1 addition & 1 deletion packages/capture-kit/src/provider-app-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function createProviderAppLogOperations<
Reader extends ProviderAppLogReader,
>(
options: ProviderAppLogOptions<Descriptor, Reader>,
): AppLogRuntimeOperations & NetworkRuntimeOperations {
): AppLogRuntimeOperations & Pick<NetworkRuntimeOperations, 'networkDump'> {
const { host, signal } = options;
const mismatch = `${options.provider} app-log descriptor does not match the bound device or owning session`;
const ownsDescriptor = (descriptor: Descriptor, sessionId: string) =>
Expand Down
11 changes: 11 additions & 0 deletions packages/command-registry/src/flag-definitions-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,17 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
projectConfig: true,
recorded: false,
},
{
key: 'networkHosts',
names: ['--hosts'],
type: 'string',
multiple: true,
usageLabel: '--hosts <domain>',
usageDescription:
'network start: repeatable domain selector that replaces the default selectors of the live capture source (exact host, subdomain, or wildcard domain).',
projectConfig: false,
recorded: false,
},
{
key: 'shutdown',
names: ['--shutdown'],
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export type CliFlags = CloudProviderProfileFields &
dsym?: string;
searchPath?: string;
networkInclude?: NetworkIncludeMode;
/** network start: extra capture-host selectors for the session's capture source. */
networkHosts?: string[];
baseline?: string;
threshold?: string;
appsFilter?: 'user-installed' | 'all';
Expand Down
7 changes: 6 additions & 1 deletion packages/contracts/src/client-observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ export type EventsOptions = AgentDeviceRequestOverrides & {
};

export type NetworkOptions = DeviceCommandBaseOptions & {
action?: 'dump' | 'log';
action?: 'dump' | 'log' | 'start' | 'stop';
limit?: number;
include?: NetworkIncludeMode;
/**
* `start`: domain selectors that replace the live capture source's defaults. Exact
* hosts, subdomains, and wildcard domains are accepted.
*/
hosts?: string[];
};

export type AudioOptions = DeviceCommandBaseOptions & {
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/network-runtime-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { defineUse } from './platform-runtime-operations.ts';

export const networkDumpUse = defineUse({ required: ['networkDump'] });

/** `network start` on a device whose owner has a live capture source; a typed refusal elsewhere. */
export const networkCaptureStartUse = defineUse({ required: ['networkCaptureStart'] });

/** `network stop`; available wherever a capture can be started. */
export const networkCaptureStopUse = defineUse({ required: ['networkCaptureStop'] });

/** Fact-only capability projection; it is not an execution-plan variant. */
export const networkAdmissionUse = defineUse({
required: [],
Expand Down
55 changes: 55 additions & 0 deletions packages/contracts/src/network-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export type NetworkProviderDumpResult = Readonly<{
backend: string;
entries: readonly NetworkProviderEntry[];
notes?: readonly string[];
/** Per-field character bound this source actually enforces; the response's limits report it. */
limits?: Readonly<{ maxPayloadChars: number }>;
}>;

export type NetworkDumpResult =
Expand All @@ -49,8 +51,61 @@ export type NetworkDumpResult =
}>
| (Readonly<{ source: 'provider' }> & NetworkProviderDumpResult);

/**
* Starts one session's live capture source. `network dump` never installs a source: it reads only
* the source a prior `network start` selected (ADR 0037). A source is chosen at start and a dump
* never switches silently.
*/
export type NetworkCaptureStartInput = Readonly<{
sessionId: string;
/** App the session has open, when one is open; a source reports `requiresRelaunch` from it. */
appBundleId?: string;
/** Domain selectors that replace the source's defaults. */
hosts?: readonly string[];
}>;

/** Typed start refusals; a caller never matches on message text. */
export type NetworkCaptureStartFailureReason =
| 'tunnel-in-use'
| 'capture-stopped'
| 'unsupported-device'
| 'invalid-hosts'
| 'tunnel-start-failed';

export type NetworkCaptureStartResult =
| Readonly<{
started: true;
/** Name of the source now serving `network dump` for this session. */
source: string;
/** Domain selectors the source routes and inspects. */
hosts: readonly string[];
/**
* True when the app was launched before capture started, so its already-open connections
* are outside the capture window and the app must relaunch for full coverage.
*/
requiresRelaunch: boolean;
/** True when start keeps request and response bodies for `network dump --include body|all`. */
capturingBodies: boolean;
}>
| Readonly<{ started: false; reason: NetworkCaptureStartFailureReason; message: string }>;

export type NetworkCaptureStopInput = Readonly<{
sessionId: string;
}>;

export type NetworkCaptureStopResult = Readonly<{
/** Whether a capture was running and is now stopped; a later dump reads the app log again. */
stopped: boolean;
}>;

export type NetworkRuntimeOperations = Readonly<{
networkDump(input: NetworkDumpInput): Promise<NetworkDumpResult>;
/**
* Live capture control. Owners without a capture source report both facts unavailable and bind
* neither; `network dump` then keeps answering from the session app log.
*/
networkCaptureStart(input: NetworkCaptureStartInput): Promise<NetworkCaptureStartResult>;
networkCaptureStop(input: NetworkCaptureStopInput): Promise<NetworkCaptureStopResult>;
}>;

export type NetworkProviderDump = (
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export function createUnavailablePlatformRuntimeFacts(
sendPushNotification: frozen.appDeployment,
uninstallApp: frozen.appDeployment,
appState: frozen.appState,
networkCaptureStart: frozen.network,
networkCaptureStop: frozen.network,
networkDump: frozen.network,
screenRecordingStart: frozen.screenRecording,
screenRecordingReattach: frozen.screenRecording,
Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/provider-device-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export type ProviderPortReverseOptions = {
name: string;
};

export type ProviderExpiredLeaseRecovery = (lease: DeviceLease) => Promise<void>;
export type ProviderExpiredLeaseRecovery = (
lease: DeviceLease,
) => Promise<void | Readonly<{ warnings?: readonly string[] }>>;

/** Provider adapter contract; root owns active-runtime and request composition. */
export type ProviderDeviceRuntime = {
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/request-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export type InternalRequestOptions = AgentDeviceClientConfig &
activity?: string;
launchConsole?: string;
launchArgs?: string[];
/** network start: capture-host selectors, projected onto the networkHosts request flag. */
hosts?: string[];
relaunch?: boolean;
shutdown?: boolean;
saveScript?: boolean | string;
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/runtime-operation-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export const RUNTIME_OPERATION_NAMES = [
'mediaPrevious',
'mediaRewind',
'mute',
'networkCaptureStart',
'networkCaptureStop',
'networkDump',
'openApplication',
'perfFrames',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ test.each([
appLogCleanup: { available: true as const },
appState: unavailable,
networkDump: unavailable,
networkCaptureStart: unavailable,
networkCaptureStop: unavailable,
screenRecordingStart: unavailable,
screenRecordingReattach: unavailable,
screenRecordingCleanup: unavailable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export function lifecycleRuntimeFacts(device: DeviceInfo): RuntimeFacts<Platform
appLogCleanup: unavailable,
appState: unavailable,
networkDump: unavailable,
networkCaptureStart: unavailable,
networkCaptureStop: unavailable,
screenRecordingStart: unavailable,
screenRecordingReattach: unavailable,
screenRecordingCleanup: unavailable,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { vi } from 'vitest';
import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime';
import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime';
import type {
NetworkCaptureStartResult,
NetworkCaptureStopResult,
NetworkDumpInput,
NetworkDumpResult,
} from '@agent-device/contracts/network-runtime';
import {
type RuntimeOperationFact,
type RuntimeOwnerRef,
Expand All @@ -17,17 +22,35 @@ export function createNetworkRuntime(
implementation: (input: NetworkDumpInput) => Promise<NetworkDumpResult>,
networkFact: RuntimeOperationFact = { available: true },
owner: RuntimeOwnerRef = localRuntimeOwner(device.platform),
captureFacts: RuntimeOperationFact | undefined = undefined,
captureImplementation?: () => Promise<NetworkCaptureStartResult>,
captureStopImplementation?: () => Promise<NetworkCaptureStopResult>,
) {
const uses: Array<{
required: readonly string[];
preferred: readonly string[];
conditional?: readonly string[];
}> = [];
const networkDump = vi.fn(implementation);
const networkCaptureStart = vi.fn(
captureImplementation ??
(async (): Promise<NetworkCaptureStartResult> => ({
started: false,
reason: 'unsupported-device',
message: 'capture is not wired in this harness',
})),
);
const networkCaptureStop = vi.fn(
captureStopImplementation ??
(async (): Promise<NetworkCaptureStopResult> => ({
stopped: false,
})),
);
const unavailable = {
available: false,
reason: 'owner-capability-missing',
} as const;
const captureFact = captureFacts ?? unavailable;
const bindDevice: BindDeviceRuntime = async (selected, use) => {
uses.push({
required: [...use.required],
Expand Down Expand Up @@ -57,6 +80,8 @@ export function createNetworkRuntime(
appLogCleanup: unavailable,
appState: unavailable,
networkDump: networkFact,
networkCaptureStart: captureFact,
networkCaptureStop: captureFact,
screenRecordingStart: unavailable,
screenRecordingReattach: unavailable,
screenRecordingCleanup: unavailable,
Expand All @@ -78,13 +103,16 @@ export function createNetworkRuntime(
}),
},
},
operations: networkFact.available ? { networkDump } : {},
operations: {
...(networkFact.available ? { networkDump } : {}),
...(captureFact.available ? { networkCaptureStart, networkCaptureStop } : {}),
},
[Symbol.asyncDispose]: async () => {},
} satisfies import('@agent-device/contracts/platform-runtime').DeviceBinding<PlatformRuntimeOperations>,
use,
);
};
return { bindDevice, networkDump, uses };
return { bindDevice, networkDump, networkCaptureStart, networkCaptureStop, uses };
}

export function emptyAppLogResult(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ test('recovery never finalizes a running checkpoint from a dead helper as comple
appLogCleanup: unavailable,
appState: unavailable,
networkDump: unavailable,
networkCaptureStart: unavailable,
networkCaptureStop: unavailable,
screenRecordingStart: unavailable,
screenRecordingReattach: unavailable,
screenRecordingCleanup: unavailable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ function makeGateway(disposeError?: Error) {
},
notes: [],
}),
networkCaptureStart: async () => ({
started: false as const,
reason: 'unsupported-device' as const,
message: 'not used',
}),
networkCaptureStop: async () => ({ stopped: false }),
};
const bindingDispose = vi.fn(async () => {
if (disposeError) throw disposeError;
Expand All @@ -233,6 +239,8 @@ function makeGateway(disposeError?: Error) {
appLogCleanup: { available: true as const },
appState: { available: false as const, reason: 'owner-capability-missing' as const },
networkDump: { available: true as const },
networkCaptureStart: unavailableRecording,
networkCaptureStop: unavailableRecording,
screenRecordingStart: unavailableRecording,
screenRecordingReattach: unavailableRecording,
screenRecordingCleanup: unavailableRecording,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,12 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) {
},
notes: [],
})),
networkCaptureStart: vi.fn(async () => ({
started: false as const,
reason: 'unsupported-device' as const,
message: 'not used',
})),
networkCaptureStop: vi.fn(async () => ({ stopped: false })),
screenRecordingStart: vi.fn(async () => {
throw new Error('not used');
}),
Expand All @@ -481,6 +487,8 @@ function makeGateway(options: { inspectAvailable?: boolean } = {}) {
appLogReattach: { available: true } as const,
appLogCleanup: { available: true } as const,
networkDump: { available: true } as const,
networkCaptureStart: { available: false, reason: 'owner-capability-missing' } as const,
networkCaptureStop: { available: false, reason: 'owner-capability-missing' } as const,
screenRecordingStart: { available: true } as const,
screenRecordingReattach: { available: true } as const,
screenRecordingCleanup: { available: true } as const,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ test('recovery cleans a recording through its exact runtime owner', async () =>
appLogCleanup: unavailable,
appState: unavailable,
networkDump: unavailable,
networkCaptureStart: unavailable,
networkCaptureStop: unavailable,
screenRecordingStart: unavailable,
screenRecordingReattach: { available: true as const },
screenRecordingCleanup: { available: true as const },
Expand Down
2 changes: 2 additions & 0 deletions packages/daemon/src/core/runtime-operation-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export const RUNTIME_OPERATION_EFFECTS: Readonly<
mediaPrevious: 'mutates',
mediaRewind: 'mutates',
mute: 'mutates',
networkCaptureStart: 'repeatable',
networkCaptureStop: 'repeatable',
networkDump: 'repeatable',
openApplication: 'mutates',
perfFrames: 'repeatable',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,8 @@ function makeGateway(
appLogCleanup: { available: true as const },
appState: { available: false as const, reason: 'owner-capability-missing' as const },
networkDump: { available: true as const },
networkCaptureStart: unavailableRecording,
networkCaptureStop: unavailableRecording,
screenRecordingStart: unavailableRecording,
screenRecordingReattach: unavailableRecording,
screenRecordingCleanup: unavailableRecording,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ function lifecycleFacts(
appLogCleanup: unavailable,
appState: unavailable,
networkDump: unavailable,
networkCaptureStart: unavailable,
networkCaptureStop: unavailable,
screenRecordingStart: unavailable,
screenRecordingReattach: unavailable,
screenRecordingCleanup: unavailable,
Expand Down
Loading
Loading