Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 125 additions & 2 deletions packages/cli/src/cdp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ interface CdpTargetInfo {

type JsonObject = Record<string, unknown>;

interface CdpCookie extends JsonObject {
name?: string;
value?: string;
domain?: string;
path?: string;
expires?: number;
httpOnly?: boolean;
secure?: boolean;
sameSite?: "Strict" | "Lax" | "None";
}

interface PendingCommand {
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
Expand Down Expand Up @@ -464,7 +475,6 @@ async function getTargets(): Promise<CdpTargetInfo[]> {
async function ensurePageTarget(targetId?: string | number): Promise<CdpTargetInfo> {
const targets = (await getTargets()).filter((target) => target.type === "page");
if (targets.length === 0) throw new Error("No page target found");

const persistedTargetId = targetId === undefined ? connectionState?.currentTargetId : undefined;
let target: CdpTargetInfo | undefined;
if (typeof targetId === "number") {
Expand Down Expand Up @@ -935,6 +945,95 @@ function fail(id: string, error: unknown): Response {
return { id, success: false, error: buildRequestError(error).message };
}

function normalizeCookie(cookie: CdpCookie) {
return {
name: String(cookie.name ?? ""),
value: String(cookie.value ?? ""),
domain: String(cookie.domain ?? ""),
path: String(cookie.path ?? "/"),
expires: typeof cookie.expires === "number" ? cookie.expires : undefined,
httpOnly: Boolean(cookie.httpOnly),
secure: Boolean(cookie.secure),
sameSite: cookie.sameSite,
};
}

function selectPageTarget(targets: CdpTargetInfo[], targetId?: string | number): CdpTargetInfo {
if (targets.length === 0) throw new Error("No page target found");

let target: CdpTargetInfo | undefined;
if (typeof targetId === "number") {
target = targets[targetId] ?? targets.find((item) => Number(item.id) === targetId);
} else if (typeof targetId === "string") {
target = targets.find((item) => item.id === targetId);
if (!target) {
const numericTargetId = Number(targetId);
if (!Number.isNaN(numericTargetId)) {
target = targets[numericTargetId] ?? targets.find((item) => Number(item.id) === numericTargetId);
}
}
}

return target ?? targets[0];
}

async function runDirectPageCommand<T>(target: CdpTargetInfo, method: string, params: JsonObject = {}): Promise<T> {
if (!target.webSocketDebuggerUrl) {
throw new Error("Page target missing webSocketDebuggerUrl");
}

const ws = await connectWebSocket(target.webSocketDebuggerUrl);
const messageId = 1;

try {
return await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
reject(new Error(`${method}: timeout`));
}, COMMAND_TIMEOUT);

const cleanup = () => {
clearTimeout(timer);
ws.off("message", onMessage);
};

const onMessage = (raw: WebSocket.RawData) => {
const message = JSON.parse(raw.toString()) as JsonObject;
if (message.id !== messageId) return;
cleanup();
if (message.error) {
reject(new Error(`${method}: ${(message.error as JsonObject).message ?? "Unknown CDP error"}`));
return;
}
resolve(message.result as T);
};

ws.on("message", onMessage);
ws.send(JSON.stringify({ id: messageId, method, params }));
});
} finally {
ws.close();
}
}

async function getCookiesViaPageWebSocket(targetId?: string | number): Promise<{ target: CdpTargetInfo; cookies: ReturnType<typeof normalizeCookie>[] }> {
const state = connectionState;
if (!state) throw new Error("CDP connection not initialized");

const targets = (await getJsonList(state.host, state.port)).filter((item) => item.type === "page");
const target = selectPageTarget(targets, targetId);
const result = await runDirectPageCommand<{ cookies?: CdpCookie[] }>(
target,
"Network.getCookies",
target.url ? { urls: [target.url] } : {},
);

return {
target,
cookies: (result.cookies ?? []).map(normalizeCookie),
};
}

export async function ensureCdpConnection(): Promise<void> {
if (connectionState) return;
if (reconnecting) return reconnecting;
Expand All @@ -955,7 +1054,6 @@ export async function ensureCdpConnection(): Promise<void> {
}
}


export async function sendCommand(request: Request): Promise<Response> {
try {
await ensureCdpConnection();
Expand All @@ -967,6 +1065,31 @@ export async function sendCommand(request: Request): Promise<Response> {
}

async function dispatchRequest(request: Request): Promise<Response> {
if (request.action === "cookies") {
const subCommand = request.cookiesCommand ?? "get";
const { target, cookies } = await getCookiesViaPageWebSocket(request.tabId);

switch (subCommand) {
case "get":
return ok(request.id, { cookies, url: target.url, count: cookies.length });
case "getByName": {
if (!request.name) return fail(request.id, "Missing name parameter");
const cookie = cookies.find((item) => item.name === request.name);
return ok(request.id, {
cookie,
url: target.url,
availableCookies: cookies.map((item) => item.name),
});
}
case "httpOnly": {
const httpOnlyCookies = cookies.filter((item) => item.httpOnly);
return ok(request.id, { cookies: httpOnlyCookies, url: target.url, count: httpOnlyCookies.length });
}
default:
return fail(request.id, `Unknown cookies subcommand: ${subCommand}`);
}
}

const target = await ensurePageTarget(request.tabId);
switch (request.action) {
case "open": {
Expand Down
103 changes: 103 additions & 0 deletions packages/cli/src/commands/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* cookies 命令 - 获取和管理 Cookies(包括 HttpOnly)
*/

import { sendCommand } from "../client.js";

interface CookiesOptions {
json?: boolean;
tabId?: number;
}

export async function cookiesCommand(
subCommand: string,
name?: string,
options: CookiesOptions = {}
): Promise<void> {
const response = await sendCommand({
id: crypto.randomUUID(),
action: "cookies",
cookiesCommand: subCommand as "get" | "getByName" | "httpOnly",
name: name,
tabId: options.tabId,
});

if (options.json) {
console.log(JSON.stringify(response));
return;
}

if (!response.success) {
throw new Error(response.error || "Cookies command failed");
}

const data = response.data;

switch (subCommand) {
case "get": {
const cookies = data?.cookies || [];
console.log(`Cookies (${cookies.length} 个, URL: ${data?.url}):\n`);
for (const cookie of cookies) {
const flags = [];
if (cookie.httpOnly) flags.push("HttpOnly");
if (cookie.secure) flags.push("Secure");
if (cookie.sameSite) flags.push(`SameSite=${cookie.sameSite}`);
const flagStr = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
console.log(`${cookie.name}: ${cookie.value}${flagStr}`);
console.log(` 域: ${cookie.domain}, 路径: ${cookie.path}`);
if (cookie.expires) {
const expiryDate = new Date(cookie.expires * 1000);
console.log(` 过期: ${expiryDate.toLocaleString()}`);
}
console.log("");
}
break;
}

case "getByName": {
const cookie = data?.cookie;
if (!cookie) {
console.log(`未找到 Cookie: ${name}`);
const available = data?.availableCookies || [];
if (available.length > 0) {
console.log(`可用的 Cookie: ${available.join(", ")}`);
}
} else {
const flags = [];
if (cookie.httpOnly) flags.push("HttpOnly");
if (cookie.secure) flags.push("Secure");
if (cookie.sameSite) flags.push(`SameSite=${cookie.sameSite}`);
const flagStr = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
console.log(`${cookie.name}: ${cookie.value}${flagStr}`);
console.log(` 域: ${cookie.domain}, 路径: ${cookie.path}`);
if (cookie.expires) {
const expiryDate = new Date(cookie.expires * 1000);
console.log(` 过期: ${expiryDate.toLocaleString()}`);
}
}
break;
}

case "httpOnly": {
const cookies = data?.cookies || [];
console.log(`HttpOnly Cookies (${cookies.length} 个, URL: ${data?.url}):\n`);
for (const cookie of cookies) {
const flags = ["HttpOnly"];
if (cookie.secure) flags.push("Secure");
if (cookie.sameSite) flags.push(`SameSite=${cookie.sameSite}`);
const flagStr = flags.join(", ");
console.log(`${cookie.name}: ${cookie.value} [${flagStr}]`);
console.log(` 域: ${cookie.domain}, 路径: ${cookie.path}`);
if (cookie.expires) {
const expiryDate = new Date(cookie.expires * 1000);
console.log(` 过期: ${expiryDate.toLocaleString()}`);
}
console.log("");
}
break;
}

default:
throw new Error(`未知的 cookies 子命令: ${subCommand}`);
}
}
9 changes: 9 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { tabCommand } from "./commands/tab.js";
import { frameCommand, frameMainCommand } from "./commands/frame.js";
import { dialogCommand } from "./commands/dialog.js";
import { networkCommand } from "./commands/network.js";
import { cookiesCommand } from "./commands/cookies.js";
import { consoleCommand } from "./commands/console.js";
import { errorsCommand } from "./commands/errors.js";
import { traceCommand } from "./commands/trace.js";
Expand Down Expand Up @@ -86,6 +87,7 @@ bb-browser - AI Agent 浏览器自动化工具

调试:
network requests [filter] 查看网络请求
cookies get|getByName|httpOnly 查看 Cookies(包括 HttpOnly)
console [--clear] 查看/清空控制台
errors [--clear] 查看/清空 JS 错误
trace start|stop|status 录制用户操作
Expand Down Expand Up @@ -529,6 +531,13 @@ async function main(): Promise<void> {
break;
}

case "cookies": {
const subCommand = parsed.args[0] || "get";
const name = parsed.args[1];
await cookiesCommand(subCommand, name, { json: parsed.flags.json, tabId: globalTabId });
break;
}

case "console": {
const clear = process.argv.includes("--clear");
await consoleCommand({ json: parsed.flags.json, clear, tabId: globalTabId });
Expand Down
25 changes: 25 additions & 0 deletions packages/shared/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type ActionType =
| "frame_main"
| "dialog"
| "network"
| "cookies"
| "console"
| "errors"
| "trace"
Expand Down Expand Up @@ -95,6 +96,10 @@ export interface Request {
traceCommand?: "start" | "stop" | "status";
/** history 子命令:search, domains */
historyCommand?: "search" | "domains";
/** cookies 子命令:get, getByName, httpOnly */
cookiesCommand?: "get" | "getByName" | "httpOnly";
/** cookie 名称(getByName 时使用) */
name?: string;
/** 按键名(press 命令使用) */
key?: string;
/** 修饰键列表(press 命令使用) */
Expand Down Expand Up @@ -167,6 +172,18 @@ export interface NetworkRequestInfo {
bodyError?: string;
}

/** Cookie 信息(cookies 命令返回) */
export interface CookieInfo {
name: string;
value: string;
domain: string;
path: string;
expires?: number;
httpOnly: boolean;
secure: boolean;
sameSite?: "Strict" | "Lax" | "None";
}

/** 控制台消息 */
export interface ConsoleMessageInfo {
type: 'log' | 'info' | 'warn' | 'error' | 'debug';
Expand Down Expand Up @@ -275,6 +292,14 @@ export interface ResponseData {
};
/** 网络请求列表(network requests 命令返回) */
networkRequests?: NetworkRequestInfo[];
/** Cookie 列表(cookies get/httpOnly 命令返回) */
cookies?: CookieInfo[];
/** 单个 Cookie(cookies getByName 命令返回) */
cookie?: CookieInfo;
/** 当前可用的 Cookie 名称(cookies getByName 未命中时返回) */
availableCookies?: string[];
/** Cookie 数量 */
count?: number;
/** 网络路由规则数量(network route/unroute 命令返回) */
routeCount?: number;
/** 控制台消息列表(console 命令返回) */
Expand Down