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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Dia source-browser support

Dia (The Browser Company) is a Chromium-family source adapter using the same Safe Storage model as Arc, Brave, and Edge. Set `browser.name: dia` in `source.yaml`. Discovery labels Dia's `User Data` root as `dia` so key lookup uses `Dia Safe Storage` rather than Chrome's.

### Multi-sink fan-out

One source can now push the same cookies and secrets to several sinks.
Expand Down
4 changes: 2 additions & 2 deletions examples/source.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ chrome:

# Optional source browser adapter. Omit for Google Chrome. Set name/profile
# when reading cookies from another Chromium-family source browser.
# Supported: chrome, brave, edge, arc. (atlas is recognized but cannot be
# Supported: chrome, brave, edge, arc, dia. (atlas is recognized but cannot be
# decrypted by a third-party tool -- see issue #80; `agentcookie doctor`
# reports the gap.)
# browser:
# name: brave # chrome | brave | edge | arc
# name: brave # chrome | brave | edge | arc | dia
# profile: Default

peer:
Expand Down
11 changes: 11 additions & 0 deletions internal/chrome/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ var browserRegistry = map[string]Browser{
KeychainAccount: "Arc",
KeychainService: "Arc Safe Storage",
},
// Dia (The Browser Company) follows the same "User Data" layout as Arc.
// Profile paths verified on disk 2026-08-24; keychain account/service
// follow the standard macOS Chromium-fork convention.
"dia": {
Name: "dia",
SupportDir: []string{"Dia", "User Data"},
KeychainAccount: "Dia",
KeychainService: "Dia Safe Storage",
},
}

// LookupBrowser returns the browser descriptor for name. Empty name defaults
Expand Down Expand Up @@ -168,6 +177,8 @@ func linuxSupportDir(macDirs []string) []string {
return macDirs
case "Microsoft Edge":
return []string{"microsoft-edge"}
case "Arc", "Dia":
return macDirs // Arc and Dia are macOS-only
default:
return macDirs
}
Expand Down
3 changes: 2 additions & 1 deletion internal/chrome/browser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func TestLookupBrowserAtlas(t *testing.T) {
}

