diff --git a/README.md b/README.md index 99ccbc2..fedde41 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/internal/cdpsource/source.go b/internal/cdpsource/source.go new file mode 100644 index 0000000..ffec5d0 --- /dev/null +++ b/internal/cdpsource/source.go @@ -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 + } +} diff --git a/internal/cdpsource/source_test.go b/internal/cdpsource/source_test.go new file mode 100644 index 0000000..432fc8d --- /dev/null +++ b/internal/cdpsource/source_test.go @@ -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) + } +} diff --git a/internal/cli/agentsync.go b/internal/cli/agentsync.go index 7663f71..772e1fa 100644 --- a/internal/cli/agentsync.go +++ b/internal/cli/agentsync.go @@ -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 @@ -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 } @@ -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 diff --git a/internal/cli/cmux_sync.go b/internal/cli/cmux_sync.go index 670dca5..f2af543 100644 --- a/internal/cli/cmux_sync.go +++ b/internal/cli/cmux_sync.go @@ -91,30 +91,29 @@ func runCmuxSync(cmd *cobra.Command, args []string) error { return err } - browserName := cmuxSyncBrowser - if browserName == "" { - browserName = cfg.Browser.Name - } - sourceBrowser, err := chrome.LookupBrowser(browserName) - if err != nil { - return err - } - password, err := cmuxSyncPasswordFor(sourceBrowser) - if err != nil { - if cmuxSyncWatch && chrome.IsKeychainAccessError(err) { - // In watch mode, a Keychain access failure means the binary has no - // grant yet. Exit 0 so launchd's KeepAlive does not restart the - // agent into a prompt storm. The operator must run wizard - // set-keychain-access before re-enabling the loop. - fmt.Fprintf(os.Stderr, "agentcookie cmux-sync --watch: Keychain not accessible; exiting cleanly so launchd does not restart.\nFix: %s\n", chrome.SafeStorageRemediation) - cmuxExitFunc(0) - return nil // unreachable in production; allows test assertions + var key []byte + if !cfg.CDPSource.Enabled { + browserName := cmuxSyncBrowser + if browserName == "" { + browserName = cfg.Browser.Name + } + sourceBrowser, err := chrome.LookupBrowser(browserName) + if err != nil { + return err + } + password, err := cmuxSyncPasswordFor(sourceBrowser) + if err != nil { + if cmuxSyncWatch && chrome.IsKeychainAccessError(err) { + fmt.Fprintf(os.Stderr, "agentcookie cmux-sync --watch: Keychain not accessible; exiting cleanly so launchd does not restart.\nFix: %s\n", chrome.SafeStorageRemediation) + cmuxExitFunc(0) + return nil + } + return err + } + key, err = chrome.DeriveAESKey(password) + if err != nil { + return err } - return err - } - key, err := chrome.DeriveAESKey(password) - if err != nil { - return err } skipDBSC := cmuxSyncSkipDBSC || os.Getenv("AGENTCOOKIE_SKIP_DBSC_SUSPECT") == "1" @@ -158,7 +157,7 @@ func runCmuxSync(cmd *cobra.Command, args []string) error { if err != nil { return 0, err } - cookies, st, err := readFilteredCookies(cfg.Chrome.DBPath, blocklist, key, skipDBSC, time.Now().UTC()) + cookies, st, err := readConfiguredCookies(ctx, cfg, blocklist, key, skipDBSC, time.Now().UTC()) if err != nil { return 0, err } @@ -214,6 +213,10 @@ func runCmuxSync(cmd *cobra.Command, args []string) error { // --watch: re-inject on every debounced Chrome Cookies change. A failed // cycle (cmux down) is logged and the watcher keeps running; the next // change retries. + if cfg.CDPSource.Enabled { + fmt.Fprintf(os.Stderr, "agentcookie cmux-sync --watch: polling %s, injecting into cmux\n", cfg.CDPSource.Endpoint) + return runCDPSourceWatch(cmd.Context(), syncOnce, cfg.CDPSource.Endpoint, cmuxSyncVerbose) + } w, err := watcher.New(watcher.Config{ CookiesPath: cfg.Chrome.DBPath, LogLabel: "agentcookie cmux-sync --watch", diff --git a/internal/cli/cookie_pipeline.go b/internal/cli/cookie_pipeline.go index 435e3db..81797db 100644 --- a/internal/cli/cookie_pipeline.go +++ b/internal/cli/cookie_pipeline.go @@ -1,9 +1,11 @@ package cli import ( + "context" "fmt" "time" + "github.com/mvanhorn/agentcookie/internal/cdpsource" "github.com/mvanhorn/agentcookie/internal/chrome" "github.com/mvanhorn/agentcookie/internal/config" "github.com/mvanhorn/agentcookie/internal/protocol" @@ -17,6 +19,23 @@ type readStats struct { dbsc dbscSummary } +var readCDPSource = cdpsource.Read + +// readConfiguredCookies reads cookies from the configured source without +// falling back between CDP and SQLite. CDP profiles never invoke browser +// discovery, Keychain, or SQLite; file-based profiles retain the legacy path. +func readConfiguredCookies(ctx context.Context, cfg *config.SourceConfig, blocklist *config.Blocklist, key []byte, skipDBSC bool, now time.Time) ([]chrome.Cookie, readStats, error) { + if cfg.CDPSource.Enabled { + all, err := readCDPSource(ctx, cfg.CDPSource.Endpoint) + if err != nil { + return nil, readStats{}, fmt.Errorf("read cookies from cdp source: %w", err) + } + cookies, stats := filterCookies(all, blocklist, skipDBSC, now) + return cookies, stats, nil + } + return readFilteredCookies(cfg.Chrome.DBPath, blocklist, key, skipDBSC, now) +} + // readFilteredCookies reads every cookie from the browser's Cookies DB, // applies the cookie policy, and runs the DBSC classifier -- the shared read // pipeline behind both `source` (push to a peer) and `cmux-sync` (local @@ -32,6 +51,14 @@ func readFilteredCookies(dbPath string, blocklist *config.Blocklist, key []byte, if err != nil { return nil, readStats{}, fmt.Errorf("read cookies: %w", err) } + filtered, stats := filterCookies(all, blocklist, skipDBSC, now) + return filtered, stats, nil +} + +// filterCookies applies the common policy and DBSC classification to cookie +// records regardless of whether they came from Chrome SQLite or a live CDP +// endpoint. The source remains fail-closed when the reader itself fails. +func filterCookies(all []chrome.Cookie, blocklist *config.Blocklist, skipDBSC bool, now time.Time) ([]chrome.Cookie, readStats) { st := readStats{totalRead: len(all)} all, st.droppedHosts = protocol.NewBlocklistMatcher(blocklist).Filter(all) @@ -46,5 +73,5 @@ func readFilteredCookies(dbPath string, blocklist *config.Blocklist, key []byte, skipped: len(dbscRes.Skipped), sample: dbscSampleReasons(dbscRes), } - return all, st, nil + return all, st } diff --git a/internal/cli/cookie_pipeline_test.go b/internal/cli/cookie_pipeline_test.go index 8227fe4..626c55d 100644 --- a/internal/cli/cookie_pipeline_test.go +++ b/internal/cli/cookie_pipeline_test.go @@ -1,7 +1,7 @@ package cli import ( - "path/filepath" + "context" "testing" "time" @@ -9,56 +9,28 @@ import ( "github.com/mvanhorn/agentcookie/internal/config" ) -func TestReadFilteredCookies(t *testing.T) { - key := []byte("0123456789abcdef") - dbPath := filepath.Join(t.TempDir(), "Cookies") - seedSourceCookiesDB(t, dbPath, []chrome.Cookie{ - {HostKey: ".blocked.com", Name: "b", Value: "1", Path: "/"}, - {HostKey: ".allowed.com", Name: "a", Value: "2", Path: "/"}, - }, key) +func TestReadConfiguredCookiesUsesCDPWithoutSQLiteFallback(t *testing.T) { + previous := readCDPSource + readCDPSource = func(_ context.Context, endpoint string) ([]chrome.Cookie, error) { + if endpoint != "http://127.0.0.1:9230" { + t.Fatalf("CDP endpoint = %q", endpoint) + } + return []chrome.Cookie{{HostKey: "example.com", Name: "session", Value: "value", Path: "/"}}, nil + } + t.Cleanup(func() { readCDPSource = previous }) - t.Run("nil blocklist passes everything", func(t *testing.T) { - cookies, st, err := readFilteredCookies(dbPath, nil, key, false, time.Now().UTC()) - if err != nil { - t.Fatalf("readFilteredCookies: %v", err) - } - if st.totalRead != 2 || len(cookies) != 2 { - t.Errorf("got totalRead=%d passing=%d, want 2/2", st.totalRead, len(cookies)) - } - if st.totalDropped != 0 { - t.Errorf("totalDropped=%d, want 0", st.totalDropped) - } - }) - - t.Run("blocklist drops the matching host only", func(t *testing.T) { - bl := &config.Blocklist{Version: 1, Domains: []config.BlocklistEntry{{Pattern: "%.blocked.com"}}} - cookies, st, err := readFilteredCookies(dbPath, bl, key, false, time.Now().UTC()) - if err != nil { - t.Fatalf("readFilteredCookies: %v", err) - } - if len(cookies) != 1 || cookies[0].HostKey != ".allowed.com" { - t.Fatalf("expected only .allowed.com to pass, got %+v", cookies) - } - if st.totalDropped != 1 { - t.Errorf("totalDropped=%d, want 1", st.totalDropped) - } - }) - - t.Run("allowlist passes the matching host only", func(t *testing.T) { - bl := &config.Blocklist{ - Version: 1, - Policy: config.CookiePolicyAllowlist, - Domains: []config.BlocklistEntry{{Pattern: "%.allowed.com"}}, - } - cookies, st, err := readFilteredCookies(dbPath, bl, key, false, time.Now().UTC()) - if err != nil { - t.Fatalf("readFilteredCookies: %v", err) - } - if len(cookies) != 1 || cookies[0].HostKey != ".allowed.com" { - t.Fatalf("expected only .allowed.com to pass, got %+v", cookies) - } - if st.totalDropped != 1 { - t.Errorf("totalDropped=%d, want 1", st.totalDropped) - } - }) + cfg := &config.SourceConfig{ + Chrome: config.ChromeRef{DBPath: "/must-not-read/Cookies"}, + CDPSource: config.CDPSourceRef{Enabled: true, Endpoint: "http://127.0.0.1:9230"}, + } + cookies, stats, err := readConfiguredCookies(context.Background(), cfg, &config.Blocklist{Version: 1}, nil, false, time.Now().UTC()) + if err != nil { + t.Fatalf("read configured CDP cookies: %v", err) + } + if len(cookies) != 1 || cookies[0].HostKey != "example.com" { + t.Fatalf("cookies = %#v", cookies) + } + if stats.totalRead != 1 { + t.Fatalf("total read = %d, want 1", stats.totalRead) + } } diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 9d087d4..d372f8d 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -113,7 +113,11 @@ func runDoctor(cmd *cobra.Command, args []string) error { return tsclient.RequireTailnetIP(context.Background()) }, LoadSourceState: func() (*state.SourceState, error) { - return state.LoadSource(state.SourcePath(home)) + cfg, err := config.LoadSource(common.ConfigDir) + if err != nil { + return nil, err + } + return state.LoadSource(sourceStatePathForConfig(cfg, common.ConfigDir, home)) }, LoadSinkState: func() (*state.SinkState, error) { return state.LoadSink(state.SinkPath(home)) diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index fe6409b..0fe988d 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -1068,6 +1068,7 @@ func TestCheckDaemonBinaryPath(t *testing.T) { }) t.Run("no plist files is OK (Linux or fresh install)", func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) srcCfg := &config.SourceConfig{} sinkCfg := &config.SinkConfig{} c := checkDaemonBinaryPath(srcCfg, sinkCfg) diff --git a/internal/cli/export.go b/internal/cli/export.go index ac30323..89ca078 100644 --- a/internal/cli/export.go +++ b/internal/cli/export.go @@ -69,26 +69,29 @@ func runExport(cmd *cobra.Command, args []string) error { return err } - browserName := exportBrowser - 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 := exportBrowser + 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 := exportSkipDBSC || os.Getenv("AGENTCOOKIE_SKIP_DBSC_SUSPECT") == "1" - 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 err } diff --git a/internal/cli/source.go b/internal/cli/source.go index 1203a8b..6071444 100644 --- a/internal/cli/source.go +++ b/internal/cli/source.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "time" @@ -41,6 +42,10 @@ var ( // specific resolution behaviors (e.g., ErrAmbiguousPeer). var resolveSinkURL = tsclient.ResolveSinkURL +// loadSecretsPayload is replaceable by tests. CDP-source profiles are +// cookie-only, so they must not inherit process-home secrets bus state. +var loadSecretsPayload = secretsbus.LoadPayloadWithDiscovery + // SetResolveSinkURLForTesting replaces resolveSinkURL with the given // function and returns a restore func. Test-only seam. func SetResolveSinkURLForTesting(f func(ctx context.Context, rawURL string) (string, error)) func() { @@ -101,19 +106,23 @@ func runSource(cmd *cobra.Command, args []string) error { return err } - sourceBrowser, err := chrome.LookupBrowser(cfg.Browser.Name) - if err != nil { - return err - } - password, err := chrome.SafeStoragePasswordFor(sourceBrowser) - if err != nil { - // SafeStoragePasswordFor already prefixes its error with - // "read from Keychain ..."; don't double the prefix. - return err - } - key, err := chrome.DeriveAESKey(password) - if err != nil { - return err + var key []byte + var sourceBrowser chrome.Browser + if !cfg.CDPSource.Enabled { + sourceBrowser, err = chrome.LookupBrowser(cfg.Browser.Name) + if err != nil { + return err + } + password, err := chrome.SafeStoragePasswordFor(sourceBrowser) + if err != nil { + // SafeStoragePasswordFor already prefixes its error with + // "read from Keychain ..."; don't double the prefix. + return err + } + key, err = chrome.DeriveAESKey(password) + if err != nil { + return err + } } // Per-sink transport secrets are resolved inside the fan-out loop // (pushOnce), not here: resolving all secrets up front would let one @@ -123,7 +132,7 @@ func runSource(cmd *cobra.Command, args []string) error { // State writer for `agentcookie status` to read. home, _ := os.UserHomeDir() - stateWriter := state.NewWriter(state.SourcePath(home)) + stateWriter := state.NewWriter(sourceStatePath(cfg.CDPSource.Enabled, common.ConfigDir, home)) legacySinkURL := "" if len(sinks) > 0 { legacySinkURL = sinks[0].URL @@ -158,6 +167,10 @@ func runSource(cmd *cobra.Command, args []string) error { return err } + if cfg.CDPSource.Enabled { + return runCDPSourceWatch(cmd.Context(), push, cfg.CDPSource.Endpoint, sourceVerbose) + } + // --watch mode: long-running fsnotify watcher across all three sync // surfaces (cookies + Local Storage + IndexedDB). v0.7 single debounce // window: a write to any surface coalesces into one full envelope push. @@ -214,6 +227,43 @@ func runSource(cmd *cobra.Command, args []string) error { return w.Run(cmd.Context()) } +func sourceStatePath(cdpSource bool, configDir, home string) string { + if cdpSource { + return filepath.Join(configDir, "state", "source-state.json") + } + return state.SourcePath(home) +} + +func sourceStatePathForConfig(cfg *config.SourceConfig, configDir, home string) string { + return sourceStatePath(cfg != nil && cfg.CDPSource.Enabled, configDir, home) +} + +const cdpSourcePollInterval = 10 * time.Second + +// runCDPSourceWatch polls the browser's CDP jar rather than watching encrypted +// SQLite files. CDP has no cookie-change event, so polling is deliberately +// bounded; every cycle follows the same allowlist and encrypted push path. +func runCDPSourceWatch(ctx context.Context, push func(context.Context) (int, error), endpoint string, verbose bool) error { + if _, err := push(ctx); err != nil { + return err + } + if verbose { + fmt.Fprintf(os.Stderr, "agentcookie source --watch: polling loopback CDP at %s every %s\n", endpoint, cdpSourcePollInterval) + } + ticker := time.NewTicker(cdpSourcePollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if _, err := push(ctx); err != nil && verbose { + fmt.Fprintf(os.Stderr, "agentcookie source --watch: CDP poll failed: %v\n", err) + } + } + } +} + func pushWithFreshBlocklist( ctx context.Context, cfg *config.SourceConfig, @@ -303,6 +353,15 @@ func recordSourcePushResult( srcState.LastPush = now } } + // A successful cycle with no eligible cookies or secrets has no per-sink + // transport result, but it is still a healthy source read. pushOnce uses a + // non-nil empty slice for that case; nil is a dry-run and intentionally + // leaves the durable health state unchanged. + if err == nil && results != nil && len(results) == 0 { + srcState.TotalPushes++ + srcState.LastPushCount = 0 + srcState.LastPush = now + } srcState.LastDBSCWarned = dbsc.warned srcState.LastDBSCSkipped = dbsc.skipped srcState.LastDBSCSample = dbsc.sample @@ -348,9 +407,20 @@ func pushOnce( // Shared read pipeline (decrypt -> cookie policy -> DBSC). See // readFilteredCookies in cookie_pipeline.go; `source` and `cmux-sync` // both use it so they filter identically. - all, st, err := readFilteredCookies(cfg.Chrome.DBPath, blocklist, key, skipDBSC, time.Now().UTC()) - if err != nil { - return nil, dbsc, err + var all []chrome.Cookie + var st readStats + var err error + if cfg.CDPSource.Enabled { + all, err = readCDPSource(ctx, cfg.CDPSource.Endpoint) + if err != nil { + return nil, dbsc, fmt.Errorf("read cookies from cdp source: %w", err) + } + all, st = filterCookies(all, blocklist, skipDBSC, time.Now().UTC()) + } else { + all, st, err = readFilteredCookies(cfg.Chrome.DBPath, blocklist, key, skipDBSC, time.Now().UTC()) + if err != nil { + return nil, dbsc, err + } } totalRead := st.totalRead totalDropped := st.totalDropped @@ -384,7 +454,11 @@ func pushOnce( // [secrets.file] in place, applies sync policy, and merges. v1 bus // wins per-key over v2 read-in-place per spec section 10.3. home, _ := os.UserHomeDir() - secretsPayload, secretsErrs := secretsbus.LoadPayloadWithDiscovery(home) + var secretsPayload *secretsbus.Payload + var secretsErrs []error + if !cfg.CDPSource.Enabled { + secretsPayload, secretsErrs = loadSecretsPayload(home) + } for _, e := range secretsErrs { fmt.Fprintf(os.Stderr, "agentcookie source: secrets-bus: %v\n", e) } @@ -410,10 +484,17 @@ func pushOnce( "posted": false, } - if dryRun || (len(all) == 0 && secretsCLICount == 0) { + if dryRun { _ = emit(result, fmt.Sprintf("agentcookie source: %d cookies after cookie policy (%s), %d secrets clis (dry-run=%v)%s\n", len(all), blocklist.CookiePolicySummary(), secretsCLICount, dryRun, dbscNote(dbsc))) return nil, dbsc, nil } + if len(all) == 0 && secretsCLICount == 0 { + _ = emit(result, fmt.Sprintf("agentcookie source: %d cookies after cookie policy (%s), %d secrets clis (dry-run=%v)%s\n", len(all), blocklist.CookiePolicySummary(), secretsCLICount, dryRun, dbscNote(dbsc))) + // A non-nil empty result records that this was a successful source + // cycle with no delivery attempt. nil remains reserved for dry-runs, + // which must not make source health look current. + return []sinkResult{}, dbsc, nil + } // v0.7: pack Local Storage and IndexedDB alongside cookies from the // configured source browser/profile. The envelope carries the bytes, the @@ -424,29 +505,34 @@ func pushOnce( // underneath us; fail loud rather than silently packing Chrome's profile // (which would mismatch the cookies/localStorage/IndexedDB the watcher and // the rest of this push are reading from the configured browser). - sourceBrowser, err := chrome.LookupBrowser(cfg.Browser.Name) - if err != nil { - return nil, dbsc, err - } var lsTarball []byte var idbTarball []byte var idbSkipped []string - if lt, _, err := chromedirsync.Pack(sourceBrowser.LocalStorageLevelDB(cfg.Browser.Profile), 0); err == nil { - lsTarball = lt - } else if !errors.Is(err, chromedirsync.ErrSourceMissing) { - fmt.Fprintf(os.Stderr, "agentcookie source: localStorage pack failed (%v); continuing without it\n", err) - } - // IndexedDB is opt-in for v0.7: typical user dirs are 400MB+ (Gmail caches, - // Slack message history) and inlining that in the JSON envelope blows - // past the source-side POST timeout. Most PP CLIs auth via localStorage - // or cookies; IndexedDB is rarely an auth-state surface in practice. - // Set AGENTCOOKIE_SYNC_INDEXEDDB=1 to opt in. - if os.Getenv("AGENTCOOKIE_SYNC_INDEXEDDB") == "1" { - if it, sk, err := chromedirsync.Pack(sourceBrowser.IndexedDBDir(cfg.Browser.Profile), 5*1024*1024); err == nil { - idbTarball = it - idbSkipped = sk + if !cfg.CDPSource.Enabled { + // CDP source mode intentionally carries cookies only: localStorage and + // IndexedDB remain in the existing browser and must not be scraped from + // an on-disk profile as a fallback. + sourceBrowser, err := chrome.LookupBrowser(cfg.Browser.Name) + if err != nil { + return nil, dbsc, err + } + if lt, _, err := chromedirsync.Pack(sourceBrowser.LocalStorageLevelDB(cfg.Browser.Profile), 0); err == nil { + lsTarball = lt } else if !errors.Is(err, chromedirsync.ErrSourceMissing) { - fmt.Fprintf(os.Stderr, "agentcookie source: indexedDB pack failed (%v); continuing without it\n", err) + fmt.Fprintf(os.Stderr, "agentcookie source: localStorage pack failed (%v); continuing without it\n", err) + } + // IndexedDB is opt-in for v0.7: typical user dirs are 400MB+ (Gmail caches, + // Slack message history) and inlining that in the JSON envelope blows + // past the source-side POST timeout. Most PP CLIs auth via localStorage + // or cookies; IndexedDB is rarely an auth-state surface in practice. + // Set AGENTCOOKIE_SYNC_INDEXEDDB=1 to opt in. + if os.Getenv("AGENTCOOKIE_SYNC_INDEXEDDB") == "1" { + if it, sk, err := chromedirsync.Pack(sourceBrowser.IndexedDBDir(cfg.Browser.Profile), 5*1024*1024); err == nil { + idbTarball = it + idbSkipped = sk + } else if !errors.Is(err, chromedirsync.ErrSourceMissing) { + fmt.Fprintf(os.Stderr, "agentcookie source: indexedDB pack failed (%v); continuing without it\n", err) + } } } diff --git a/internal/cli/source_test.go b/internal/cli/source_test.go index 8b8be88..1f392e4 100644 --- a/internal/cli/source_test.go +++ b/internal/cli/source_test.go @@ -22,6 +22,7 @@ import ( "github.com/mvanhorn/agentcookie/internal/chrome" "github.com/mvanhorn/agentcookie/internal/config" "github.com/mvanhorn/agentcookie/internal/protocol" + "github.com/mvanhorn/agentcookie/internal/secretsbus" "github.com/mvanhorn/agentcookie/internal/state" "github.com/mvanhorn/agentcookie/internal/transport" "github.com/mvanhorn/agentcookie/internal/tsclient" @@ -107,6 +108,143 @@ domains: } } +func TestCDPSourceStatePathIsScopedToConfigDirectory(t *testing.T) { + configDir := filepath.Join(t.TempDir(), "agentcookie-generic") + if got, want := sourceStatePath(true, configDir, "/irrelevant"), filepath.Join(configDir, "state", "source-state.json"); got != want { + t.Fatalf("CDP source state path = %q, want %q", got, want) + } + if got, want := sourceStatePathForConfig(&config.SourceConfig{CDPSource: config.CDPSourceRef{Enabled: true}}, configDir, "/irrelevant"), filepath.Join(configDir, "state", "source-state.json"); got != want { + t.Fatalf("configured CDP source state path = %q, want %q", got, want) + } + if got, want := sourceStatePathForConfig(nil, configDir, "/home/test"), state.SourcePath("/home/test"); got != want { + t.Fatalf("default source state path = %q, want %q", got, want) + } +} + +func TestSourcePushCDPSourceAppliesAllowlistWithoutSQLiteFallback(t *testing.T) { + fx := newSourcePushFixture(t, nil) + fx.cfg.CDPSource = config.CDPSourceRef{Enabled: true, Endpoint: "http://127.0.0.1:9230"} + writeCLIFile(t, filepath.Join(fx.configDir, "blocklist.yaml"), ` +version: 1 +policy: allowlist +domains: + - pattern: "example.com" + - pattern: "%.example.com" +`) + + previous := readCDPSource + readCDPSource = func(_ context.Context, endpoint string) ([]chrome.Cookie, error) { + if endpoint != "http://127.0.0.1:9230" { + t.Fatalf("endpoint = %q", endpoint) + } + return []chrome.Cookie{ + {HostKey: "example.com", Name: "apex", Value: "a", Path: "/"}, + {HostKey: "www.example.com", Name: "sub", Value: "s", Path: "/"}, + {HostKey: "blocked.com", Name: "blocked", Value: "b", Path: "/"}, + }, nil + } + t.Cleanup(func() { readCDPSource = previous }) + previousSecrets := loadSecretsPayload + loadSecretsPayload = func(string) (*secretsbus.Payload, []error) { + t.Fatal("CDP source must not read the machine-wide secrets bus") + return nil, nil + } + t.Cleanup(func() { loadSecretsPayload = previousSecrets }) + + if _, err := fx.push(); err != nil { + t.Fatalf("push: %v", err) + } + if got := fx.hostsAt(0); !reflect.DeepEqual(got, []string{"example.com", "www.example.com"}) { + t.Fatalf("CDP source pushed hosts = %v", got) + } + if got := fx.capture.envelopeAt(0).Secrets; len(got) != 0 { + t.Fatalf("CDP source transported secrets = %v, want none", got) + } +} + +func TestSourcePushEmptyCycleRecordsHealthySourceState(t *testing.T) { + fx := newSourcePushFixture(t, nil) + statePath := filepath.Join(t.TempDir(), "source-state.json") + stateWriter := state.NewWriter(statePath) + + n, err := pushWithFreshBlocklist(context.Background(), fx.cfg, fx.key, false, false, false, fx.srcState, stateWriter) + if err != nil { + t.Fatalf("empty push: %v", err) + } + if n != 0 { + t.Fatalf("empty push count = %d, want 0", n) + } + if got := fx.batchCount(); got != 0 { + t.Fatalf("empty push should not POST, got %d requests", got) + } + if got := fx.srcState.TotalPushes; got != 1 { + t.Errorf("TotalPushes = %d, want 1 after a successful empty cycle", got) + } + if fx.srcState.LastPush.IsZero() { + t.Fatal("LastPush should be recorded after a successful empty cycle") + } + if got := fx.srcState.LastPushCount; got != 0 { + t.Errorf("LastPushCount = %d, want 0", got) + } + if got := fx.srcState.TotalFailures; got != 0 { + t.Errorf("TotalFailures = %d, want 0", got) + } + persisted, err := state.LoadSource(statePath) + if err != nil { + t.Fatalf("load persisted source state: %v", err) + } + if persisted == nil { + t.Fatal("successful empty cycle did not persist source health") + } + if got := persisted.TotalPushes; got != 1 { + t.Errorf("persisted TotalPushes = %d, want 1", got) + } + if persisted.LastPush.IsZero() { + t.Error("persisted LastPush should be recorded after a successful empty cycle") + } + if got := persisted.LastPushCount; got != 0 { + t.Errorf("persisted LastPushCount = %d, want 0", got) + } +} + +func TestSourcePushDryRunDoesNotRecordSourceHealth(t *testing.T) { + fx := newSourcePushFixture(t, []chrome.Cookie{ + {HostKey: ".example.com", Name: "session", Value: "value", Path: "/"}, + }) + statePath := filepath.Join(t.TempDir(), "source-state.json") + stateWriter := state.NewWriter(statePath) + + n, err := pushWithFreshBlocklist(context.Background(), fx.cfg, fx.key, true, false, false, fx.srcState, stateWriter) + if err != nil { + t.Fatalf("dry-run: %v", err) + } + if n != 0 { + t.Fatalf("dry-run push count = %d, want 0", n) + } + if got := fx.batchCount(); got != 0 { + t.Fatalf("dry-run should not POST, got %d requests", got) + } + if got := fx.srcState.TotalPushes; got != 0 { + t.Errorf("dry-run TotalPushes = %d, want 0", got) + } + if !fx.srcState.LastPush.IsZero() { + t.Fatal("dry-run should not update LastPush") + } + persisted, err := state.LoadSource(statePath) + if err != nil { + t.Fatalf("load dry-run state: %v", err) + } + if persisted == nil { + t.Fatal("dry-run should persist its non-health state envelope") + } + if got := persisted.TotalPushes; got != 0 { + t.Errorf("persisted dry-run TotalPushes = %d, want 0", got) + } + if !persisted.LastPush.IsZero() { + t.Errorf("persisted dry-run should not update LastPush, got %s", persisted.LastPush) + } +} + func TestSourcePushMalformedBlocklistSkipsPushAndRecordsFailure(t *testing.T) { fx := newSourcePushFixture(t, []chrome.Cookie{ {HostKey: ".blocked.com", Name: "blocked", Value: "b", Path: "/"}, @@ -258,10 +396,11 @@ func (f *sourcePushFixture) hostsAt(i int) []string { } type sourceCapture struct { - secret string - mu sync.Mutex - batches [][]chrome.Cookie - urls []string + secret string + mu sync.Mutex + batches [][]chrome.Cookie + urls []string + envelopes []protocol.SyncEnvelope // failURL, when non-empty, makes a POST to that exact URL return a // non-200 so tests can exercise per-sink failure isolation. failURL string @@ -300,6 +439,7 @@ func (c *sourceCapture) RoundTrip(req *http.Request) (*http.Response, error) { c.mu.Lock() c.batches = append(c.batches, append([]chrome.Cookie(nil), envelope.Cookies...)) c.urls = append(c.urls, req.URL.String()) + c.envelopes = append(c.envelopes, envelope) c.mu.Unlock() return &http.Response{ @@ -323,6 +463,12 @@ func (c *sourceCapture) batchCount() int { return len(c.batches) } +func (c *sourceCapture) envelopeAt(i int) protocol.SyncEnvelope { + c.mu.Lock() + defer c.mu.Unlock() + return c.envelopes[i] +} + func (c *sourceCapture) batchAt(i int) []chrome.Cookie { c.mu.Lock() defer c.mu.Unlock() diff --git a/internal/cli/status.go b/internal/cli/status.go index 89b7623..e0ade1c 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -66,7 +66,7 @@ var statusCmd = &cobra.Command{ } else { st.Errors = append(st.Errors, "blocklist.yaml: "+err.Error()) } - if ss, err := state.LoadSource(state.SourcePath(home)); err == nil && ss != nil { + if ss, err := state.LoadSource(sourceStatePathForConfig(st.SourceConfig, common.ConfigDir, home)); err == nil && ss != nil { st.SourceState = ss } if sk, err := state.LoadSink(state.SinkPath(home)); err == nil && sk != nil { @@ -105,8 +105,12 @@ var statusCmd = &cobra.Command{ fmt.Printf("agentcookie %s\n", st.Version) fmt.Printf("config dir: %s\n", st.ConfigDir) if st.SourceConfig != nil { - fmt.Printf(" source -> %s\n", st.SourceConfig.Sink.URL) - fmt.Printf(" chrome db: %s\n", st.SourceConfig.Chrome.DBPath) + fmt.Printf(" source -> %s\n", sinkURLList(st.SourceConfig.ResolvedSinks())) + if st.SourceConfig.CDPSource.Enabled { + fmt.Printf(" cdp source: %s\n", st.SourceConfig.CDPSource.Endpoint) + } else { + fmt.Printf(" chrome db: %s\n", st.SourceConfig.Chrome.DBPath) + } } else { fmt.Println(" source: not configured") } diff --git a/internal/cli/status_test.go b/internal/cli/status_test.go index 3bf9efc..67aced7 100644 --- a/internal/cli/status_test.go +++ b/internal/cli/status_test.go @@ -37,6 +37,46 @@ domains: } } +func TestStatusReportsResolvedSourceSinks(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", t.TempDir()) + writeCLIFile(t, filepath.Join(dir, "source.yaml"), ` +sinks: + - url: http://first.test:9999/sync + peer: first + - url: http://second.test:9999/sync + peer: second +cdp_source: + enabled: true + endpoint: http://127.0.0.1:9222 +`) + + oldDir := common.ConfigDir + oldJSON := common.JSON + common.ConfigDir = dir + common.JSON = false + t.Cleanup(func() { + common.ConfigDir = oldDir + common.JSON = oldJSON + }) + + out := captureStdout(t, func() { + if err := statusCmd.RunE(commandWithOutput(&bytes.Buffer{}), nil); err != nil { + t.Fatalf("status: %v", err) + } + }) + want := "source -> http://first.test:9999/sync, http://second.test:9999/sync" + if !strings.Contains(out, want) { + t.Fatalf("status should report resolved source sinks %q, got %q", want, out) + } + if !strings.Contains(out, "cdp source: http://127.0.0.1:9222") { + t.Fatalf("status should identify the active CDP source, got %q", out) + } + if strings.Contains(out, "chrome db:") { + t.Fatalf("status must not present a SQLite source for a CDP source, got %q", out) + } +} + func captureStdout(t *testing.T, fn func()) string { t.Helper() old := os.Stdout diff --git a/internal/cli/wizard.go b/internal/cli/wizard.go index a24570a..b9b243c 100644 --- a/internal/cli/wizard.go +++ b/internal/cli/wizard.go @@ -754,7 +754,7 @@ peer: // renderSourceYAMLSinks renders a multi-sink source.yaml body, always as an // explicit sinks: list (never the legacy scalar sink:/peer:), preserving the -// loaded config's chrome / browser / security / cmux settings. Written via +// loaded config's selected source reader / security / cmux settings. Written via // template rather than yaml.Marshal on purpose: yaml.v3 does not omit a // zero-value legacy Sink struct even with omitempty, so marshaling a // sinks-only config would emit a stray empty sink: block. @@ -767,31 +767,47 @@ func renderSourceYAMLSinks(cfg *config.SourceConfig, sinks []config.SinkTarget) fmt.Fprintf(&b, " peer: %s\n", s.Peer) } } - dbPath := cfg.Chrome.DBPath - if dbPath == "" { - dbPath = "~/Library/Application Support/Google/Chrome/Default/Cookies" - } - b.WriteString("chrome:\n") - fmt.Fprintf(&b, " db_path: %s\n", dbPath) - if cfg.Browser.Name != "" || cfg.Browser.Profile != "" { - b.WriteString("browser:\n") - if cfg.Browser.Name != "" { - fmt.Fprintf(&b, " name: %s\n", cfg.Browser.Name) + // CDP is mutually exclusive with the on-disk Chrome/browser reader. + // Preserve the selected mode without emitting a default SQLite path that + // would make the rewritten config invalid at its next load. + if cfg.CDPSource.Enabled { + b.WriteString("cdp_source:\n enabled: true\n") + if cfg.CDPSource.Endpoint != "" { + fmt.Fprintf(&b, " endpoint: %s\n", cfg.CDPSource.Endpoint) } - if cfg.Browser.Profile != "" { - fmt.Fprintf(&b, " profile: %s\n", cfg.Browser.Profile) + } else { + dbPath := cfg.Chrome.DBPath + if dbPath == "" { + dbPath = "~/Library/Application Support/Google/Chrome/Default/Cookies" + } + b.WriteString("chrome:\n") + fmt.Fprintf(&b, " db_path: %s\n", dbPath) + if cfg.Browser.Name != "" || cfg.Browser.Profile != "" { + b.WriteString("browser:\n") + if cfg.Browser.Name != "" { + fmt.Fprintf(&b, " name: %s\n", cfg.Browser.Name) + } + if cfg.Browser.Profile != "" { + fmt.Fprintf(&b, " profile: %s\n", cfg.Browser.Profile) + } } } if cfg.Security.SharedSecret != "" { b.WriteString("security:\n") fmt.Fprintf(&b, " shared_secret: %s\n", cfg.Security.SharedSecret) } - if cfg.Cmux.Enabled { + if cfg.Cmux.Enabled || cfg.Cmux.CmuxPath != "" || len(cfg.Cmux.DomainFilter) > 0 { b.WriteString("cmux:\n") fmt.Fprintf(&b, " enabled: %v\n", cfg.Cmux.Enabled) if cfg.Cmux.CmuxPath != "" { fmt.Fprintf(&b, " cmux_path: %s\n", cfg.Cmux.CmuxPath) } + if len(cfg.Cmux.DomainFilter) > 0 { + b.WriteString(" domain_filter:\n") + for _, pattern := range cfg.Cmux.DomainFilter { + fmt.Fprintf(&b, " - %q\n", pattern) + } + } } return b.String() } diff --git a/internal/cli/wizard_test.go b/internal/cli/wizard_test.go index 8640715..ed9b9c6 100644 --- a/internal/cli/wizard_test.go +++ b/internal/cli/wizard_test.go @@ -569,6 +569,89 @@ func TestBuildAddSinkYAMLMigratesLegacyAndAppends(t *testing.T) { } } +func TestBuildAddSinkYAMLPreservesCDPSource(t *testing.T) { + cfg := &config.SourceConfig{ + Sinks: []config.SinkTarget{{URL: "http://first.test:9999/sync", Peer: "first"}}, + CDPSource: config.CDPSourceRef{ + Enabled: true, + Endpoint: "http://127.0.0.1:9222", + }, + } + yamlBody, err := buildAddSinkYAML(cfg, "http://second.test:9999/sync", "second") + if err != nil { + t.Fatalf("buildAddSinkYAML: %v", err) + } + if strings.Contains(yamlBody, "\nchrome:\n") || strings.Contains(yamlBody, "\nbrowser:\n") { + t.Fatalf("CDP source render must not add SQLite/browser source settings:\n%s", yamlBody) + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "source.yaml"), []byte(yamlBody), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + loaded, err := config.LoadSource(dir) + if err != nil { + t.Fatalf("LoadSource on rendered CDP YAML: %v\n%s", err, yamlBody) + } + if !loaded.CDPSource.Enabled || loaded.CDPSource.Endpoint != "http://127.0.0.1:9222" { + t.Fatalf("CDP source changed during add-sink: %+v", loaded.CDPSource) + } + if got := loaded.ResolvedSinks(); len(got) != 2 || got[0].Peer != "first" || got[1].Peer != "second" { + t.Fatalf("rendered sinks = %+v, want first and second", got) + } +} + +func TestBuildAddSinkYAMLPreservesCmuxDomainFilter(t *testing.T) { + tests := []struct { + name string + cmux config.CmuxRef + }{ + { + name: "enabled loop", + cmux: config.CmuxRef{ + Enabled: true, + CmuxPath: "/opt/cmux/bin/cmux", + DomainFilter: []string{"%github.com", "%.example.com"}, + }, + }, + { + name: "manual sync", + cmux: config.CmuxRef{ + CmuxPath: "/opt/cmux/bin/cmux", + DomainFilter: []string{"%github.com", "%.example.com"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.SourceConfig{ + Sinks: []config.SinkTarget{{URL: "http://first.test:9999/sync", Peer: "first"}}, + Chrome: config.ChromeRef{DBPath: "/tmp/Cookies"}, + Cmux: tt.cmux, + } + yamlBody, err := buildAddSinkYAML(cfg, "http://second.test:9999/sync", "second") + if err != nil { + t.Fatalf("buildAddSinkYAML: %v", err) + } + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "source.yaml"), []byte(yamlBody), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + loaded, err := config.LoadSource(dir) + if err != nil { + t.Fatalf("LoadSource on rendered YAML: %v\n%s", err, yamlBody) + } + if loaded.Cmux.Enabled != tt.cmux.Enabled || loaded.Cmux.CmuxPath != tt.cmux.CmuxPath { + t.Fatalf("cmux settings changed during add-sink: got %+v, want %+v", loaded.Cmux, tt.cmux) + } + if got, want := strings.Join(loaded.Cmux.DomainFilter, ","), strings.Join(tt.cmux.DomainFilter, ","); got != want { + t.Fatalf("cmux domain_filter = %q, want %q\n%s", got, want, yamlBody) + } + }) + } +} + func TestBuildAddSinkYAMLRejectsDuplicatePeer(t *testing.T) { cfg := &config.SourceConfig{ Sinks: []config.SinkTarget{{URL: "http://a.test/sync", Peer: "alpha"}}, diff --git a/internal/config/config.go b/internal/config/config.go index 611a6e6..47f284f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "sort" "strings" + "github.com/mvanhorn/agentcookie/internal/cdpsource" "gopkg.in/yaml.v3" ) @@ -26,12 +27,16 @@ type SourceConfig struct { // from Sink+Peer (see ResolvedSinks), so every pre-multi-sink // source.yaml keeps working with no migration. omitempty keeps a // legacy-only config from emitting an empty sinks: key. - Sinks []SinkTarget `yaml:"sinks,omitempty" json:"sinks,omitempty"` - Sink SinkRef `yaml:"sink,omitempty" json:"sink,omitempty"` - Chrome ChromeRef `yaml:"chrome" json:"chrome"` - Browser BrowserRef `yaml:"browser,omitempty" json:"browser,omitempty"` - Peer PeerRef `yaml:"peer,omitempty" json:"peer,omitempty"` - Security SecurityRef `yaml:"security,omitempty" json:"security,omitempty"` + Sinks []SinkTarget `yaml:"sinks,omitempty" json:"sinks,omitempty"` + Sink SinkRef `yaml:"sink,omitempty" json:"sink,omitempty"` + Chrome ChromeRef `yaml:"chrome" json:"chrome"` + Browser BrowserRef `yaml:"browser,omitempty" json:"browser,omitempty"` + // CDPSource reads the cookie jar through an existing loopback-only CDP + // endpoint instead of opening the browser's encrypted SQLite database. + // It is mutually exclusive with the browser/Chrome source reader at runtime. + CDPSource CDPSourceRef `yaml:"cdp_source,omitempty" json:"cdp_source,omitempty"` + Peer PeerRef `yaml:"peer,omitempty" json:"peer,omitempty"` + Security SecurityRef `yaml:"security,omitempty" json:"security,omitempty"` // Cmux configures the same-machine local loop: `agentcookie cmux-sync` // reads this machine's Chrome and injects into this machine's cmux // browser. Independent of the sink/peer push path; absent = loop off. @@ -162,6 +167,13 @@ type ChromeRef struct { DBPath string `yaml:"db_path" json:"db_path"` } +// CDPSourceRef selects a pre-existing browser to read through CDP. The +// endpoint is restricted to a loopback HTTP origin during config loading. +type CDPSourceRef struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Endpoint string `yaml:"endpoint,omitempty" json:"endpoint,omitempty"` +} + type BrowserRef struct { Name string `yaml:"name" json:"name"` Profile string `yaml:"profile" json:"profile"` @@ -253,6 +265,15 @@ func LoadSourceLocal(dir string) (*SourceConfig, error) { // shared by LoadSource and LoadSourceLocal (everything except the // push-only sink/peer/secret validation). func resolveSourcePaths(path string, cfg *SourceConfig) error { + if cfg.CDPSource.Enabled { + if err := cdpsource.ValidateEndpoint(cfg.CDPSource.Endpoint); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if cfg.Chrome.DBPath != "" || cfg.Browser.Name != "" || cfg.Browser.Profile != "" { + return fmt.Errorf("%s: cdp_source cannot be combined with chrome.db_path or browser configuration", path) + } + return nil + } cfg.Chrome.DBPath = ExpandTilde(cfg.Chrome.DBPath) if cfg.Browser.Name != "" { if _, err := lookupSourceBrowserPath(cfg.Browser.Name); err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 774e244..c2e1a54 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -41,6 +41,79 @@ security: } } +func TestLoadSourceCDPSourceParsesAndRejectsUnsafeEndpoint(t *testing.T) { + t.Run("loopback endpoint", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "source.yaml", ` +sink: + url: http://example.test:9999/sync +peer: + hostname: sink +cdp_source: + enabled: true + endpoint: http://127.0.0.1:9230 +`) + cfg, err := LoadSource(dir) + if err != nil { + t.Fatalf("LoadSource: %v", err) + } + if !cfg.CDPSource.Enabled || cfg.CDPSource.Endpoint != "http://127.0.0.1:9230" { + t.Fatalf("CDPSource = %+v", cfg.CDPSource) + } + }) + + t.Run("non-loopback endpoint", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "source.yaml", ` +sink: + url: http://example.test:9999/sync +peer: + hostname: sink +cdp_source: + enabled: true + endpoint: http://100.91.16.115:9230 +`) + if _, err := LoadSource(dir); err == nil { + t.Fatal("LoadSource accepted a non-loopback cdp_source endpoint") + } + }) + + t.Run("rejects SQLite browser configuration", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "source.yaml", ` +sink: + url: http://example.test:9999/sync +peer: + hostname: sink +chrome: + db_path: /private/Cookies +cdp_source: + enabled: true + endpoint: http://127.0.0.1:9230 +`) + if _, err := LoadSource(dir); err == nil { + t.Fatal("LoadSource accepted cdp_source combined with chrome.db_path") + } + }) +} + +func TestLoadSourceLocalAcceptsCDPSource(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "source.yaml", ` +cdp_source: + enabled: true + endpoint: http://127.0.0.1:9230 +`) + + cfg, err := LoadSourceLocal(dir) + if err != nil { + t.Fatalf("LoadSourceLocal CDP source: %v", err) + } + if !cfg.CDPSource.Enabled { + t.Fatal("LoadSourceLocal did not retain cdp_source") + } +} + func TestLoadSourceBrowserBlockParsesAndDerivesPath(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "source.yaml", ` @@ -385,6 +458,22 @@ chrome: t.Fatal("LoadSource should still require sink.url") } }) + + t.Run("accepts CDP source for CDP-capable local commands", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "source.yaml", ` +cdp_source: + enabled: true + endpoint: http://127.0.0.1:9230 +`) + cfg, err := LoadSourceLocal(dir) + if err != nil { + t.Fatalf("LoadSourceLocal CDP source: %v", err) + } + if !cfg.CDPSource.Enabled { + t.Fatal("LoadSourceLocal did not retain CDP source configuration") + } + }) } func TestLoadSourceCmuxLoop(t *testing.T) {