diff --git a/CHANGELOG.md b/CHANGELOG.md index 73ee0ce0..ec9607ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number. ## [Unreleased] +### Added + +- CLI/Extension: new `bsk cookies` command (`tool.cookies`) exports the cookies of the + site open in the session's Agent Window tab — including `httpOnly` cookies — via CDP + `Network.getCookies`. The call reuses the `tool.evaluate` sandbox (`resolveTargetTab` + + `enforceAgentWindow`, Agent Window tabs only) and the query is scoped to the tab's + own URL, so the export cannot widen into a browser-wide token-exfil window (design §6). + Primary use case: hand a logged-in session to a headless test runner (e.g. Playwright + `storageState`) for repeatable UI regression on production sites. + ### Fixed - Extension: input to a background Agent Window tab no longer keeps failing with diff --git a/apps/extension/src/tools/cookies.ts b/apps/extension/src/tools/cookies.ts new file mode 100644 index 00000000..4fdca77b --- /dev/null +++ b/apps/extension/src/tools/cookies.ts @@ -0,0 +1,117 @@ +// `tool.cookies` — export cookies (including httpOnly) for the site +// open in the target Agent Window tab, via CDP `Network.getCookies`. +// +// Red-line (design §6, same sandbox as `tool.evaluate`): the target tab +// resolves through `resolveTargetTab` + `enforceAgentWindow`, so only +// sites the human has explicitly handed to the agent can be exported — +// never arbitrary user tabs. The query is scoped to the tab's own URL, +// which keeps the export from widening into a browser-wide token-exfil +// window. +// +// Errors: RPC-level (`not_found / invalid_params / permission_denied / +// cdp_failed`) are returned as `RpcError`. + +import { ChromiumCdp } from "@/browser-driver/chromium-cdp"; +import type { SessionManager } from "@/session-manager/manager"; +import type { CookieEntry, CookiesParams, CookiesResult, RpcError } from "@/transport/types"; +import { + type ChromeTabsApi, + chromeTabsApi, + enforceAgentWindow, + isRpcError, + lookupSession, + resolveTargetTab, +} from "./shared"; + +export interface CookiesDeps { + send: (tabId: number, method: string, params?: object) => Promise; + tabsApi: ChromeTabsApi; + signal?: AbortSignal; +} + +interface CdpCookie { + name: string; + value: string; + domain: string; + path: string; + expires?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: number | string; +} + +interface NetworkGetCookiesReply { + cookies?: CdpCookie[]; +} + +const SAME_SITE: Record = { + 0: undefined, + 1: "NoRestrictions", + 2: "Lax", + 3: "Strict", +}; + +let defaultDeps: CookiesDeps | null = null; + +function getDefaultDeps(): CookiesDeps { + if (!defaultDeps) { + const cdp = new ChromiumCdp(); + defaultDeps = { + send: (tabId, method, params) => cdp.send(tabId, method, params), + tabsApi: chromeTabsApi, + }; + } + return defaultDeps; +} + +function toWireCookie(c: CdpCookie): CookieEntry { + return { + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: typeof c.expires === "number" ? c.expires : undefined, + http_only: c.httpOnly === true, + secure: c.secure === true, + same_site: SAME_SITE[c.sameSite ?? 0] ?? (typeof c.sameSite === "string" ? c.sameSite : undefined), + }; +} + +export async function handleCookies( + manager: SessionManager, + params: CookiesParams, + deps: CookiesDeps = getDefaultDeps(), +): Promise { + if (!params || typeof params.session_id !== "string" || params.session_id.length === 0) { + return { code: "invalid_params", message: "cookies requires a session_id" }; + } + const ctxOrErr = lookupSession(manager, params, "cookies"); + if (isRpcError(ctxOrErr)) return ctxOrErr; + const ctx = ctxOrErr; + if (deps.signal?.aborted) { + return { code: "cancelled", message: "cookies aborted" }; + } + const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi); + if (isRpcError(target)) return target; + const denied = enforceAgentWindow(ctx, target, "cookies"); + if (denied) return denied; + + const url = target.url ?? ""; + if (!/^https?:/i.test(url)) { + return { code: "invalid_params", message: `cookies requires an http(s) tab url, got ${url}` }; + } + + try { + // Scoped to the tab's own URL — the export cannot be widened. + const reply = (await deps.send(target.tabId, "Network.getCookies", { + urls: [url], + })) as NetworkGetCookiesReply | undefined; + const cookies = (reply?.cookies ?? []).map(toWireCookie); + return { tab_id: target.tabId, url, cookies }; + } catch (err) { + return { + code: "cdp_failed", + message: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index 0a658904..6db120e5 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -12,6 +12,7 @@ import type { ConsoleParams, DownloadParams, EmulateParams, + CookiesParams, EvaluateParams, FillParams, FocusParams, @@ -52,6 +53,7 @@ import { handleDebug } from "./debug"; import { handleDownload } from "./download"; import { type EmulateCdpRunner, handleEmulate } from "./emulate"; import { classifyCdpError } from "./errors"; +import { handleCookies } from "./cookies"; import { handleEvaluate } from "./evaluate"; import { handleRequestHelp } from "./human-loop"; import { @@ -809,6 +811,19 @@ export class ToolDispatcher { req.params as EvaluateParams, this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi, signal } : undefined, ); + case "tool.cookies": + return handleCookies( + this.sessions, + req.params as CookiesParams, + this.cdp + ? { + send: (tabId: number, method: string, params?: object) => + this.cdp!.send(tabId, method, params), + tabsApi: chromeTabsApi, + signal, + } + : undefined, + ); case "tool.wait_for_navigation": return handleWaitForNavigation( this.sessions, @@ -1023,6 +1038,7 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null { case "tool.upload": case "tool.download": case "tool.evaluate": + case "tool.cookies": case "tool.observe": case "tool.screenshot_full_page": case "tool.request_help": diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index 37c01491..be5bdadb 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -752,6 +752,29 @@ export interface EvaluateResult { dialogs?: JavaScriptDialogInfo[]; } +export interface CookiesParams { + session_id: string; + tab_id?: number; + timeout_ms?: number; +} + +export interface CookieEntry { + name: string; + value: string; + domain: string; + path: string; + expires?: number; + http_only: boolean; + secure: boolean; + same_site?: string; +} + +export interface CookiesResult { + tab_id: number; + url: string; + cookies: CookieEntry[]; +} + export interface WaitForNavigationParams { session_id: string; tab_id?: number; diff --git a/crates/bsk-cli/src/cli/cookies.rs b/crates/bsk-cli/src/cli/cookies.rs new file mode 100644 index 00000000..2280d135 --- /dev/null +++ b/crates/bsk-cli/src/cli/cookies.rs @@ -0,0 +1,87 @@ +//! `bsk cookies` — export cookies (including httpOnly) for the site +//! open in the session's Agent Window tab. Thin clap wrapper around the +//! `tool.cookies` IPC call. +//! +//! Scope: the export is limited to the target tab's own URL (CDP +//! `Network.getCookies` with `urls: [tab.url]`), and the tab must live +//! in the Agent Window — same red-line as `tool.evaluate` (design §6). + +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use bsk_protocol::Method; +use bsk_protocol::tools::{CookiesParams, CookiesResult}; +use clap::Args; + +use crate::cli::ensure_daemon::ensure_daemon; +use crate::cli::error::{CliError, Format}; + +#[derive(Debug, Clone, Args)] +pub struct CookiesArgs { + /// 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, + + /// Hard upper bound on the call, milliseconds. + #[arg(long = "timeout-ms", default_value_t = 30_000)] + pub timeout: u32, +} + +pub fn dispatch(args: CookiesArgs, format: Format) -> Result<(), CliError> { + let info = ensure_daemon().context("ensure daemon is running")?; + let params = CookiesParams { + session_id: args.session, + tab_id: args.tab_id, + timeout_ms: Some(args.timeout), + }; + let reply = call(info.sock_path, params, args.timeout)?; + render(&reply, format) +} + +fn call(sock: PathBuf, params: CookiesParams, timeout_ms: u32) -> Result { + crate::cli::business_rpc::call::( + sock, + "cookies", + Method::ToolCookies, + Some(params), + ipc_timeout(timeout_ms), + ) +} + +fn ipc_timeout(timeout_ms: u32) -> Duration { + Duration::from_millis(u64::from(timeout_ms)) + .checked_add(Duration::from_secs(15)) + .unwrap_or(Duration::from_secs(u64::from(timeout_ms / 1_000) + 15)) +} + +fn render(reply: &CookiesResult, 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 => { + println!("url: {}", reply.url); + for c in &reply.cookies { + let flags = [ + if c.http_only { "httpOnly" } else { "" }, + if c.secure { "secure" } else { "" }, + ] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join(","); + println!(" {:24} {} ({}{})", c.name, c.domain, flags, if !flags.is_empty() { "" } else { "-" }); + } + println!("{} cookies", reply.cookies.len()); + } + } + Ok(()) +} diff --git a/crates/bsk-cli/src/cli/mod.rs b/crates/bsk-cli/src/cli/mod.rs index 32afe05d..8b427225 100644 --- a/crates/bsk-cli/src/cli/mod.rs +++ b/crates/bsk-cli/src/cli/mod.rs @@ -15,6 +15,7 @@ pub mod download; pub mod emulate; pub mod ensure_daemon; pub mod error; +pub mod cookies; pub mod evaluate; pub mod get_html; pub mod human_loop; @@ -47,6 +48,7 @@ use crate::cli::console::ConsoleArgs; use crate::cli::daemon::DaemonCmd; use crate::cli::download::DownloadArgs; use crate::cli::emulate::EmulateArgs; +use crate::cli::cookies::CookiesArgs; use crate::cli::evaluate::EvaluateArgs; use crate::cli::get_html::GetHtmlArgs; use crate::cli::human_loop::RequestHelpArgs; @@ -214,6 +216,8 @@ pub enum Command { /// Evaluate a JavaScript expression inside the Agent Window. Evaluate(EvaluateArgs), + /// Export cookies (incl. httpOnly) for the Agent Window tab's site. + Cookies(CookiesArgs), /// Wait for a page-lifecycle event. #[command(name = "wait-for-navigation")] diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index 7f15fb68..208944c2 100644 --- a/crates/bsk-cli/src/daemon/ipc.rs +++ b/crates/bsk-cli/src/daemon/ipc.rs @@ -301,6 +301,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc) -> RpcHandler | Method::ToolUpload | Method::ToolDownload | Method::ToolEvaluate + | Method::ToolCookies | Method::ToolWaitForNavigation | Method::ToolRequestHelp | Method::ToolRecordStart diff --git a/crates/bsk-cli/src/main.rs b/crates/bsk-cli/src/main.rs index 529f3da7..acbbbadd 100644 --- a/crates/bsk-cli/src/main.rs +++ b/crates/bsk-cli/src/main.rs @@ -105,6 +105,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { Command::Upload(args) => cli::upload::dispatch(args, format), Command::Download(args) => cli::download::dispatch(args, format), Command::Evaluate(args) => cli::evaluate::dispatch(args, format), + Command::Cookies(args) => cli::cookies::dispatch(args, format), Command::WaitForNavigation(args) => cli::waits::dispatch_wait_for_navigation(args, format), Command::WaitMs(args) => cli::waits::dispatch_wait_ms(args, format), Command::RequestHelp(args) => cli::human_loop::dispatch(args, format), diff --git a/crates/bsk-protocol/src/method.rs b/crates/bsk-protocol/src/method.rs index 9a6d6b5d..936010ca 100644 --- a/crates/bsk-protocol/src/method.rs +++ b/crates/bsk-protocol/src/method.rs @@ -120,6 +120,8 @@ pub enum Method { ToolNetwork, #[serde(rename = "tool.evaluate")] ToolEvaluate, + #[serde(rename = "tool.cookies")] + ToolCookies, #[serde(rename = "tool.wait_for_navigation")] ToolWaitForNavigation, #[serde(rename = "tool.wait_ms")] @@ -202,6 +204,7 @@ impl Method { | Method::ToolUpload | Method::ToolDownload | Method::ToolEvaluate + | Method::ToolCookies // May navigate via optional `url` and changes Agent Window // chrome; gate behind pending-interrupt like other writes. | Method::ToolRecordStart => MethodEffect::BrowserMutation, diff --git a/crates/bsk-protocol/src/tools/cookies.rs b/crates/bsk-protocol/src/tools/cookies.rs new file mode 100644 index 00000000..0d3aae68 --- /dev/null +++ b/crates/bsk-protocol/src/tools/cookies.rs @@ -0,0 +1,91 @@ +//! `tool.cookies` — export cookies (including httpOnly) for the site +//! open in the session's Agent Window tab, via CDP `Network.getCookies`. +//! +//! Red-line (design §6, same as `tool.evaluate`): the call resolves the +//! target tab through `resolveTargetTab` + `enforceAgentWindow`, so only +//! sites the human has explicitly handed to the agent (opened in the +//! Agent Window) can be exported — never arbitrary user tabs. The cookie query +//! is scoped to the tab's own URL so the export cannot be widened into a +//! browser-wide token-exfil window. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct CookiesParams { + pub session_id: String, + /// Target tab. Defaults to the Agent Window's currently active tab. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + /// Hard upper bound on the call. Defaults to 30s. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub timeout_ms: Option, +} + +/// One cookie as reported by CDP `Network.getCookies`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct CookieEntry { + pub name: String, + pub value: String, + pub domain: String, + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires: Option, + #[serde(default)] + pub http_only: bool, + #[serde(default)] + pub secure: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub same_site: Option, +} + +/// Outcome of a `cookies` call. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct CookiesResult { + pub tab_id: i64, + /// The URL the cookies were scoped to (the tab's own URL). + pub url: String, + pub cookies: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn params_roundtrip() { + let p = CookiesParams { + session_id: "s1".into(), + tab_id: Some(7), + timeout_ms: None, + }; + let v = serde_json::to_value(&p).unwrap(); + assert!(v.get("tab_id").is_some()); + assert!(v.get("timeout_ms").is_none()); + let back: CookiesParams = serde_json::from_value(v).unwrap(); + assert_eq!(p, back); + } + + #[test] + fn result_roundtrip() { + let r = CookiesResult { + tab_id: 7, + url: "https://example.com/".into(), + cookies: vec![CookieEntry { + name: "SID".into(), + value: "v".into(), + domain: ".example.com".into(), + path: "/".into(), + expires: Some(1.0), + http_only: true, + secure: true, + same_site: None, + }], + }; + let v = serde_json::to_value(&r).unwrap(); + let back: CookiesResult = serde_json::from_value(v).unwrap(); + assert_eq!(r, back); + assert!(v["cookies"][0]["http_only"].is_boolean()); + } +} diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index ea4beb09..d29de733 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -1,6 +1,7 @@ //! Typed params/results for the `tool.*` RPC methods (§7). pub mod console; +pub mod cookies; pub mod debug; pub mod dialog; pub mod emulate; @@ -23,6 +24,7 @@ pub mod wheel; pub mod window; pub use console::*; +pub use cookies::*; pub use debug::*; pub use dialog::*; pub use emulate::*;