func TestLookupBrowserUnknownListsSupportedNames(t *testing.T) {
_, err := LookupBrowser("dia")
_, err := LookupBrowser("vivaldi")
if err == nil {
t.Fatal("expected unsupported browser error")
}
Expand All @@ -76,6 +76,7 @@ func TestLookupBrowserStandardForks(t *testing.T) {
{"brave", []string{"BraveSoftware", "Brave-Browser"}, "Brave", "Brave Safe Storage"},
{"edge", []string{"Microsoft Edge"}, "Microsoft Edge", "Microsoft Edge Safe Storage"},
{"arc", []string{"Arc", "User Data"}, "Arc", "Arc Safe Storage"},
{"dia", []string{"Dia", "User Data"}, "Dia", "Dia Safe Storage"},
}
for _, tc := range cases {
b, err := LookupBrowser(tc.name)
Expand Down
25 changes: 24 additions & 1 deletion internal/chromepaths/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ func chromeRoots() []string {
filepath.Join(appSupport, "Chromium"),
filepath.Join(appSupport, "BraveSoftware", "Brave-Browser"),
filepath.Join(appSupport, "Microsoft Edge"),
// Dia uses Arc's User Data layout. Scan the user-data-dir itself
// (profiles live under Dia/User Data/<profile>/), not the Dia
// support dir, or Default/Profile N would not be found.
filepath.Join(appSupport, "Dia", "User Data"),
)
case "linux":
configDir := filepath.Join(home, ".config")
Expand Down Expand Up @@ -142,19 +146,38 @@ func osDefaultChromeRoot() string {

// browserForRoot returns a browser identifier based on the root path.
func browserForRoot(root string) string {
lower := strings.ToLower(root)
lower := strings.ToLower(filepath.ToSlash(root))
switch {
case strings.Contains(lower, "brave"):
return "brave"
case strings.Contains(lower, "edge"):
return "edge"
case strings.Contains(lower, "chromium"):
return "chromium"
case isDiaUserDataRoot(lower):
// Require the Dia/User Data pair, not a lone "dia" component.
// CHROME_USER_DATA_DIR or cdp.profile_dir under a folder named
// dia (or a user named dia) must stay Chrome so key lookup does
// not request Dia Safe Storage and drop the store.
return "dia"
default:
return "chrome"
}
}

// isDiaUserDataRoot reports whether slashPath contains consecutive Dia
// support-dir components ("dia" then "user data"). slashPath must already
// use forward slashes and be lowercased.
func isDiaUserDataRoot(slashPath string) bool {
parts := strings.Split(slashPath, "/")
for i := 0; i+1 < len(parts); i++ {
if parts[i] == "dia" && parts[i+1] == "user data" {
return true
}
}
return false
}

// Discover scans known Chrome user-data-dirs and returns all usable
// cookie stores. A store is usable if a Cookies file exists at either
// <profile>/Cookies or <profile>/Network/Cookies. Local State is NOT
Expand Down
65 changes: 65 additions & 0 deletions internal/chromepaths/discover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,11 @@ func TestBrowserForRoot(t *testing.T) {
{"/home/me/.config/microsoft-edge", "edge"},
{"/home/me/chrome-profile", "chrome"},
{"/some/random/path", "chrome"}, // Default
{"/Users/me/Library/Application Support/Dia/User Data", "dia"},
{"/Users/me/Library/Application Support/Dia/User Data/Default", "dia"},
{"/Users/me/Library/Application Support/some-media-app", "chrome"}, // "media" contains "dia"
{"/Users/dia/Library/Application Support/Google/Chrome", "chrome"},
{"/tmp/dia/chrome-profile", "chrome"},
}

for _, tc := range cases {
Expand All @@ -524,3 +529,63 @@ func TestBrowserForRoot(t *testing.T) {
}
}
}

// TestDiscoverForConfig_DiaUserDataKeepsDiaIdentity is the #120 discovery
// guard: a Dia User Data root must stay labeled "dia" so key lookup uses
// Dia Safe Storage, not Chrome Safe Storage.
func TestDiscoverForConfig_DiaUserDataKeepsDiaIdentity(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

diaRoot := filepath.Join(home, "Library", "Application Support", "Dia", "User Data")
if err := os.MkdirAll(diaRoot, 0o755); err != nil {
t.Fatal(err)
}
makeProfile(t, diaRoot, "Default", true, false)

result := DiscoverForConfig(diaRoot)

found := false
for _, s := range result.Stores {
if s.Profile == "Default" && s.Browser == "dia" {
found = true
break
}
}
if !found {
t.Fatalf("expected dia/Default store from Dia User Data root, got %+v", result.Stores)
}
}

// TestDiscoverForConfig_CustomRootUnderDiaDirStaysChrome is the inverse of
// the Dia identity guard: a Chrome user-data-dir whose path merely contains
// a "dia" directory (CHROME_USER_DATA_DIR / cdp.profile_dir) must not be
// labeled dia, or key lookup would request Dia Safe Storage and skip the
// store.
func TestDiscoverForConfig_CustomRootUnderDiaDirStaysChrome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)

chromeRoot := filepath.Join(home, "dia", "agent-chrome")
if err := os.MkdirAll(chromeRoot, 0o755); err != nil {
t.Fatal(err)
}
makeProfile(t, chromeRoot, "Default", true, false)

result := DiscoverForConfig(chromeRoot)
found := false
for _, s := range result.Stores {
if s.CookiesPath == "" {
continue
}
if s.Browser != "chrome" {
t.Errorf("store under %q labeled %q, want chrome", chromeRoot, s.Browser)
}
if s.Profile == "Default" && s.Browser == "chrome" {
found = true
}
}
if !found {
t.Fatalf("expected chrome/Default store under a dia/ parent, got %+v", result.Stores)
}
}
2 changes: 1 addition & 1 deletion internal/cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,7 @@ func TestCheckSourceAdapter(t *testing.T) {
t.Run("unknown browser lists supported names", func(t *testing.T) {
cfg := &config.SourceConfig{
Chrome: config.ChromeRef{DBPath: "/tmp/Cookies"},
Browser: config.BrowserRef{Name: "dia"},
Browser: config.BrowserRef{Name: "vivaldi"},
}
c := checkSourceAdapter(cfg, exists, password, decryptOK)
if c.Severity != SeverityFail {
Expand Down
5 changes: 3 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ var sourceBrowserPaths = map[string]browserPathRef{
"brave": {SupportDir: []string{"BraveSoftware", "Brave-Browser"}},
"edge": {SupportDir: []string{"Microsoft Edge"}},
"arc": {SupportDir: []string{"Arc", "User Data"}},
"dia": {SupportDir: []string{"Dia", "User Data"}},
}

// SecurityRef holds transport credentials. SharedSecret is the pre-pairing
Expand Down Expand Up @@ -437,8 +438,8 @@ func linuxSupportDir(macDirs []string) []string {
return []string{"microsoft-edge"}
case "com.openai.atlas":
return macDirs // Atlas is macOS-only
case "Arc":
return macDirs // Arc is macOS-only
case "Arc", "Dia":
return macDirs // Arc and Dia are macOS-only
default:
return macDirs
}
Expand Down
31 changes: 30 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,35 @@ security:
}
}

func TestLoadSourceBrowserDiaDerivesUserDataPath(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "source.yaml", `
sink:
url: http://example.test:9999/sync
browser:
name: dia
security:
shared_secret: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
`)
cfg, err := LoadSource(dir)
if err != nil {
t.Fatalf("LoadSource: %v", err)
}
if cfg.Browser.Name != "dia" {
t.Errorf("browser name: got %q, want dia", cfg.Browser.Name)
}
home, _ := os.UserHomeDir()
var want string
if runtime.GOOS == "linux" {
want = filepath.Join(home, ".config", "Dia", "User Data", "Default", "Cookies")
} else {
want = filepath.Join(home, "Library", "Application Support", "Dia", "User Data", "Default", "Cookies")
}
if cfg.Chrome.DBPath != want {
t.Errorf("derived DBPath: got %q, want %q", cfg.Chrome.DBPath, want)
}
}

func TestLoadSourceDBPathOverridesBrowserDerivedPath(t *testing.T) {
dir := t.TempDir()
explicit := filepath.Join(dir, "Custom", "Cookies")
Expand Down Expand Up @@ -123,7 +152,7 @@ func TestLoadSourceUnknownBrowserFailsWithSupportedNames(t *testing.T) {
sink:
url: http://example.test:9999/sync
browser:
name: dia
name: vivaldi
security:
shared_secret: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
`)
Expand Down
Loading