Skip to content

Commit f512b7d

Browse files
authored
feat: add network dump command for session app traffic (#120)
* feat: add network dump command for session app traffic * fix: restore cli branch block after rebase conflict
1 parent 43aae1d commit f512b7d

13 files changed

Lines changed: 669 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ The project is in early development and considered experimental. Pull requests a
1818
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
1919
- Clipboard commands: `clipboard read`, `clipboard write <text>`.
2020
- Performance command: `perf` (alias: `metrics`) returns a metrics JSON blob for the active session; startup timing is currently sampled.
21-
- App logs: `logs path` returns session log metadata; `logs start` / `logs stop` stream app output; `logs clear` truncates session app logs; `logs clear --restart` resets and restarts stream in one step; `logs doctor` checks readiness; `logs mark` writes timeline markers.
21+
- App logs and traffic inspection: `logs path` returns session log metadata; `logs start` / `logs stop` stream app output; `logs clear` truncates session app logs; `logs clear --restart` resets and restarts stream in one step; `logs doctor` checks readiness; `logs mark` writes timeline markers; `network dump` parses recent HTTP(s) entries from session logs.
2222
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
2323
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).
2424

@@ -150,6 +150,7 @@ agent-device scrollintoview @e42
150150
- `trace start`, `trace stop`
151151
- `logs path`, `logs start`, `logs stop`, `logs clear`, `logs clear --restart`, `logs doctor`, `logs mark` (session app log file for grep; iOS simulator + iOS device + Android)
152152
- `clipboard read`, `clipboard write <text>` (iOS simulator + Android)
153+
- `network dump [limit] [summary|headers|body|all]`, `network log ...` (best-effort HTTP(s) parsing from session app log)
153154
- `settings wifi|airplane|location on|off`
154155
- `settings appearance light|dark|toggle`
155156
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
@@ -382,6 +383,7 @@ Clipboard:
382383
- **App logs (token-efficient):** Logging is off by default in normal flows. Enable it on demand when debugging. With an active session, run `logs path` to get path + state metadata (e.g. `~/.agent-device/sessions/<session>/app.log`). Run `logs start` to stream app output to that file; use `logs stop` to stop. Run `logs clear` to truncate `app.log` (and remove rotated `app.log.N` files) before a new repro window. Run `logs doctor` for tool/runtime checks and `logs mark "step"` to insert timeline markers. Grep the file when you need to inspect errors (e.g. `grep -n "Error\|Exception" <path>`) instead of pulling full logs into context. Supported on iOS simulator, iOS physical device, and Android.
383384
- Use `logs clear --restart` when you want one command to stop an active stream, clear current logs, and immediately resume streaming.
384385
- `logs start` appends to `app.log` and rotates to `app.log.1` when the file exceeds 5 MB.
386+
- **Network dump (best-effort):** `network dump [limit] [summary|headers|body|all]` parses recent HTTP(s) lines from the same session app log file and returns method/url/status with optional headers/bodies. `network log ...` is an alias. Current limits: scans up to 4000 recent log lines, returns up to 200 entries, truncates payload/header fields at 2048 characters.
385387
- Android log streaming automatically rebinds to the app PID after process restarts.
386388
- Detailed playbook: `skills/agent-device/references/logs-and-debug.md`
387389
- iOS log capture relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime.

skills/agent-device/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Use this skill as a router, not a full manual.
2222

2323
- No target context yet: `devices` -> pick target -> `open`.
2424
- Normal UI task: `open` -> `snapshot -i` -> `press/fill` -> `diff snapshot -i` -> `close`
25-
- Debug/crash: `open <app>` -> `logs clear --restart` -> reproduce -> `logs path` -> targeted `grep`
25+
- Debug/crash: `open <app>` -> `logs clear --restart` -> reproduce -> `network dump` -> `logs path` -> targeted `grep`
2626
- Replay drift: `replay -u <path>` -> verify updated selectors
2727

