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:
hjxccc
2026-07-18 22:37:57 +08:00
committed by BB-fat
parent 3d6c95c22a
commit 259b4826b7
19 changed files with 1156 additions and 3 deletions
@@ -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();
});
});
+8
View File
@@ -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,
+105
View File
@@ -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);
}
+13 -1
View File
@@ -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;
}
/**
+31
View File
@@ -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 {