diff --git a/packages/cli/src/cdp-client.ts b/packages/cli/src/cdp-client.ts index eb3bc9c3..ffea87bc 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; @@ -464,7 +475,6 @@ 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"); - const persistedTargetId = targetId === undefined ? connectionState?.currentTargetId : undefined; let target: CdpTargetInfo | undefined; if (typeof targetId === "number") { @@ -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(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; @@ -955,7 +1054,6 @@ export async function ensureCdpConnection(): Promise { } } - export async function sendCommand(request: Request): Promise { try { await ensureCdpConnection(); @@ -967,6 +1065,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 92798072..e2934822 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"; @@ -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 录制用户操作 @@ -529,6 +531,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 a52d2a4f..4d465dc5 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -33,6 +33,7 @@ export type ActionType = | "frame_main" | "dialog" | "network" + | "cookies" | "console" | "errors" | "trace" @@ -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 命令使用) */ @@ -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'; @@ -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 命令返回) */