feat(cookies): cross-profile cookies export / cookies transfer
Transferring a logged-in session between Chrome profiles previously needed an ad-hoc external script to decrypt the source profile's cookie store. Make it first-class: - `cookies export --from <profile> [--domain <d>[,<d>]]` decrypts another profile's on-disk cookies and prints CDP-shaped JSON for `cookies set --curl`. - `cookies transfer --from <profile> [--domain <d>]` exports + injects into the connected browser in one shot (reuses the cookies_set path). Source profile is resolved by directory name, display name, or "auto". The store is copied to a temp file (immune to a running Chrome's lock/WAL), read via sqlite3, and values are decrypted (macOS v10: AES-128-CBC, key from the shared 'Chrome Safe Storage' Keychain entry). httpOnly/secure/per-domain auth cookies round-trip intact; SameSite=None without Secure is downgraded so CDP accepts it. macOS only for now (clear error elsewhere).
This commit is contained in:
Generated
+34
-1
@@ -210,6 +210,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "built"
|
||||
version = "0.8.0"
|
||||
@@ -246,6 +255,15 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.56"
|
||||
@@ -272,11 +290,13 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrome-use"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"futures-util",
|
||||
@@ -286,11 +306,13 @@ dependencies = [
|
||||
"image",
|
||||
"include_dir",
|
||||
"libc",
|
||||
"pbkdf2",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"similar",
|
||||
"socket2",
|
||||
@@ -1086,6 +1108,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -1376,6 +1399,16 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "chrome-use"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
edition = "2021"
|
||||
description = "Fast browser automation CLI for AI agents"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -1289,6 +1289,51 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
||||
"cookies" => {
|
||||
let op = rest.first().unwrap_or(&"get");
|
||||
match *op {
|
||||
"transfer" => {
|
||||
// Copy a logged-in session between Chrome profiles: decrypt
|
||||
// the SOURCE profile's on-disk cookie store and inject the
|
||||
// cookies into the active (connected) session — no CDP access
|
||||
// to the source, no Chrome restart.
|
||||
// cookies transfer --from <profile> [--domain <d>[,<d>]]
|
||||
// `--from` wins; otherwise the global `--profile` is used.
|
||||
let from = rest
|
||||
.iter()
|
||||
.position(|a| *a == "--from")
|
||||
.and_then(|i| rest.get(i + 1).copied())
|
||||
.or(flags.profile.as_deref());
|
||||
let from = from.ok_or_else(|| ParseError::MissingArguments {
|
||||
context: "cookies transfer".to_string(),
|
||||
usage: "cookies transfer --from <profile> [--domain <domain>[,<domain>]]",
|
||||
})?;
|
||||
let domain = rest
|
||||
.iter()
|
||||
.position(|a| *a == "--domain")
|
||||
.and_then(|i| rest.get(i + 1).copied());
|
||||
let cookies =
|
||||
crate::cookie_export::export_cookies(from, domain).map_err(|e| {
|
||||
ParseError::InvalidValue {
|
||||
message: format!("cookies transfer: {}", e),
|
||||
usage: "cookies transfer --from <profile> [--domain <domain>]",
|
||||
}
|
||||
})?;
|
||||
if cookies.is_empty() {
|
||||
return Err(ParseError::InvalidValue {
|
||||
message: format!(
|
||||
"cookies transfer: no cookies found in profile \"{}\"{}",
|
||||
from,
|
||||
domain
|
||||
.map(|d| format!(" for domain {}", d))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
usage: "cookies transfer --from <profile> [--domain <domain>]",
|
||||
});
|
||||
}
|
||||
return Ok(json!({
|
||||
"id": id,
|
||||
"action": "cookies_set",
|
||||
"cookies": cookies,
|
||||
}));
|
||||
}
|
||||
"set" => {
|
||||
// --curl <file> mode: import cookies from a JSON array,
|
||||
// raw cURL dump, or bare Cookie header. Scoped to the
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! Offline export of a Chrome profile's cookies.
|
||||
//!
|
||||
//! Reads a profile's on-disk cookie store, decrypts the values with the OS
|
||||
//! credential-store key, and returns CDP `Network.setCookie`-shaped objects —
|
||||
//! the same shape `cookies set --curl` accepts. This is what powers
|
||||
//! `cookies transfer`: it moves a logged-in session (whose auth cookies are
|
||||
//! httpOnly + secure and span several hosts) from one profile to another
|
||||
//! without the source profile being reachable over CDP, and without restarting
|
||||
//! Chrome.
|
||||
//!
|
||||
//! Currently macOS-only. There, value encryption uses the `v10` scheme:
|
||||
//! AES-128-CBC with a key derived (PBKDF2-HMAC-SHA1, 1003 iterations) from the
|
||||
//! "Chrome Safe Storage" Keychain entry, shared by every profile of one Chrome
|
||||
//! install. Other platforms return a clear error.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resolve, read, and decrypt a Chrome profile's cookies.
|
||||
///
|
||||
/// `profile` accepts a directory name ("Default", "Profile 14"), a display name
|
||||
/// ("Davian", case-insensitive), or "auto" (last-used profile). `domain`, when
|
||||
/// set, is a comma-separated host-suffix filter (e.g. "claude.ai,anthropic.com")
|
||||
/// matched against `host_key`; pass `None` to export every cookie.
|
||||
pub fn export_cookies(profile: &str, domain: Option<&str>) -> Result<Vec<Value>, String> {
|
||||
let db = resolve_cookie_db(profile)?;
|
||||
let rows = read_cookie_rows(&db, domain)?;
|
||||
let key = safe_storage_key()?;
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for r in &rows {
|
||||
if let Some(value) = decrypt_value(&r.encrypted_value, &key) {
|
||||
out.push(to_cdp_cookie(r, value));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn resolve_cookie_db(profile: &str) -> Result<PathBuf, String> {
|
||||
use crate::native::cdp::chrome::{find_chrome_user_data_dir, resolve_chrome_profile};
|
||||
let udd = find_chrome_user_data_dir()
|
||||
.ok_or_else(|| "No Chrome user data directory found".to_string())?;
|
||||
let dir = resolve_chrome_profile(&udd, profile)?;
|
||||
let base = udd.join(&dir);
|
||||
// Chrome >=96 keeps cookies under Network/; older builds at the profile root.
|
||||
let net = base.join("Network").join("Cookies");
|
||||
if net.is_file() {
|
||||
return Ok(net);
|
||||
}
|
||||
let root = base.join("Cookies");
|
||||
if root.is_file() {
|
||||
return Ok(root);
|
||||
}
|
||||
Err(format!(
|
||||
"no cookie store found for profile \"{}\" (looked in {} and {})",
|
||||
profile,
|
||||
net.display(),
|
||||
root.display()
|
||||
))
|
||||
}
|
||||
|
||||
struct CookieRow {
|
||||
host_key: String,
|
||||
name: String,
|
||||
encrypted_value: Vec<u8>,
|
||||
path: String,
|
||||
is_secure: bool,
|
||||
is_httponly: bool,
|
||||
samesite: i64,
|
||||
expires_utc: i64,
|
||||
}
|
||||
|
||||
/// Removes a temp directory when dropped.
|
||||
struct TempGuard(PathBuf);
|
||||
impl Drop for TempGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_cookie_rows(db: &Path, domain: Option<&str>) -> Result<Vec<CookieRow>, String> {
|
||||
// Copy the store (plus any -wal/-shm) to a temp file so a running Chrome's
|
||||
// lock / hot journal can't block the read or be disturbed by it.
|
||||
let tmp_dir = std::env::temp_dir().join(format!("chrome-use-cookies-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| format!("temp dir: {}", e))?;
|
||||
let _guard = TempGuard(tmp_dir.clone());
|
||||
let tmp_db = tmp_dir.join("Cookies");
|
||||
copy_db(db, &tmp_db)?;
|
||||
|
||||
let where_clause = build_where(domain)?;
|
||||
let sql = format!(
|
||||
"SELECT json_group_array(json_object(\
|
||||
'h',host_key,'n',name,'e',hex(encrypted_value),'p',path,\
|
||||
'sec',is_secure,'ho',is_httponly,'ss',samesite,'x',expires_utc)) \
|
||||
FROM cookies{};",
|
||||
where_clause
|
||||
);
|
||||
let output = std::process::Command::new("sqlite3")
|
||||
.arg(tmp_db.to_string_lossy().to_string())
|
||||
.arg(&sql)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"could not run sqlite3 (required to read the cookie store): {}",
|
||||
e
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"sqlite3 failed reading the cookie store: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let trimmed = stdout.trim();
|
||||
if trimmed.is_empty() || trimmed == "null" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let arr: Vec<Value> =
|
||||
serde_json::from_str(trimmed).map_err(|e| format!("parsing cookie rows: {}", e))?;
|
||||
let mut rows = Vec::with_capacity(arr.len());
|
||||
for v in arr {
|
||||
let enc_hex = v.get("e").and_then(|x| x.as_str()).unwrap_or("");
|
||||
let path = v.get("p").and_then(|x| x.as_str()).unwrap_or("/");
|
||||
rows.push(CookieRow {
|
||||
host_key: v
|
||||
.get("h")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
name: v
|
||||
.get("n")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
encrypted_value: hex::decode(enc_hex).unwrap_or_default(),
|
||||
path: if path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
path.to_string()
|
||||
},
|
||||
is_secure: v.get("sec").and_then(|x| x.as_i64()).unwrap_or(0) != 0,
|
||||
is_httponly: v.get("ho").and_then(|x| x.as_i64()).unwrap_or(0) != 0,
|
||||
samesite: v.get("ss").and_then(|x| x.as_i64()).unwrap_or(-1),
|
||||
expires_utc: v.get("x").and_then(|x| x.as_i64()).unwrap_or(0),
|
||||
});
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn copy_db(src: &Path, dst: &Path) -> Result<(), String> {
|
||||
std::fs::copy(src, dst).map_err(|e| format!("copying cookie store: {}", e))?;
|
||||
for suffix in ["-wal", "-shm"] {
|
||||
let s = path_with_suffix(src, suffix);
|
||||
if s.is_file() {
|
||||
let _ = std::fs::copy(&s, path_with_suffix(dst, suffix));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn path_with_suffix(p: &Path, suffix: &str) -> PathBuf {
|
||||
let mut s = p.as_os_str().to_os_string();
|
||||
s.push(suffix);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
|
||||
/// Build a `WHERE host_key LIKE '%domain'` clause from a comma-separated filter.
|
||||
/// Domains are validated (alnum/./-) so they can be inlined without injection.
|
||||
fn build_where(domain: Option<&str>) -> Result<String, String> {
|
||||
let Some(domain) = domain else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
let mut clauses = Vec::new();
|
||||
for d in domain.split(',') {
|
||||
let d = d.trim();
|
||||
if d.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !d
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||
{
|
||||
return Err(format!("invalid domain filter \"{}\"", d));
|
||||
}
|
||||
clauses.push(format!("host_key LIKE '%{}'", d));
|
||||
}
|
||||
if clauses.is_empty() {
|
||||
Ok(String::new())
|
||||
} else {
|
||||
Ok(format!(" WHERE {}", clauses.join(" OR ")))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cdp_cookie(r: &CookieRow, value: String) -> Value {
|
||||
let mut o = serde_json::Map::new();
|
||||
o.insert("name".into(), json!(r.name));
|
||||
o.insert("value".into(), json!(value));
|
||||
o.insert("domain".into(), json!(r.host_key));
|
||||
o.insert("path".into(), json!(r.path));
|
||||
o.insert("secure".into(), json!(r.is_secure));
|
||||
o.insert("httpOnly".into(), json!(r.is_httponly));
|
||||
// Chrome SameSite: -1 unspecified, 0 None, 1 Lax, 2 Strict.
|
||||
let same_site = match r.samesite {
|
||||
0 => Some("None"),
|
||||
1 => Some("Lax"),
|
||||
2 => Some("Strict"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(ss) = same_site {
|
||||
// CDP rejects SameSite=None without Secure; downgrade rather than fail.
|
||||
if ss == "None" && !r.is_secure {
|
||||
o.insert("sameSite".into(), json!("Lax"));
|
||||
} else {
|
||||
o.insert("sameSite".into(), json!(ss));
|
||||
}
|
||||
}
|
||||
if let Some(unix) = chrome_epoch_to_unix(r.expires_utc) {
|
||||
o.insert("expires".into(), json!(unix));
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Chrome stores `expires_utc` as microseconds since 1601-01-01 (0 = session
|
||||
/// cookie). CDP wants seconds since the Unix epoch. Returns None for session
|
||||
/// cookies and anything that converts to a non-positive time.
|
||||
fn chrome_epoch_to_unix(expires_utc: i64) -> Option<f64> {
|
||||
if expires_utc <= 0 {
|
||||
return None;
|
||||
}
|
||||
let unix = expires_utc as f64 / 1_000_000.0 - 11_644_473_600.0;
|
||||
if unix > 0.0 {
|
||||
Some(unix)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt a Chrome `v10` cookie value (AES-128-CBC, IV = 16 spaces, PKCS7).
|
||||
/// Returns None for unrecognized schemes or undecryptable values.
|
||||
fn decrypt_value(enc: &[u8], key: &[u8; 16]) -> Option<String> {
|
||||
if enc.len() < 3 || &enc[0..3] != b"v10" {
|
||||
return None;
|
||||
}
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
|
||||
type Dec = cbc::Decryptor<aes::Aes128>;
|
||||
let iv = [0x20u8; 16];
|
||||
let mut buf = enc[3..].to_vec();
|
||||
let pt = Dec::new(key.into(), &iv.into())
|
||||
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
||||
.ok()?;
|
||||
// Chrome >=24 prepends a 32-byte SHA256(host) domain hash to the plaintext.
|
||||
match std::str::from_utf8(pt) {
|
||||
Ok(s) => Some(s.to_string()),
|
||||
Err(_) if pt.len() > 32 => Some(String::from_utf8_lossy(&pt[32..]).into_owned()),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn safe_storage_key() -> Result<[u8; 16], String> {
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
use sha1::Sha1;
|
||||
let out = std::process::Command::new("security")
|
||||
.args(["find-generic-password", "-ws", "Chrome Safe Storage"])
|
||||
.output()
|
||||
.map_err(|e| format!("could not read Keychain (security command): {}", e))?;
|
||||
if !out.status.success() {
|
||||
return Err(
|
||||
"could not read the 'Chrome Safe Storage' key from Keychain \
|
||||
(you may be prompted to allow access — approve it and retry)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let pw = String::from_utf8_lossy(&out.stdout);
|
||||
let pw = pw.trim_end_matches('\n');
|
||||
let mut key = [0u8; 16];
|
||||
pbkdf2_hmac::<Sha1>(pw.as_bytes(), b"saltysalt", 1003, &mut key);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn safe_storage_key() -> Result<[u8; 16], String> {
|
||||
Err("cookies export/transfer is currently supported on macOS only".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn where_clause_filters_and_validates() {
|
||||
assert_eq!(build_where(None).unwrap(), "");
|
||||
assert_eq!(
|
||||
build_where(Some("claude.ai")).unwrap(),
|
||||
" WHERE host_key LIKE '%claude.ai'"
|
||||
);
|
||||
assert_eq!(
|
||||
build_where(Some("claude.ai, anthropic.com")).unwrap(),
|
||||
" WHERE host_key LIKE '%claude.ai' OR host_key LIKE '%anthropic.com'"
|
||||
);
|
||||
assert!(build_where(Some("evil' OR 1=1 --")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_conversion() {
|
||||
assert_eq!(chrome_epoch_to_unix(0), None);
|
||||
assert_eq!(chrome_epoch_to_unix(-5), None);
|
||||
// 13380163200000000 us since 1601 == 2025-01-01T00:00:00Z (1735689600 unix)
|
||||
assert_eq!(
|
||||
chrome_epoch_to_unix(13_380_163_200_000_000),
|
||||
Some(1_735_689_600.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_cdp_downgrades_samesite_none_without_secure() {
|
||||
let row = CookieRow {
|
||||
host_key: ".claude.ai".into(),
|
||||
name: "x".into(),
|
||||
encrypted_value: vec![],
|
||||
path: "/".into(),
|
||||
is_secure: false,
|
||||
is_httponly: true,
|
||||
samesite: 0, // None
|
||||
expires_utc: 0,
|
||||
};
|
||||
let c = to_cdp_cookie(&row, "v".into());
|
||||
assert_eq!(c["sameSite"], "Lax");
|
||||
assert_eq!(c["httpOnly"], true);
|
||||
assert_eq!(c.get("expires"), None);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod color;
|
||||
mod commands;
|
||||
mod connect;
|
||||
mod connection;
|
||||
mod cookie_export;
|
||||
mod doctor;
|
||||
mod findurl;
|
||||
mod flags;
|
||||
@@ -200,6 +201,64 @@ fn run_profiles(json_mode: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_cookies_export(args: &[String], flags: &Flags) {
|
||||
// Source profile comes from `--from <profile>`, falling back to the global
|
||||
// `--profile` (which the flag parser has already moved into flags.profile).
|
||||
let from = args
|
||||
.iter()
|
||||
.position(|a| a == "--from")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str())
|
||||
.or(flags.profile.as_deref());
|
||||
let profile = match from {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let msg = "cookies export needs a source profile: cookies export --from <profile> [--domain <d>]";
|
||||
if flags.json {
|
||||
print_json_error(msg);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), msg);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
};
|
||||
let domain = args
|
||||
.iter()
|
||||
.position(|a| a == "--domain")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map(|s| s.as_str());
|
||||
|
||||
match cookie_export::export_cookies(profile, domain) {
|
||||
Ok(cookies) => {
|
||||
if flags.json {
|
||||
print_json_value(json!({ "success": true, "data": cookies }));
|
||||
} else {
|
||||
// A JSON array ready for `cookies set --curl <file>`.
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string(&cookies).unwrap_or_else(|_| "[]".to_string())
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
color::dim(&format!(
|
||||
"{} cookies exported from \"{}\"",
|
||||
cookies.len(),
|
||||
profile
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if flags.json {
|
||||
print_json_error(&e);
|
||||
} else {
|
||||
eprintln!("{} {}", color::error_indicator(), e);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
let subcommand = args.get(1).map(|s| s.as_str());
|
||||
|
||||
@@ -639,6 +698,15 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle `cookies export` (doesn't need daemon): decrypt an on-disk Chrome
|
||||
// profile's cookies and print them as JSON for `cookies set --curl`.
|
||||
if clean.first().map(|s| s.as_str()) == Some("cookies")
|
||||
&& clean.get(1).map(|s| s.as_str()) == Some("export")
|
||||
{
|
||||
run_cookies_export(&clean, &flags);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle skills command (doesn't need daemon)
|
||||
if clean.first().map(|s| s.as_str()) == Some("skills") {
|
||||
skills::run_skills(&clean, flags.json);
|
||||
|
||||
@@ -3101,6 +3101,10 @@ Network: chrome-use network <action>
|
||||
Storage:
|
||||
cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires)
|
||||
Or: cookies set --curl <file> [--domain <host>] (auto-detects JSON/cURL/Cookie-header files)
|
||||
cookies export --from <profile> [--domain <d>[,<d>]]
|
||||
Decrypt another Chrome profile's cookies -> JSON for `cookies set --curl` (macOS)
|
||||
cookies transfer --from <profile> [--domain <d>[,<d>]]
|
||||
Copy a logged-in session from another profile into the connected browser (macOS)
|
||||
storage <local|session> Manage web storage
|
||||
|
||||
Tabs:
|
||||
|
||||
Reference in New Issue
Block a user