From 0265818989fc936015ee026bfa12cc611f163fe9 Mon Sep 17 00:00:00 2001 From: kyle Date: Wed, 18 Mar 2026 10:10:59 +0800 Subject: [PATCH] Add: pure CDP cookies command Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/cli/src/cdp-client.ts | 142 ++++++++++++++++++++++++--- packages/cli/src/commands/cookies.ts | 103 +++++++++++++++++++ packages/cli/src/index.ts | 9 ++ packages/shared/src/protocol.ts | 25 +++++ 4 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/commands/cookies.ts diff --git a/packages/cli/src/cdp-client.ts b/packages/cli/src/cdp-client.ts index 692a1b33..e31b4114 100644 --- a/packages/cli/src/cdp-client.ts +++ b/packages/cli/src/cdp-client.ts @@ -19,6 +19,17 @@ interface CdpTargetInfo { type JsonObject = Record; +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; @@ -429,21 +440,7 @@ async function getTargets(): Promise { async function ensurePageTarget(targetId?: string | number): Promise { const targets = (await getTargets()).filter((target) => target.type === "page"); - 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); - } - } - } - target ??= targets[0]; + const target = selectPageTarget(targets, targetId); connectionState!.currentTargetId = target.id; await attachTarget(target.id); return target; @@ -843,6 +840,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(target: CdpTargetInfo, method: string, params: JsonObject = {}): Promise { + if (!target.webSocketDebuggerUrl) { + throw new Error("Page target missing webSocketDebuggerUrl"); + } + + const ws = await connectWebSocket(target.webSocketDebuggerUrl); + const messageId = 1; + + try { + return await new Promise((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[] }> { + 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 { if (connectionState) return; if (reconnecting) return reconnecting; @@ -863,7 +949,6 @@ export async function ensureCdpConnection(): Promise { } } - export async function sendCommand(request: Request): Promise { try { await ensureCdpConnection(); @@ -875,6 +960,31 @@ export async function sendCommand(request: Request): Promise { } async function dispatchRequest(request: Request): Promise { + 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": { diff --git a/packages/cli/src/commands/cookies.ts b/packages/cli/src/commands/cookies.ts new file mode 100644 index 00000000..597fb9a1 --- /dev/null +++ b/packages/cli/src/commands/cookies.ts @@ -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 { + 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}`); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 83055817..a484911c 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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"; @@ -83,6 +84,7 @@ bb-browser - AI Agent 浏览器自动化工具 调试: network requests [filter] 查看网络请求 + cookies get|getByName|httpOnly 查看 Cookies(包括 HttpOnly) console [--clear] 查看/清空控制台 errors [--clear] 查看/清空 JS 错误 trace start|stop|status 录制用户操作 @@ -521,6 +523,13 @@ async function main(): Promise { 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 }); diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 72f21543..c6317794 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -31,6 +31,7 @@ export type ActionType = | "frame_main" | "dialog" | "network" + | "cookies" | "console" | "errors" | "trace" @@ -93,6 +94,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 命令使用) */ @@ -165,6 +170,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'; @@ -273,6 +290,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 命令返回) */