diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da11e5..c7da128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/examples/source.yaml b/examples/source.yaml index 4339845..5a02fbd 100644 --- a/examples/source.yaml +++ b/examples/source.yaml @@ -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: diff --git a/internal/chrome/browser.go b/internal/chrome/browser.go index c22099c..b6c1dd1 100644 --- a/internal/chrome/browser.go +++ b/internal/chrome/browser.go @@ -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 @@ -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 } diff --git a/internal/chrome/browser_test.go b/internal/chrome/browser_test.go index c20f165..f278658 100644 --- a/internal/chrome/browser_test.go +++ b/internal/chrome/browser_test.go @@ -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") } @@ -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) diff --git a/internal/chromepaths/discover.go b/internal/chromepaths/discover.go index 029507e..ed2c91b 100644 --- a/internal/chromepaths/discover.go +++ b/internal/chromepaths/discover.go @@ -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//), 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") @@ -142,7 +146,7 @@ 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" @@ -150,11 +154,30 @@ func browserForRoot(root string) string { 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 // /Cookies or /Network/Cookies. Local State is NOT diff --git a/internal/chromepaths/discover_test.go b/internal/chromepaths/discover_test.go index f232560..addb6b4 100644 --- a/internal/chromepaths/discover_test.go +++ b/internal/chromepaths/discover_test.go @@ -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 { @@ -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) + } +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index b8e91cf..fe6409b 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -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 { diff --git a/internal/config/config.go b/internal/config/config.go index 5562aab..611a6e6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 40c8a83..774e244 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") @@ -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 `)