2828
## Canonical Flows
@@ -43,6 +43,7 @@ agent-device close
4343
```bash
4444
agent-device open MyApp --platform ios
4545
agent-device logs clear --restart
46+
agent-device network dump 25
4647
agent-device logs path
4748
```
4849

@@ -97,6 +98,7 @@ agent-device appstate
9798
agent-device clipboard read
9899
agent-device clipboard write "token"
99100
agent-device perf --json
101+
agent-device network dump [limit] [summary|headers|body|all]
100102
agent-device push <bundle|package> <payload.json|inline-json>
101103
agent-device get text @e1
102104
agent-device screenshot out.png
@@ -125,6 +127,7 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
125127
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
126128
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
127129
- Clipboard helpers: `clipboard read` / `clipboard write <text>` are supported on Android and iOS simulators; iOS physical devices are not supported yet.
130+
- `network dump` is best-effort and parses HTTP(s) entries from the session app log file.
128131
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
129132
- For AndroidTV/tvOS selection, always pair `--target` with `--platform` (`ios`, `android`, or `apple` alias); target-only selection is invalid.
130133
- `push` simulates notification delivery:

skills/agent-device/references/logs-and-debug.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Logs (Token-Efficient Debugging)
22

33
Logging is off by default in normal flows. Enable it on demand for debugging windows. App output is written to a session-scoped file so agents can grep it instead of loading full logs into context.
4+
`network dump` parses recent HTTP(s) entries from this same session app log file.
45

56
## Data Handling
67

@@ -23,6 +24,7 @@ Logging is off by default in normal flows. Enable it on demand for debugging win
2324
```bash
2425
agent-device open MyApp --platform ios # or --platform android
2526
agent-device logs clear --restart # Preferred: stop stream, clear logs, and start streaming again
27+
agent-device network dump 25 # Parse latest HTTP(s) requests (method/url/status) from app.log
2628
agent-device logs path # Print path, e.g. ~/.agent-device/sessions/default/app.log
2729
agent-device logs doctor # Check tool/runtime readiness for current session/device
2830
agent-device logs mark "before tap" # Insert a timeline marker into app.log
@@ -41,10 +43,13 @@ Precondition: `logs clear --restart` requires an active app session (`open <app>
4143
- `logs clear --restart`: convenience reset for repro loops (stop stream, clear files, restart stream).
4244
- `logs doctor`: reports backend/tool checks and readiness notes for troubleshooting.
4345
- `logs mark`: writes a timestamped marker line to the session log.
46+
- `network dump [limit] [summary|headers|body|all]`: parses recent HTTP(s) lines from the session app log and returns request summaries.
47+
- `network log ...`: alias for `network dump`.
4448

4549
## Behavior and Limits
4650

4751
- `logs start` appends to `app.log` and rotates to `app.log.1` when `app.log` exceeds 5 MB.
52+
- `network dump` scans the last 4000 app-log lines, returns up to 200 entries, and truncates payload/header fields at 2048 characters.
4853
- Android log streaming automatically rebinds to the app PID after process restarts.
4954
- iOS log capture relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime.
5055
- Retention knobs:

src/__tests__/cli-network.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { runCli } from '../cli.ts';
4+
import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts';
5+
6+
class ExitSignal extends Error {
7+
public readonly code: number;
8+
9+
constructor(code: number) {
10+
super(`EXIT_${code}`);
11+
this.code = code;
12+
}
13+
}
14+
15+
type RunResult = {
16+
code: number | null;
17+
stdout: string;
18+
stderr: string;
19+
calls: Omit<DaemonRequest, 'token'>[];
20+
};
21+
22+
async function runCliCapture(
23+
argv: string[],
24+
responder: (req: Omit<DaemonRequest, 'token'>) => Promise<DaemonResponse>,
25+
): Promise<RunResult> {
26+
let stdout = '';
27+
let stderr = '';
28+
let code: number | null = null;
29+
const calls: Array<Omit<DaemonRequest, 'token'>> = [];
30+
31+
const originalExit = process.exit;
32+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
33+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
34+
35+
(process as any).exit = ((nextCode?: number) => {
36+
throw new ExitSignal(nextCode ?? 0);
37+
}) as typeof process.exit;
38+
(process.stdout as any).write = ((chunk: unknown) => {
39+
stdout += String(chunk);
40+
return true;
41+
}) as typeof process.stdout.write;
42+
(process.stderr as any).write = ((chunk: unknown) => {
43+
stderr += String(chunk);
44+
return true;
45+
}) as typeof process.stderr.write;
46+
47+
const sendToDaemon = async (req: Omit<DaemonRequest, 'token'>): Promise<DaemonResponse> => {
48+
calls.push(req);
49+
return await responder(req);
50+
};
51+
52+
try {
53+
await runCli(argv, { sendToDaemon });
54+
} catch (error) {
55+
if (error instanceof ExitSignal) code = error.code;
56+
else throw error;
57+
} finally {
58+
process.exit = originalExit;
59+
process.stdout.write = originalStdoutWrite;
60+
process.stderr.write = originalStderrWrite;
61+
}
62+
63+
return { code, stdout, stderr, calls };
64+
}
65+
66+
test('network dump prints parsed entries and metadata', async () => {
67+
const result = await runCliCapture(['network', 'dump', '10', 'all'], async () => ({
68+
ok: true,
69+
data: {
70+
path: '/tmp/app.log',
71+
include: 'all',
72+
active: true,
73+
state: 'active',
74+
backend: 'android',
75+
scannedLines: 120,
76+
matchedLines: 2,
77+
entries: [
78+
{
79+
timestamp: '2026-02-24T10:00:01Z',
80+
method: 'POST',
81+
url: 'https://api.example.com/v1/login',
82+
status: 401,
83+
headers: '{"x-id":"abc"}',
84+
requestBody: '{"email":"u@example.com"}',
85+
responseBody: '{"error":"denied"}',
86+
},
87+
],
88+
notes: ['best-effort parser'],
89+
},
90+
}));
91+
92+
assert.equal(result.code, null);
93+
assert.equal(result.calls.length, 1);
94+
assert.deepEqual(result.calls[0]?.positionals, ['dump', '10', 'all']);
95+
assert.match(result.stdout, /\/tmp\/app\.log/);
96+
assert.match(result.stdout, /POST https:\/\/api\.example\.com\/v1\/login status=401/);
97+
assert.match(result.stdout, /headers:/);
98+
assert.match(result.stdout, /request:/);
99+
assert.match(result.stdout, /response:/);
100+
assert.match(result.stderr, /active=true/);
101+
assert.match(result.stderr, /include=all/);
102+
assert.match(result.stderr, /matchedLines=2/);
103+
assert.match(result.stderr, /best-effort parser/);
104+
});

