Skip to content
Merged
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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,36 @@ Replace:
- `your-mac.tailnet` with your Mac's Tailscale hostname (`tailscale status` on either machine)
- The pairing code and URL with the values printed by the Mac source wizard

### Linux CDP source (existing browser; no SQLite access)

When the authenticated browser already runs on Linux, set `cdp_source` in its
**source** configuration. AgentCookie reads the live jar through CDP, applies
the existing `blocklist.yaml`, and sends the normal paired encrypted envelope.
It never opens, copies, or decrypts Chromium's SQLite database.

```yaml
# ~/.config/agentcookie/source.yaml
sink:
url: http://your-sink.tailnet:9999/sync
peer:
hostname: your-sink
cdp_source:
enabled: true
endpoint: http://127.0.0.1:9230
```

The endpoint must be a bare `http` origin using a **literal loopback IP**
(`127.0.0.1` or `::1`); hostnames such as `localhost` are rejected so a hosts
or DNS override cannot redirect browser-control access off-host. Tailnet, LAN,
public, credential-bearing, and path/query endpoints are rejected. CDP-source
configuration is exclusive: do not set `chrome.db_path` or `browser`.
`export`, `agent-sync`, and `cmux-sync` also read from the configured CDP
endpoint in this mode, without falling back to another profile; their watch
modes poll rather than watching a SQLite file. `source --once` reads once;
`source --watch` polls every 10 seconds because CDP does not provide a
cookie-change event. CDP-source mode carries cookies only: it deliberately does
not scrape Local Storage or IndexedDB from an on-disk profile as a fallback.

### Attach to the existing Chrome (or start one as fallback)

On Grok Bot and most agent runtimes, Chrome is already running with a debug port. Probe before starting a new one:
Expand Down
146 changes: 146 additions & 0 deletions internal/cdpsource/source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Package cdpsource reads cookies from an already-running Chromium instance
// through a loopback-only Chrome DevTools Protocol endpoint. It never opens
// or copies the browser's encrypted SQLite cookie database.
package cdpsource

import (
"context"
"fmt"
"net"
"net/url"
"strings"

"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/storage"
"github.com/chromedp/chromedp"

"github.com/mvanhorn/agentcookie/internal/chrome"
)

const chromeEpochOffsetSec = 11644473600

// ValidateEndpoint permits only a root HTTP endpoint on loopback. A CDP source
// has browser-control authority, so it must never be pointed at a tailnet or
// public endpoint through configuration.
func ValidateEndpoint(raw string) error {
if raw == "" {
return fmt.Errorf("cdp source endpoint is required")
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("parse cdp source endpoint: %w", err)
}
if u.Scheme != "http" {
return fmt.Errorf("cdp source endpoint must use http, got %q", u.Scheme)
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" && u.Path != "/" {
return fmt.Errorf("cdp source endpoint must be a bare loopback origin")
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("cdp source endpoint host is required")
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return fmt.Errorf("cdp source endpoint must use a literal loopback IP, got %q", host)
}
return nil
}

// Read obtains the current browser cookie jar via CDP. Cookie values remain in
// memory and are returned only to the caller's encrypted AgentCookie transport.
func Read(ctx context.Context, endpoint string) ([]chrome.Cookie, error) {
if err := ValidateEndpoint(endpoint); err != nil {
return nil, err
}
allocator, cancel := chromedp.NewRemoteAllocator(ctx, strings.TrimSuffix(endpoint, "/")+"/json/version")
defer cancel()
browserCtx, browserCancel := chromedp.NewContext(allocator)
defer browserCancel()

// Initialize a target before issuing the browser-scoped Storage command.
// A freshly created chromedp context has no executor until its first Run.
if err := chromedp.Run(browserCtx); err != nil {
return nil, fmt.Errorf("initialize cdp source context: %w", err)
}
var cookies []*network.Cookie
if err := chromedp.Run(browserCtx, chromedp.ActionFunc(func(ctx context.Context) error {
browser := chromedp.FromContext(ctx).Browser
var err error
cookies, err = storage.GetCookies().Do(cdp.WithExecutor(ctx, browser))
return err
})); err != nil {
return nil, fmt.Errorf("read cookies via cdp: %w", err)
}
out := make([]chrome.Cookie, 0, len(cookies))
for _, cookie := range cookies {
out = append(out, convertCookie(cookie))
}
return out, nil
}

