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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions apps/extension/src/tools/cookies.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
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<number | string, string | undefined> = {
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<CookiesResult | RpcError> {
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),
};
}
}
16 changes: 16 additions & 0 deletions apps/extension/src/tools/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
ConsoleParams,
DownloadParams,
EmulateParams,
CookiesParams,
EvaluateParams,
FillParams,
FocusParams,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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":
Expand Down
23 changes: 23 additions & 0 deletions apps/extension/src/transport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
87 changes: 87 additions & 0 deletions crates/bsk-cli/src/cli/cookies.rs
Original file line number Diff line number Diff line change
@@ -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<i64>,

/// 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<CookiesResult, CliError> {
crate::cli::business_rpc::call::<CookiesParams, CookiesResult>(
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::<Vec<_>>()
.join(",");
println!(" {:24} {} ({}{})", c.name, c.domain, flags, if !flags.is_empty() { "" } else { "-" });
}
println!("{} cookies", reply.cookies.len());
}
}
Ok(())
}
4 changes: 4 additions & 0 deletions crates/bsk-cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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")]
Expand Down
1 change: 1 addition & 0 deletions crates/bsk-cli/src/daemon/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
| Method::ToolUpload
| Method::ToolDownload
| Method::ToolEvaluate
| Method::ToolCookies
| Method::ToolWaitForNavigation
| Method::ToolRequestHelp
| Method::ToolRecordStart
Expand Down
1 change: 1 addition & 0 deletions crates/bsk-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions crates/bsk-protocol/src/method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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,
Expand Down
Loading