src/cli.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,58 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
305305
return;
306306
}
307307
}
308+
if (command === 'network') {
309+
const data = response.data as Record<string, unknown> | undefined;
310+
const pathOut = typeof data?.path === 'string' ? data.path : '';
311+
if (pathOut) {
312+
process.stdout.write(`${pathOut}\n`);
313+
}
314+
const entries = Array.isArray(data?.entries) ? data.entries : [];
315+
if (entries.length === 0) {
316+
process.stdout.write('No recent HTTP(s) entries found.\n');
317+
} else {
318+
for (const entry of entries as Array<Record<string, unknown>>) {
319+
const method = typeof entry.method === 'string' ? entry.method : 'HTTP';
320+
const url = typeof entry.url === 'string' ? entry.url : '<unknown-url>';
321+
const status = typeof entry.status === 'number' ? ` status=${entry.status}` : '';
322+
const timestamp = typeof entry.timestamp === 'string' ? `${entry.timestamp} ` : '';
323+
process.stdout.write(`${timestamp}${method} ${url}${status}\n`);
324+
if (typeof entry.headers === 'string') {
325+
process.stdout.write(` headers: ${entry.headers}\n`);
326+
}
327+
if (typeof entry.requestBody === 'string') {
328+
process.stdout.write(` request: ${entry.requestBody}\n`);
329+
}
330+
if (typeof entry.responseBody === 'string') {
331+
process.stdout.write(` response: ${entry.responseBody}\n`);
332+
}
333+
}
334+
}
335+
const active = typeof data?.active === 'boolean' ? data.active : undefined;
336+
const state = typeof data?.state === 'string' ? data.state : undefined;
337+
const backend = typeof data?.backend === 'string' ? data.backend : undefined;
338+
const scannedLines = typeof data?.scannedLines === 'number' ? data.scannedLines : undefined;
339+
const matchedLines = typeof data?.matchedLines === 'number' ? data.matchedLines : undefined;
340+
const include = typeof data?.include === 'string' ? data.include : undefined;
341+
const meta = [
342+
active !== undefined ? `active=${active}` : '',
343+
state ? `state=${state}` : '',
344+
backend ? `backend=${backend}` : '',
345+
include ? `include=${include}` : '',
346+
scannedLines !== undefined ? `scannedLines=${scannedLines}` : '',
347+
matchedLines !== undefined ? `matchedLines=${matchedLines}` : '',
348+
].filter(Boolean).join(' ');
349+
if (meta) process.stderr.write(`${meta}\n`);
350+
if (Array.isArray(data?.notes)) {
351+
for (const note of data.notes) {
352+
if (typeof note === 'string' && note.length > 0) {
353+
process.stderr.write(`${note}\n`);
354+
}
355+
}
356+
}
357+
if (logTailStopper) logTailStopper();
358+
return;
359+
}
308360
if (command === 'click' || command === 'press') {
309361
const ref = (response.data as any)?.ref ?? '';
310362
const x = (response.data as any)?.x;

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
3131
is: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3232
home: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3333
logs: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
34+
network: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3435
longpress: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3536
open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3637
perf: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { readRecentNetworkTraffic } from '../network-log.ts';
7+
8+
test('readRecentNetworkTraffic parses latest HTTP entries from session log', () => {
9+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-network-log-'));
10+
const logPath = path.join(tempDir, 'app.log');
11+
fs.writeFileSync(
12+
logPath,
13+
[
14+
'2026-02-24T10:00:00Z GET https://api.example.com/v1/profile status=200',
15+
'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"}}',
16+
'non-network-line',
17+
].join('\n'),
18+
'utf8',
19+
);
20+
21+
const dump = readRecentNetworkTraffic(logPath, {
22+
maxEntries: 5,
23+
include: 'all',
24+
maxPayloadChars: 2048,
25+
maxScanLines: 100,
26+
});
27+
28+
assert.equal(dump.exists, true);
29+
assert.equal(dump.entries.length, 2);
30+
assert.equal(dump.entries[0]?.method, 'POST');
31+
assert.equal(dump.entries[0]?.url, 'https://api.example.com/v1/login');
32+
assert.equal(dump.entries[0]?.status, 401);
33+
assert.equal(typeof dump.entries[0]?.headers, 'string');
34+
assert.equal(typeof dump.entries[0]?.requestBody, 'string');
35+
assert.equal(typeof dump.entries[0]?.responseBody, 'string');
36+
assert.equal(dump.entries[1]?.method, 'GET');
37+
assert.equal(dump.entries[1]?.status, 200);
38+
});
39+
40+
test('readRecentNetworkTraffic returns empty result when log file is missing', () => {
41+
const logPath = path.join(os.tmpdir(), 'agent-device-network-log-missing', 'app.log');
42+
const dump = readRecentNetworkTraffic(logPath, { maxEntries: 10, include: 'summary' });
43+
assert.equal(dump.exists, false);
44+
assert.equal(dump.entries.length, 0);
45+
});

0 commit comments

Comments
 (0)