func convertCookie(in *network.Cookie) chrome.Cookie {
out := chrome.Cookie{
HostKey: in.Domain,
Name: in.Name,
Value: in.Value,
Path: in.Path,
IsSecure: boolInt(in.Secure),
IsHTTPOnly: boolInt(in.HTTPOnly),
Priority: priority(in.Priority),
SameSite: sameSite(in.SameSite),
SourceScheme: sourceScheme(in.SourceScheme),
SourcePort: int(in.SourcePort),
}
if !in.Session && in.Expires >= 0 {
out.ExpiresUTC = int64((in.Expires + chromeEpochOffsetSec) * 1e6)
out.HasExpires = 1
out.IsPersistent = 1
}
return out
}

func boolInt(value bool) int {
if value {
return 1
}
return 0
}

func priority(value network.CookiePriority) int {
switch value {
case network.CookiePriorityLow:
return 0
case network.CookiePriorityMedium:
return 1
case network.CookiePriorityHigh:
return 2
default:
return 1
}
}

func sameSite(value network.CookieSameSite) int {
switch value {
case network.CookieSameSiteNone:
return 0
case network.CookieSameSiteLax:
return 1
case network.CookieSameSiteStrict:
return 2
default:
return -1
}
}

func sourceScheme(value network.CookieSourceScheme) int {
switch value {
case network.CookieSourceSchemeNonSecure:
return 1
case network.CookieSourceSchemeSecure:
return 2
default:
return 0
}
}
114 changes: 114 additions & 0 deletions internal/cdpsource/source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package cdpsource

import (
"context"
"os"
"testing"
"time"

"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"

"github.com/mvanhorn/agentcookie/internal/livecdp"
)

func TestValidateEndpointAcceptsLoopbackHTTP(t *testing.T) {
for _, endpoint := range []string{
"http://127.0.0.1:9230",
"http://[::1]:9230",
} {
if err := ValidateEndpoint(endpoint); err != nil {
t.Fatalf("ValidateEndpoint(%q): %v", endpoint, err)
}
}
}

func TestValidateEndpointRejectsNonLoopbackOrUnsafeURLs(t *testing.T) {
for _, endpoint := range []string{
"",
"https://127.0.0.1:9230",
"http://localhost:9230",
"http://100.91.16.115:9230",
"http://example.com:9230",
"http://127.0.0.1:9230/json/version",
"http://127.0.0.1:9230?token=secret",
} {
if err := ValidateEndpoint(endpoint); err == nil {
t.Errorf("ValidateEndpoint(%q) succeeded, want error", endpoint)
}
}
}

func TestReadLiveChrome(t *testing.T) {
if os.Getenv("AGENTCOOKIE_LIVE_CDP_TEST") == "" {
t.Skip("set AGENTCOOKIE_LIVE_CDP_TEST=1 to run live CDP source test")
}

dir := t.TempDir()
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
owned, err := livecdp.LaunchOwnedChrome(ctx, "", dir, 9412, true)
if err != nil {
t.Fatalf("LaunchOwnedChrome: %v", err)
}
defer owned.Close()

allocator, allocatorCancel := chromedp.NewRemoteAllocator(ctx, owned.Endpoint)
defer allocatorCancel()
browserCtx, browserCancel := chromedp.NewContext(allocator)
defer browserCancel()
if err := chromedp.Run(browserCtx); err != nil {
t.Fatalf("connect to owned Chrome: %v", err)
}
if err := chromedp.Run(browserCtx, chromedp.ActionFunc(func(ctx context.Context) error {
return network.SetCookie("agentcookie_cdp_source", "test-value").WithDomain("example.com").WithPath("/").Do(ctx)
})); err != nil {
t.Fatalf("set test cookie: %v", err)
}

cookies, err := Read(ctx, owned.Endpoint)
if err != nil {
t.Fatalf("Read: %v", err)
}
for _, cookie := range cookies {
if cookie.HostKey == "example.com" && cookie.Name == "agentcookie_cdp_source" {
return
}
}
t.Fatal("Read did not return the cookie from the browser-scoped CDP cookie store")
}

