mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat: add bsk network read-only command
Add `bsk network` as a sibling of the `console` command from #7, reading the buffered network responses / failures for a tab. Fills the remaining half of the request in #2 (console landed in #7; network did not). Mirrors the console command's conventions exactly: bsk network --session <id> [--tab-id N] [--since C] [--limit N] [--max-text-chars N] The extension enables `Network` on attach (best-effort) and buffers `responseReceived` / `loadingFailed` events per tab with a monotonic sequence, correlating them to `requestWillBeSent` for method/URL. Reads are cursor-paginated (`since` -> `next_since`) and bounded (`limit`, `max_text_chars`) for agent-context safety, same as console. Entries carry the real HTTP status code (response) or the CDP failure reason (failure) — things `performance.getEntriesByType('resource')` can't give. Classified read-only (pass-through under the pending-interrupt gate); defaults to the Agent Window's active tab. Wires bsk-protocol (Method + params/result/entry structs + schema), bsk-cli (network subcommand + daemon dispatch allow-list) and the extension (handler + dispatcher route), with unit / parse / IPC tests paralleling the console command. Refs #2.
This commit is contained in:
@@ -27,6 +27,9 @@ import type {
|
||||
ConsoleStackFrame,
|
||||
JavaScriptDialogInfo,
|
||||
JavaScriptDialogType,
|
||||
NetworkEntry,
|
||||
NetworkEntryKind,
|
||||
NetworkResult,
|
||||
} from "@/transport/types";
|
||||
|
||||
/**
|
||||
@@ -87,6 +90,9 @@ const MAX_DIALOG_FIELD_LENGTH = 4096;
|
||||
const MAX_CONSOLE_BUFFER = 200;
|
||||
const MAX_CONSOLE_FIELD_LENGTH = 4096;
|
||||
const MAX_CONSOLE_STACK_FRAMES = 20;
|
||||
const MAX_NETWORK_BUFFER = 200;
|
||||
const MAX_NETWORK_FIELD_LENGTH = 4096;
|
||||
const MAX_NETWORK_REQUEST_META = 1024;
|
||||
|
||||
interface ParsedDialogOpening {
|
||||
type: JavaScriptDialogType;
|
||||
@@ -98,6 +104,15 @@ interface ParsedDialogOpening {
|
||||
|
||||
interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
|
||||
|
||||
interface ParsedNetworkEntry extends Omit<NetworkEntry, "sequence"> {}
|
||||
|
||||
/** Request metadata remembered from `requestWillBeSent`, keyed by requestId. */
|
||||
interface NetworkRequestMeta {
|
||||
url: string;
|
||||
method?: string;
|
||||
resourceType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around `chrome.debugger` that owns the "attach once per
|
||||
* tabId" cache and exposes typed `send<T>()`.
|
||||
@@ -112,15 +127,21 @@ export class ChromiumCdp {
|
||||
private readonly consoleBuffers = new Map<number, ConsoleEntry[]>();
|
||||
private readonly consoleSequences = new Map<number, number>();
|
||||
private readonly consoleDomainsAttemptedTabs = new Set<number>();
|
||||
private readonly networkBuffers = new Map<number, NetworkEntry[]>();
|
||||
private readonly networkSequences = new Map<number, number>();
|
||||
private readonly networkDomainsAttemptedTabs = new Set<number>();
|
||||
private readonly networkRequestMeta = new Map<string, NetworkRequestMeta>();
|
||||
private detachSubscription: { dispose(): void } | null = null;
|
||||
private dialogSubscription: { dispose(): void } | null = null;
|
||||
private consoleSubscription: { dispose(): void } | null = null;
|
||||
private networkSubscription: { dispose(): void } | null = null;
|
||||
|
||||
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
|
||||
this.api = api;
|
||||
this.bindAutoDetach();
|
||||
this.bindDialogHandler();
|
||||
this.bindConsoleHandler();
|
||||
this.bindNetworkHandler();
|
||||
}
|
||||
|
||||
/** Attach to `tabId` if we haven't already in this driver. */
|
||||
@@ -135,6 +156,7 @@ export class ChromiumCdp {
|
||||
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
|
||||
await this.enablePageDomain(tabId);
|
||||
await this.enableConsoleDomains(tabId);
|
||||
await this.enableNetworkDomains(tabId);
|
||||
this.attachedTabs.add(tabId);
|
||||
})()
|
||||
.catch((err) => {
|
||||
@@ -216,6 +238,44 @@ export class ChromiumCdp {
|
||||
};
|
||||
}
|
||||
|
||||
/** Ensure CDP domains for network capture are enabled for this tab. */
|
||||
async ensureNetworkCapture(tabId: number): Promise<void> {
|
||||
if (!this.attachedTabs.has(tabId)) {
|
||||
await this.ensureAttached(tabId);
|
||||
return;
|
||||
}
|
||||
await this.enableNetworkDomains(tabId);
|
||||
}
|
||||
|
||||
/** Network entries observed on `tabId`, bounded for agent context safety. */
|
||||
networkEntriesSince(
|
||||
tabId: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
maxTextChars: number,
|
||||
): NetworkResult {
|
||||
const buf = this.networkBuffers.get(tabId) ?? [];
|
||||
const hasCursor = typeof since === "number";
|
||||
const candidates = hasCursor
|
||||
? buf.filter((entry) => entry.sequence > since)
|
||||
: buf.slice(Math.max(0, buf.length - limit));
|
||||
const limited = hasCursor ? candidates.slice(0, limit) : candidates;
|
||||
const entries = limited.map((entry) => projectNetworkEntry(entry, maxTextChars));
|
||||
const lastReturned = entries.at(-1)?.sequence;
|
||||
const nextSince = lastReturned ?? this.networkSequences.get(tabId) ?? 0;
|
||||
const droppedEntries = (this.networkSequences.get(tabId) ?? 0) > buf.length;
|
||||
const truncated =
|
||||
droppedEntries ||
|
||||
candidates.length > limited.length ||
|
||||
entries.some((entry) => entry.truncated);
|
||||
return {
|
||||
tab_id: tabId,
|
||||
entries,
|
||||
next_since: nextSince,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/** Detach if attached; never throws. */
|
||||
async detach(tabId: number): Promise<void> {
|
||||
this.attachInFlight.delete(tabId);
|
||||
@@ -223,6 +283,7 @@ export class ChromiumCdp {
|
||||
this.attachedTabs.delete(tabId);
|
||||
this.clearDialogState(tabId);
|
||||
this.clearConsoleState(tabId);
|
||||
this.clearNetworkState(tabId);
|
||||
try {
|
||||
await this.api.detach({ tabId });
|
||||
} catch (err) {
|
||||
@@ -265,6 +326,10 @@ export class ChromiumCdp {
|
||||
this.consoleBuffers.clear();
|
||||
this.consoleSequences.clear();
|
||||
this.consoleDomainsAttemptedTabs.clear();
|
||||
this.networkBuffers.clear();
|
||||
this.networkSequences.clear();
|
||||
this.networkDomainsAttemptedTabs.clear();
|
||||
this.networkRequestMeta.clear();
|
||||
await Promise.all(
|
||||
tabs.map(async (tabId) => {
|
||||
try {
|
||||
@@ -292,6 +357,16 @@ export class ChromiumCdp {
|
||||
}
|
||||
}
|
||||
|
||||
private async enableNetworkDomains(tabId: number): Promise<void> {
|
||||
if (this.networkDomainsAttemptedTabs.has(tabId)) return;
|
||||
this.networkDomainsAttemptedTabs.add(tabId);
|
||||
try {
|
||||
await this.api.sendCommand({ tabId }, "Network.enable", {});
|
||||
} catch (err) {
|
||||
console.debug("[bsk cdp] network domain enable failed", { tabId, err });
|
||||
}
|
||||
}
|
||||
|
||||
private bindDialogHandler(): void {
|
||||
if (this.dialogSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
|
||||
@@ -386,6 +461,65 @@ export class ChromiumCdp {
|
||||
this.consoleDomainsAttemptedTabs.delete(tabId);
|
||||
}
|
||||
|
||||
private bindNetworkHandler(): void {
|
||||
if (this.networkSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
|
||||
const tabId = source.tabId;
|
||||
if (typeof tabId !== "number") return;
|
||||
switch (method) {
|
||||
case "Network.requestWillBeSent":
|
||||
this.rememberNetworkRequest(params);
|
||||
break;
|
||||
case "Network.responseReceived":
|
||||
this.appendNetwork(tabId, parseNetworkResponse(this.networkRequestMeta, params));
|
||||
break;
|
||||
case "Network.loadingFailed":
|
||||
this.appendNetwork(tabId, parseNetworkFailure(this.networkRequestMeta, params));
|
||||
break;
|
||||
}
|
||||
};
|
||||
this.api.onEvent.addListener(listener);
|
||||
this.networkSubscription = {
|
||||
dispose: () => this.api.onEvent.removeListener(listener),
|
||||
};
|
||||
}
|
||||
|
||||
private appendNetwork(tabId: number, entry: ParsedNetworkEntry | null): void {
|
||||
if (!entry) return;
|
||||
const sequence = (this.networkSequences.get(tabId) ?? 0) + 1;
|
||||
this.networkSequences.set(tabId, sequence);
|
||||
const buf = this.networkBuffers.get(tabId) ?? [];
|
||||
buf.push({ ...entry, sequence });
|
||||
while (buf.length > MAX_NETWORK_BUFFER) {
|
||||
buf.shift();
|
||||
}
|
||||
this.networkBuffers.set(tabId, buf);
|
||||
}
|
||||
|
||||
/** Remember `requestWillBeSent` metadata so responses/failures can be attributed. */
|
||||
private rememberNetworkRequest(params: unknown): void {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
|
||||
const request = (raw.request ?? {}) as Record<string, unknown>;
|
||||
if (!id || typeof request.url !== "string") return;
|
||||
this.networkRequestMeta.set(id, {
|
||||
url: request.url,
|
||||
method: typeof request.method === "string" ? request.method : undefined,
|
||||
resourceType: typeof raw.type === "string" ? raw.type : undefined,
|
||||
});
|
||||
while (this.networkRequestMeta.size > MAX_NETWORK_REQUEST_META) {
|
||||
const oldest = this.networkRequestMeta.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.networkRequestMeta.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private clearNetworkState(tabId: number): void {
|
||||
this.networkBuffers.delete(tabId);
|
||||
this.networkSequences.delete(tabId);
|
||||
this.networkDomainsAttemptedTabs.delete(tabId);
|
||||
}
|
||||
|
||||
private bindAutoDetach(): void {
|
||||
if (this.detachSubscription) return;
|
||||
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
|
||||
@@ -395,6 +529,7 @@ export class ChromiumCdp {
|
||||
this.tabOwners.delete(source.tabId);
|
||||
this.clearDialogState(source.tabId);
|
||||
this.clearConsoleState(source.tabId);
|
||||
this.clearNetworkState(source.tabId);
|
||||
}
|
||||
};
|
||||
this.api.onDetach.addListener(listener);
|
||||
@@ -411,6 +546,8 @@ export class ChromiumCdp {
|
||||
this.dialogSubscription = null;
|
||||
this.consoleSubscription?.dispose();
|
||||
this.consoleSubscription = null;
|
||||
this.networkSubscription?.dispose();
|
||||
this.networkSubscription = null;
|
||||
}
|
||||
|
||||
/** Detach tabs only when no other live session has claimed them. */
|
||||
@@ -604,6 +741,109 @@ function truncateOptionalConsoleField(value: string | undefined): string | undef
|
||||
return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text;
|
||||
}
|
||||
|
||||
function parseNetworkResponse(
|
||||
meta: Map<string, NetworkRequestMeta>,
|
||||
params: unknown,
|
||||
): ParsedNetworkEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
|
||||
const response = (raw.response ?? {}) as Record<string, unknown>;
|
||||
const info = id ? meta.get(id) : undefined;
|
||||
const url = typeof response.url === "string" ? response.url : info?.url;
|
||||
if (!url) return null;
|
||||
return makeNetworkEntry({
|
||||
kind: "response",
|
||||
method: info?.method,
|
||||
url,
|
||||
status: typeof response.status === "number" ? response.status : undefined,
|
||||
status_text: typeof response.statusText === "string" ? response.statusText : undefined,
|
||||
mime_type: typeof response.mimeType === "string" ? response.mimeType : undefined,
|
||||
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function parseNetworkFailure(
|
||||
meta: Map<string, NetworkRequestMeta>,
|
||||
params: unknown,
|
||||
): ParsedNetworkEntry | null {
|
||||
const raw = (params ?? {}) as Record<string, unknown>;
|
||||
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
|
||||
const info = id ? meta.get(id) : undefined;
|
||||
return makeNetworkEntry({
|
||||
kind: "failure",
|
||||
method: info?.method,
|
||||
url: info?.url ?? "(unknown)",
|
||||
error_text: typeof raw.errorText === "string" ? raw.errorText : undefined,
|
||||
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
|
||||
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function makeNetworkEntry(input: {
|
||||
kind: NetworkEntryKind;
|
||||
method?: string;
|
||||
url: string;
|
||||
status?: number;
|
||||
status_text?: string;
|
||||
mime_type?: string;
|
||||
resource_type?: string;
|
||||
error_text?: string;
|
||||
timestamp?: number;
|
||||
}): ParsedNetworkEntry {
|
||||
const projectedUrl = truncateNetworkText(input.url, MAX_NETWORK_FIELD_LENGTH);
|
||||
const projectedError =
|
||||
input.error_text !== undefined
|
||||
? truncateNetworkText(input.error_text, MAX_NETWORK_FIELD_LENGTH)
|
||||
: undefined;
|
||||
return {
|
||||
kind: input.kind,
|
||||
method: input.method,
|
||||
url: projectedUrl.text,
|
||||
status: input.status,
|
||||
status_text: truncateOptionalNetworkField(input.status_text),
|
||||
mime_type: truncateOptionalNetworkField(input.mime_type),
|
||||
resource_type: input.resource_type,
|
||||
error_text: projectedError?.text,
|
||||
timestamp: input.timestamp,
|
||||
truncated: projectedUrl.truncated || (projectedError?.truncated ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function projectNetworkEntry(entry: NetworkEntry, maxTextChars: number): NetworkEntry {
|
||||
const projectedUrl = truncateNetworkText(entry.url, maxTextChars);
|
||||
const projectedError =
|
||||
entry.error_text !== undefined
|
||||
? truncateNetworkText(entry.error_text, maxTextChars)
|
||||
: undefined;
|
||||
return {
|
||||
sequence: entry.sequence,
|
||||
kind: entry.kind,
|
||||
method: entry.method,
|
||||
url: projectedUrl.text,
|
||||
status: entry.status,
|
||||
status_text: entry.status_text,
|
||||
mime_type: entry.mime_type,
|
||||
resource_type: entry.resource_type,
|
||||
error_text: projectedError?.text,
|
||||
timestamp: entry.timestamp,
|
||||
truncated: entry.truncated || projectedUrl.truncated || (projectedError?.truncated ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function truncateNetworkText(
|
||||
value: string,
|
||||
maxChars: number,
|
||||
): { text: string; truncated: boolean } {
|
||||
if (value.length <= maxChars) return { text: value, truncated: false };
|
||||
return { text: value.slice(0, maxChars), truncated: true };
|
||||
}
|
||||
|
||||
function truncateOptionalNetworkField(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return truncateNetworkText(value, MAX_NETWORK_FIELD_LENGTH).text;
|
||||
}
|
||||
|
||||
function normalizeError(err: unknown): Error {
|
||||
if (err instanceof Error) return err;
|
||||
if (typeof err === "string") return new Error(err);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { CdpRunner } from "@/tools/shared";
|
||||
import type { NetworkResult } from "@/transport/types";
|
||||
import { handleNetwork } from "../network";
|
||||
|
||||
function fakeAgentWindow(ids: number[]) {
|
||||
let i = 0;
|
||||
return {
|
||||
create: vi.fn(async () => {
|
||||
const id = ids[i++];
|
||||
if (id === undefined) throw new Error("ran out of fake ids");
|
||||
return id;
|
||||
}),
|
||||
remove: vi.fn(async () => {}),
|
||||
ensureActiveTab: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
opts: {
|
||||
get?: (tabId: number) => Promise<chrome.tabs.Tab>;
|
||||
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
|
||||
result?: NetworkResult;
|
||||
} = {},
|
||||
) {
|
||||
const result =
|
||||
opts.result ??
|
||||
({
|
||||
tab_id: 7,
|
||||
entries: [],
|
||||
next_since: 0,
|
||||
truncated: false,
|
||||
} satisfies NetworkResult);
|
||||
const ensureNetworkCapture = vi.fn(async () => {});
|
||||
const networkEntriesSince = vi.fn(() => result);
|
||||
const cdp = {
|
||||
send: vi.fn(),
|
||||
ensureNetworkCapture,
|
||||
networkEntriesSince,
|
||||
} as unknown as CdpRunner;
|
||||
const tabsApi = {
|
||||
get:
|
||||
opts.get ??
|
||||
vi.fn(
|
||||
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
|
||||
),
|
||||
query:
|
||||
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]),
|
||||
};
|
||||
return { cdp, tabsApi, ensureNetworkCapture, networkEntriesSince };
|
||||
}
|
||||
|
||||
describe("handleNetwork", () => {
|
||||
it("reads the Agent Window active tab with safe defaults", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({
|
||||
result: {
|
||||
tab_id: 7,
|
||||
entries: [
|
||||
{ sequence: 3, kind: "response", url: "https://x/api", status: 404, truncated: false },
|
||||
],
|
||||
next_since: 3,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await handleNetwork(sm, { session_id: "aa11" }, deps);
|
||||
|
||||
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
|
||||
expect(res.entries[0].status).toBe(404);
|
||||
expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(7);
|
||||
expect(deps.networkEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000);
|
||||
});
|
||||
|
||||
it("reads an explicit tab and forwards bounded options", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps({
|
||||
get: vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab),
|
||||
});
|
||||
|
||||
await handleNetwork(
|
||||
sm,
|
||||
{
|
||||
session_id: "aa11",
|
||||
tab_id: 9,
|
||||
since: 12,
|
||||
limit: 500,
|
||||
max_text_chars: 9999,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(9);
|
||||
expect(deps.networkEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096);
|
||||
});
|
||||
|
||||
it("rejects invalid bounds before touching CDP", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
|
||||
await sm.start("aa11");
|
||||
const deps = makeDeps();
|
||||
|
||||
const res = await handleNetwork(sm, { session_id: "aa11", limit: 0 }, deps);
|
||||
|
||||
expect(res).toMatchObject({ code: "invalid_params", message: /limit/ });
|
||||
expect(deps.ensureNetworkCapture).not.toHaveBeenCalled();
|
||||
expect(deps.networkEntriesSince).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides other sessions' Agent Window tabs", async () => {
|
||||
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) });
|
||||
await sm.start("aa11");
|
||||
await sm.start("bb22");
|
||||
const deps = makeDeps({
|
||||
get: vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab),
|
||||
});
|
||||
|
||||
const res = await handleNetwork(sm, { session_id: "aa11", tab_id: 9 }, deps);
|
||||
|
||||
expect(res).toMatchObject({ code: "not_found" });
|
||||
expect(deps.ensureNetworkCapture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
NavigateBackParams,
|
||||
NavigateForwardParams,
|
||||
NavigateParams,
|
||||
NetworkParams,
|
||||
PressParams,
|
||||
ProtocolFrame,
|
||||
ReloadParams,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
handleNavigateForward,
|
||||
handleReload,
|
||||
} from "./navigation";
|
||||
import { handleNetwork } from "./network";
|
||||
import {
|
||||
type CdpRunner,
|
||||
chromeTabsCaptureApi,
|
||||
@@ -285,6 +287,12 @@ export class ToolDispatcher {
|
||||
req.params as ConsoleParams,
|
||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
||||
);
|
||||
case "tool.network":
|
||||
return handleNetwork(
|
||||
this.sessions,
|
||||
req.params as NetworkParams,
|
||||
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
|
||||
);
|
||||
case "tool.snapshot":
|
||||
return handleSnapshot(
|
||||
this.sessions,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
|
||||
import type { SessionManager } from "@/session-manager/manager";
|
||||
import type { NetworkParams, NetworkResult, RpcError } from "@/transport/types";
|
||||
import {
|
||||
type CdpRunner,
|
||||
type ChromeTabsApi,
|
||||
chromeTabsApi,
|
||||
isRpcError,
|
||||
lookupSession,
|
||||
resolveTargetTab,
|
||||
} from "./shared";
|
||||
|
||||
const DEFAULT_NETWORK_LIMIT = 50;
|
||||
const MAX_NETWORK_LIMIT = 200;
|
||||
const DEFAULT_MAX_TEXT_CHARS = 1000;
|
||||
const MAX_TEXT_CHARS = 4096;
|
||||
|
||||
export interface NetworkDeps {
|
||||
cdp: CdpRunner;
|
||||
tabsApi: ChromeTabsApi;
|
||||
}
|
||||
|
||||
function defaultNetworkDeps(): NetworkDeps {
|
||||
return {
|
||||
cdp: new ChromiumCdp(),
|
||||
tabsApi: chromeTabsApi,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleNetwork(
|
||||
manager: SessionManager,
|
||||
params: NetworkParams,
|
||||
deps: NetworkDeps = defaultNetworkDeps(),
|
||||
): Promise<NetworkResult | RpcError> {
|
||||
const ctxOrErr = lookupSession(manager, params, "network");
|
||||
if (isRpcError(ctxOrErr)) return ctxOrErr;
|
||||
const bounds = parseNetworkBounds(params);
|
||||
if (isRpcError(bounds)) return bounds;
|
||||
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
|
||||
if (isRpcError(target)) return target;
|
||||
if (!deps.cdp.ensureNetworkCapture || !deps.cdp.networkEntriesSince) {
|
||||
return { code: "cdp_failed", message: "network capture requires CDP network support" };
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.cdp.ensureNetworkCapture(target.tabId);
|
||||
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
|
||||
return deps.cdp.networkEntriesSince(
|
||||
target.tabId,
|
||||
bounds.since,
|
||||
bounds.limit,
|
||||
bounds.maxTextChars,
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
code: "cdp_failed",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseNetworkBounds(params: NetworkParams):
|
||||
| {
|
||||
since: number | undefined;
|
||||
limit: number;
|
||||
maxTextChars: number;
|
||||
}
|
||||
| RpcError {
|
||||
const since = params.since;
|
||||
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
|
||||
return { code: "invalid_params", message: "since must be a non-negative integer" };
|
||||
}
|
||||
const limit = boundedOptionalInteger(
|
||||
params.limit,
|
||||
DEFAULT_NETWORK_LIMIT,
|
||||
MAX_NETWORK_LIMIT,
|
||||
"limit",
|
||||
);
|
||||
if (isRpcError(limit)) return limit;
|
||||
const maxTextChars = boundedOptionalInteger(
|
||||
params.max_text_chars,
|
||||
DEFAULT_MAX_TEXT_CHARS,
|
||||
MAX_TEXT_CHARS,
|
||||
"max_text_chars",
|
||||
);
|
||||
if (isRpcError(maxTextChars)) return maxTextChars;
|
||||
return {
|
||||
since,
|
||||
limit,
|
||||
maxTextChars,
|
||||
};
|
||||
}
|
||||
|
||||
function boundedOptionalInteger(
|
||||
value: number | undefined,
|
||||
defaultValue: number,
|
||||
maxValue: number,
|
||||
field: string,
|
||||
): number | RpcError {
|
||||
if (value === undefined) return defaultValue;
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
return { code: "invalid_params", message: `${field} must be a positive integer` };
|
||||
}
|
||||
return Math.min(value, maxValue);
|
||||
}
|
||||
@@ -9,7 +9,12 @@
|
||||
import type { DialogCursor } from "@/browser-driver/chromium-cdp";
|
||||
import type { SessionContext, SessionManager } from "@/session-manager/manager";
|
||||
import { normaliseRef } from "@/session-manager/ref-store";
|
||||
import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
|
||||
import type {
|
||||
ConsoleResult,
|
||||
JavaScriptDialogInfo,
|
||||
NetworkResult,
|
||||
RpcError,
|
||||
} from "@/transport/types";
|
||||
import { rpcError } from "./errors";
|
||||
|
||||
/**
|
||||
@@ -53,6 +58,13 @@ export interface CdpRunner {
|
||||
maxTextChars: number,
|
||||
includeStack: boolean,
|
||||
): ConsoleResult;
|
||||
ensureNetworkCapture?(tabId: number): Promise<void>;
|
||||
networkEntriesSince?(
|
||||
tabId: number,
|
||||
since: number | undefined,
|
||||
limit: number,
|
||||
maxTextChars: number,
|
||||
): NetworkResult;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,6 +172,37 @@ export interface ConsoleResult {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type NetworkEntryKind = "response" | "failure";
|
||||
|
||||
export interface NetworkEntry {
|
||||
sequence: number;
|
||||
kind: NetworkEntryKind;
|
||||
method?: string;
|
||||
url: string;
|
||||
status?: number;
|
||||
status_text?: string;
|
||||
mime_type?: string;
|
||||
resource_type?: string;
|
||||
error_text?: string;
|
||||
timestamp?: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkParams {
|
||||
session_id: string;
|
||||
tab_id?: number;
|
||||
since?: number;
|
||||
limit?: number;
|
||||
max_text_chars?: number;
|
||||
}
|
||||
|
||||
export interface NetworkResult {
|
||||
tab_id: number;
|
||||
entries: NetworkEntry[];
|
||||
next_since: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type TabScopeFilter = "user" | "agent" | "all";
|
||||
|
||||
export interface TabInfo {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod install_skill;
|
||||
pub mod interaction;
|
||||
pub mod logs;
|
||||
pub mod navigate;
|
||||
pub mod network;
|
||||
pub mod render_error;
|
||||
pub mod screenshot;
|
||||
pub mod session;
|
||||
@@ -37,6 +38,7 @@ use crate::cli::human_loop::RequestHelpArgs;
|
||||
use crate::cli::install_skill::InstallSkillArgs;
|
||||
use crate::cli::interaction::{ClickArgs, FillArgs, PressArgs, SelectArgs};
|
||||
use crate::cli::navigate::{NavigateCommand, NavigateHistoryArgs, ReloadArgs};
|
||||
use crate::cli::network::NetworkArgs;
|
||||
use crate::cli::screenshot::ScreenshotArgs;
|
||||
use crate::cli::session::SessionCmd;
|
||||
use crate::cli::snapshot::SnapshotArgs;
|
||||
@@ -120,6 +122,9 @@ pub enum Command {
|
||||
/// Read buffered console/log/exception messages.
|
||||
Console(ConsoleArgs),
|
||||
|
||||
/// Read buffered network responses / failures.
|
||||
Network(NetworkArgs),
|
||||
|
||||
/// Dump raw HTML for a tab or a snapshot ref.
|
||||
#[command(name = "get-html")]
|
||||
GetHtml(GetHtmlArgs),
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! `bsk network` — read buffered page network responses / failures.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use bsk_protocol::Method;
|
||||
use bsk_protocol::tools::{NetworkEntry, NetworkParams, NetworkResult};
|
||||
use clap::Args;
|
||||
|
||||
use crate::cli::TOOL_IPC_TIMEOUT;
|
||||
use crate::cli::ensure_daemon::ensure_daemon;
|
||||
use crate::cli::error::{CliError, Format};
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct NetworkArgs {
|
||||
/// Session id (must be active).
|
||||
#[arg(long)]
|
||||
pub session: String,
|
||||
|
||||
/// Target tab. Defaults to the Agent Window's active tab.
|
||||
#[arg(long = "tab-id")]
|
||||
pub tab_id: Option<i64>,
|
||||
|
||||
/// Return entries with sequence greater than this cursor.
|
||||
#[arg(long)]
|
||||
pub since: Option<u64>,
|
||||
|
||||
/// Maximum number of entries to return. Defaults to 50; extension caps at 200.
|
||||
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
pub limit: Option<u32>,
|
||||
|
||||
/// Maximum characters per URL / error text. Defaults to 1000; extension caps at 4096.
|
||||
#[arg(long = "max-text-chars", value_parser = clap::value_parser!(u32).range(1..))]
|
||||
pub max_text_chars: Option<u32>,
|
||||
}
|
||||
|
||||
pub fn dispatch(args: NetworkArgs, format: Format) -> Result<(), CliError> {
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
run(info.sock_path, args, format)
|
||||
}
|
||||
|
||||
fn run(sock: PathBuf, args: NetworkArgs, format: Format) -> Result<(), CliError> {
|
||||
let params = NetworkParams {
|
||||
session_id: args.session,
|
||||
tab_id: args.tab_id,
|
||||
since: args.since,
|
||||
limit: args.limit,
|
||||
max_text_chars: args.max_text_chars,
|
||||
};
|
||||
let reply: NetworkResult = call(sock, params)?;
|
||||
render(&reply, format)
|
||||
}
|
||||
|
||||
fn call(sock: PathBuf, params: NetworkParams) -> Result<NetworkResult, CliError> {
|
||||
crate::cli::business_rpc::call::<NetworkParams, NetworkResult>(
|
||||
sock,
|
||||
"network",
|
||||
Method::ToolNetwork,
|
||||
Some(params),
|
||||
TOOL_IPC_TIMEOUT,
|
||||
)
|
||||
}
|
||||
|
||||
fn render(reply: &NetworkResult, format: Format) -> Result<(), CliError> {
|
||||
match format {
|
||||
Format::Json => {
|
||||
let json = serde_json::to_string_pretty(reply)
|
||||
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?;
|
||||
println!("{json}");
|
||||
}
|
||||
Format::Human => {
|
||||
if reply.entries.is_empty() {
|
||||
println!("(no network activity captured)");
|
||||
} else {
|
||||
for entry in &reply.entries {
|
||||
println!("{}", render_entry(entry));
|
||||
}
|
||||
}
|
||||
if reply.truncated {
|
||||
eprintln!(
|
||||
"warning: network output truncated (next_since={}). Use --since / --limit / --max-text-chars to request a different slice.",
|
||||
reply.next_since
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_entry(entry: &NetworkEntry) -> String {
|
||||
let method = entry.method.as_deref().unwrap_or("?");
|
||||
match entry.kind {
|
||||
bsk_protocol::tools::NetworkEntryKind::Failure => {
|
||||
let err = entry.error_text.as_deref().unwrap_or("failed");
|
||||
format!("#{} FAILED {method} {} — {err}", entry.sequence, entry.url)
|
||||
}
|
||||
bsk_protocol::tools::NetworkEntryKind::Response => {
|
||||
let status = entry
|
||||
.status
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
format!("#{} {status} {method} {}", entry.sequence, entry.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
|
||||
| Method::ToolTabReturn
|
||||
| Method::ToolScreenshot
|
||||
| Method::ToolConsole
|
||||
| Method::ToolNetwork
|
||||
| Method::ToolSnapshot
|
||||
| Method::ToolGetHtml
|
||||
| Method::ToolNavigate
|
||||
|
||||
@@ -83,6 +83,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
|
||||
Command::Screenshot(args) => cli::screenshot::dispatch(args, format),
|
||||
Command::Snapshot(args) => cli::snapshot::dispatch(args, format),
|
||||
Command::Console(args) => cli::console::dispatch(args, format),
|
||||
Command::Network(args) => cli::network::dispatch(args, format),
|
||||
Command::GetHtml(args) => cli::get_html::dispatch(args, format),
|
||||
Command::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format),
|
||||
Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format),
|
||||
|
||||
@@ -104,6 +104,41 @@ fn rejects_zero_console_bounds() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_network_command_with_context_safety_flags() {
|
||||
let cli = parse(&[
|
||||
"bsk",
|
||||
"network",
|
||||
"--session",
|
||||
"s1",
|
||||
"--tab-id",
|
||||
"9",
|
||||
"--since",
|
||||
"12",
|
||||
"--limit",
|
||||
"75",
|
||||
"--max-text-chars",
|
||||
"2048",
|
||||
]);
|
||||
let Command::Network(args) = cli.command else {
|
||||
panic!("expected network command");
|
||||
};
|
||||
assert_eq!(args.session, "s1");
|
||||
assert_eq!(args.tab_id, Some(9));
|
||||
assert_eq!(args.since, Some(12));
|
||||
assert_eq!(args.limit, Some(75));
|
||||
assert_eq!(args.max_text_chars, Some(2048));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_network_bounds() {
|
||||
assert!(Cli::try_parse_from(["bsk", "network", "--session", "s1", "--limit", "0"]).is_err());
|
||||
assert!(
|
||||
Cli::try_parse_from(["bsk", "network", "--session", "s1", "--max-text-chars", "0"])
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_install_skill_subcommand() {
|
||||
let cli = parse(&["bsk", "install-skill", "--list"]);
|
||||
|
||||
@@ -11,8 +11,9 @@ use bsk::ipc_client::IpcClient;
|
||||
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
|
||||
use bsk_protocol::tools::{
|
||||
ConsoleEntry, ConsoleEntryKind, ConsoleParams, ConsoleResult, GetHtmlParams, GetHtmlResult,
|
||||
ScreenshotParams, ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams,
|
||||
SnapshotResult, TabInfo, TabListParams, TabListResult, TabScope,
|
||||
NetworkEntry, NetworkEntryKind, NetworkParams, NetworkResult, ScreenshotParams,
|
||||
ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams, SnapshotResult,
|
||||
TabInfo, TabListParams, TabListResult, TabScope,
|
||||
};
|
||||
use bsk_protocol::{
|
||||
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
|
||||
@@ -365,6 +366,62 @@ async fn console_returns_buffered_entries() {
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn network_returns_buffered_entries() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
let mut ws = connect_ext(handle.ws_addr()).await;
|
||||
let _ = do_handshake(&mut ws).await;
|
||||
|
||||
run_extension(ws, |req| {
|
||||
assert_eq!(req.method, Method::ToolNetwork);
|
||||
let params: NetworkParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
|
||||
assert_eq!(params.tab_id, Some(7));
|
||||
assert_eq!(params.since, Some(3));
|
||||
assert_eq!(params.limit, Some(50));
|
||||
assert_eq!(params.max_text_chars, Some(1000));
|
||||
ResponseBody::Ok(
|
||||
serde_json::to_value(NetworkResult {
|
||||
tab_id: 7,
|
||||
entries: vec![NetworkEntry {
|
||||
sequence: 4,
|
||||
kind: NetworkEntryKind::Response,
|
||||
method: Some("GET".into()),
|
||||
url: "https://example.test/api".into(),
|
||||
status: Some(404),
|
||||
status_text: Some("Not Found".into()),
|
||||
mime_type: Some("application/json".into()),
|
||||
resource_type: Some("Fetch".into()),
|
||||
error_text: None,
|
||||
timestamp: None,
|
||||
truncated: false,
|
||||
}],
|
||||
next_since: 4,
|
||||
truncated: false,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
|
||||
let session_id = ipc_session_start(&sock).await;
|
||||
let result: NetworkResult = ipc_tool_call(
|
||||
&sock,
|
||||
Method::ToolNetwork,
|
||||
NetworkParams {
|
||||
session_id,
|
||||
tab_id: Some(7),
|
||||
since: Some(3),
|
||||
limit: Some(50),
|
||||
max_text_chars: Some(1000),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("network ok");
|
||||
assert_eq!(result.tab_id, 7);
|
||||
assert_eq!(result.next_since, 4);
|
||||
assert_eq!(result.entries[0].status, Some(404));
|
||||
handle.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn snapshot_returns_text_and_ref_count() {
|
||||
let (handle, sock) = spawn_daemon().await;
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "NetworkEntry",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"sequence",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"error_text": {
|
||||
"description": "CDP failure reason (`failure` entries only).",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/definitions/NetworkEntryKind"
|
||||
},
|
||||
"method": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"mime_type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"resource_type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"status": {
|
||||
"description": "HTTP status code (`response` entries only).",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"status_text": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timestamp": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"truncated": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"NetworkEntryKind": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "A response was received (`Network.responseReceived`).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"response"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The request failed before completing (`Network.loadingFailed`).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"failure"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "NetworkParams",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"session_id"
|
||||
],
|
||||
"properties": {
|
||||
"limit": {
|
||||
"description": "Maximum number of entries to return. Extension applies safe defaults and caps.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"max_text_chars": {
|
||||
"description": "Maximum characters returned per URL / error text. Extension applies safe defaults and caps.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"since": {
|
||||
"description": "Return entries with sequence strictly greater than this cursor.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint64",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"tab_id": {
|
||||
"description": "Optional target tab. Defaults to the Agent Window's currently active tab.",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "NetworkResult",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"next_since",
|
||||
"tab_id"
|
||||
],
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NetworkEntry"
|
||||
}
|
||||
},
|
||||
"next_since": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"tab_id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"truncated": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"NetworkEntry": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kind",
|
||||
"sequence",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"error_text": {
|
||||
"description": "CDP failure reason (`failure` entries only).",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"kind": {
|
||||
"$ref": "#/definitions/NetworkEntryKind"
|
||||
},
|
||||
"method": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"mime_type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"resource_type": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"status": {
|
||||
"description": "HTTP status code (`response` entries only).",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint32",
|
||||
"minimum": 0.0
|
||||
},
|
||||
"status_text": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"timestamp": {
|
||||
"type": [
|
||||
"number",
|
||||
"null"
|
||||
],
|
||||
"format": "double"
|
||||
},
|
||||
"truncated": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"NetworkEntryKind": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "A response was received (`Network.responseReceived`).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"response"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The request failed before completing (`Network.loadingFailed`).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"failure"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,9 @@ fn main() {
|
||||
dump!(ConsoleResult, "tool_console_result");
|
||||
dump!(ConsoleEntry, "tool_console_entry");
|
||||
dump!(ConsoleStackFrame, "tool_console_stack_frame");
|
||||
dump!(NetworkParams, "tool_network_params");
|
||||
dump!(NetworkResult, "tool_network_result");
|
||||
dump!(NetworkEntry, "tool_network_entry");
|
||||
|
||||
dump!(EvaluateParams, "tool_evaluate_params");
|
||||
dump!(EvaluateResult, "tool_evaluate_result");
|
||||
|
||||
@@ -64,6 +64,8 @@ pub enum Method {
|
||||
ToolScreenshot,
|
||||
#[serde(rename = "tool.console")]
|
||||
ToolConsole,
|
||||
#[serde(rename = "tool.network")]
|
||||
ToolNetwork,
|
||||
#[serde(rename = "tool.evaluate")]
|
||||
ToolEvaluate,
|
||||
#[serde(rename = "tool.wait_for_navigation")]
|
||||
@@ -127,6 +129,7 @@ impl Method {
|
||||
| Method::ToolGetHtml
|
||||
| Method::ToolScreenshot
|
||||
| Method::ToolConsole
|
||||
| Method::ToolNetwork
|
||||
| Method::ToolWaitForNavigation
|
||||
| Method::ToolWaitMs
|
||||
| Method::ToolRequestHelp => false,
|
||||
@@ -169,6 +172,13 @@ mod tests {
|
||||
assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.console"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_method_round_trips() {
|
||||
let method: Method = serde_json::from_value(json!("tool.network")).unwrap();
|
||||
assert_eq!(method, Method::ToolNetwork);
|
||||
assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_params_and_result_round_trip() {
|
||||
let params: CancelParams = serde_json::from_value(json!({ "rpc_id": "wait-1" })).unwrap();
|
||||
@@ -187,6 +197,7 @@ mod tests {
|
||||
assert!(!Method::ToolGetHtml.is_mutating());
|
||||
assert!(!Method::ToolScreenshot.is_mutating());
|
||||
assert!(!Method::ToolConsole.is_mutating());
|
||||
assert!(!Method::ToolNetwork.is_mutating());
|
||||
assert!(!Method::ToolWaitForNavigation.is_mutating());
|
||||
assert!(!Method::ToolWaitMs.is_mutating());
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod dialog;
|
||||
pub mod human_loop;
|
||||
pub mod interaction;
|
||||
pub mod navigation;
|
||||
pub mod network;
|
||||
pub mod observation;
|
||||
pub mod script;
|
||||
pub mod session;
|
||||
@@ -16,6 +17,7 @@ pub use dialog::*;
|
||||
pub use human_loop::*;
|
||||
pub use interaction::*;
|
||||
pub use navigation::*;
|
||||
pub use network::*;
|
||||
pub use observation::*;
|
||||
pub use script::*;
|
||||
pub use session::*;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! `tool.network` — read buffered network responses / failures.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NetworkParams {
|
||||
pub session_id: String,
|
||||
/// Optional target tab. Defaults to the Agent Window's currently active tab.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tab_id: Option<i64>,
|
||||
/// Return entries with sequence strictly greater than this cursor.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub since: Option<u64>,
|
||||
/// Maximum number of entries to return. Extension applies safe defaults and caps.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub limit: Option<u32>,
|
||||
/// Maximum characters returned per URL / error text. Extension applies safe defaults and caps.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(range(min = 1))]
|
||||
pub max_text_chars: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum NetworkEntryKind {
|
||||
/// A response was received (`Network.responseReceived`).
|
||||
Response,
|
||||
/// The request failed before completing (`Network.loadingFailed`).
|
||||
Failure,
|
||||
}
|
||||
|
||||
impl NetworkEntryKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Response => "response",
|
||||
Self::Failure => "failure",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NetworkEntry {
|
||||
pub sequence: u64,
|
||||
pub kind: NetworkEntryKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
pub url: String,
|
||||
/// HTTP status code (`response` entries only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resource_type: Option<String>,
|
||||
/// CDP failure reason (`failure` entries only).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timestamp: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NetworkResult {
|
||||
pub tab_id: i64,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub entries: Vec<NetworkEntry>,
|
||||
pub next_since: u64,
|
||||
#[serde(default)]
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn network_params_omit_optional_fields() {
|
||||
let params = NetworkParams {
|
||||
session_id: "aa11".into(),
|
||||
tab_id: None,
|
||||
since: None,
|
||||
limit: None,
|
||||
max_text_chars: None,
|
||||
};
|
||||
let value = serde_json::to_value(¶ms).unwrap();
|
||||
assert_eq!(value["session_id"], "aa11");
|
||||
assert!(value.get("tab_id").is_none());
|
||||
assert!(value.get("since").is_none());
|
||||
assert!(value.get("limit").is_none());
|
||||
assert!(value.get("max_text_chars").is_none());
|
||||
let round: NetworkParams = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(round, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_result_round_trips_response_and_failure() {
|
||||
let result = NetworkResult {
|
||||
tab_id: 7,
|
||||
entries: vec![
|
||||
NetworkEntry {
|
||||
sequence: 3,
|
||||
kind: NetworkEntryKind::Response,
|
||||
method: Some("GET".into()),
|
||||
url: "https://example.test/api".into(),
|
||||
status: Some(404),
|
||||
status_text: Some("Not Found".into()),
|
||||
mime_type: Some("application/json".into()),
|
||||
resource_type: Some("Fetch".into()),
|
||||
error_text: None,
|
||||
timestamp: Some(1234.5),
|
||||
truncated: false,
|
||||
},
|
||||
NetworkEntry {
|
||||
sequence: 4,
|
||||
kind: NetworkEntryKind::Failure,
|
||||
method: Some("GET".into()),
|
||||
url: "https://example.test/blocked".into(),
|
||||
status: None,
|
||||
status_text: None,
|
||||
mime_type: None,
|
||||
resource_type: Some("Script".into()),
|
||||
error_text: Some("net::ERR_BLOCKED_BY_CLIENT".into()),
|
||||
timestamp: Some(1240.0),
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
next_since: 4,
|
||||
truncated: false,
|
||||
};
|
||||
let value = serde_json::to_value(&result).unwrap();
|
||||
assert_eq!(value["entries"][0]["kind"], json!("response"));
|
||||
assert_eq!(value["entries"][0]["status"], json!(404));
|
||||
assert_eq!(value["entries"][1]["kind"], json!("failure"));
|
||||
assert_eq!(
|
||||
value["entries"][1]["error_text"],
|
||||
json!("net::ERR_BLOCKED_BY_CLIENT")
|
||||
);
|
||||
let round: NetworkResult = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(round, result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user