func TestConvertCookiePreservesCDPFields(t *testing.T) {
in := &network.Cookie{
Domain: ".example.com",
Name: "session",
Value: "value",
Path: "/account",
Expires: 42,
Secure: true,
HTTPOnly: true,
Priority: network.CookiePriorityHigh,
SameSite: network.CookieSameSiteStrict,
SourceScheme: network.CookieSourceSchemeSecure,
SourcePort: 443,
Session: false,
}

got := convertCookie(in)
if got.HostKey != ".example.com" || got.Name != "session" || got.Value != "value" || got.Path != "/account" {
t.Fatalf("identity fields = %#v", got)
}
if got.IsSecure != 1 || got.IsHTTPOnly != 1 || got.Priority != 2 || got.SameSite != 2 || got.SourceScheme != 2 || got.SourcePort != 443 {
t.Fatalf("cookie attributes = %#v", got)
}
if got.ExpiresUTC == 0 || got.HasExpires != 1 || got.IsPersistent != 1 {
t.Fatalf("expiry fields = %#v", got)
}
}

func TestConvertCookieKeepsSessionCookieNonPersistent(t *testing.T) {
got := convertCookie(&network.Cookie{Domain: "example.com", Name: "session", Value: "v", Path: "/", Session: true})
if got.ExpiresUTC != 0 || got.HasExpires != 0 || got.IsPersistent != 0 {
t.Fatalf("session cookie fields = %#v", got)
}
}
76 changes: 43 additions & 33 deletions internal/cli/agentsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,21 +81,24 @@ func runAgentSync(cmd *cobra.Command, args []string) error {
return err
}

browserName := agentSyncBrowser
if browserName == "" {
browserName = cfg.Browser.Name
}
sourceBrowser, err := chrome.LookupBrowser(browserName)
if err != nil {
return err
}
password, err := chrome.SafeStoragePasswordFor(sourceBrowser)
if err != nil {
return err
}
key, err := chrome.DeriveAESKey(password)
if err != nil {
return err
var key []byte
if !cfg.CDPSource.Enabled {
browserName := agentSyncBrowser
if browserName == "" {
browserName = cfg.Browser.Name
}
sourceBrowser, err := chrome.LookupBrowser(browserName)
if err != nil {
return err
}
password, err := chrome.SafeStoragePasswordFor(sourceBrowser)
if err != nil {
return err
}
key, err = chrome.DeriveAESKey(password)
if err != nil {
return err
}
}
skipDBSC := agentSyncSkipDBSC || os.Getenv("AGENTCOOKIE_SKIP_DBSC_SUSPECT") == "1"
domainFilter := agentSyncDomains
Expand All @@ -107,7 +110,7 @@ func runAgentSync(cmd *cobra.Command, args []string) error {
if err != nil {
return nil, err
}
cookies, st, err := readFilteredCookies(cfg.Chrome.DBPath, blocklist, key, skipDBSC, time.Now().UTC())
cookies, st, err := readConfiguredCookies(cmd.Context(), cfg, blocklist, key, skipDBSC, time.Now().UTC())
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -174,24 +177,31 @@ func runAgentSync(cmd *cobra.Command, args []string) error {
// current cookies into every live context so a site the user just logged
// into in their real Chrome becomes logged-in in the agent browser too.
// A failed cycle is logged and the watcher keeps running.
w, err := watcher.New(watcher.Config{
CookiesPath: cfg.Chrome.DBPath,
LogLabel: "agentcookie agent-sync",
Push: func(context.Context) (int, error) {
if cfg.CDPSource.Enabled {
if err := runCDPSourceWatch(ctx, func(ctx context.Context) (int, error) {
return syncer.ReinjectAll()
},
OnEvent: func(ev watcher.Event) {
if agentSyncVerbose {
fmt.Fprintf(os.Stderr, "agentcookie agent-sync: %s\n", ev.String())
}
},
})
if err != nil {
return fmt.Errorf("init watcher: %w", err)
}
err = w.Run(ctx)
if err != nil && err != context.Canceled {
return err
}, cfg.CDPSource.Endpoint, agentSyncVerbose); err != nil && err != context.Canceled {
return err
}
} else {
w, err := watcher.New(watcher.Config{
CookiesPath: cfg.Chrome.DBPath,
LogLabel: "agentcookie agent-sync",
Push: func(context.Context) (int, error) {
return syncer.ReinjectAll()
},
OnEvent: func(ev watcher.Event) {
if agentSyncVerbose {
fmt.Fprintf(os.Stderr, "agentcookie agent-sync: %s\n", ev.String())
}
},
})
if err != nil {
return fmt.Errorf("init watcher: %w", err)
}
if err := w.Run(ctx); err != nil && err != context.Canceled {
return err
}
}
fmt.Fprintln(os.Stderr, "agentcookie agent-sync: stopped")
return nil
Expand Down
Loading