Skip to content

Commit 39091ff

Browse files
committed
feat(auth): --profile flag + x auth browsers for multi-profile users
User reported `x auth import` auto-detect picked Chrome `Profile 8` which had stale cookies → 403 from UserByRestId. Their actual logged-in session was on `Profile 6`. Need a way to (a) see which profiles are available and (b) pin a specific one. internal/browsercookies: Load(ctx, browser, profile, domain) — new `profile` parameter, case-insensitive substring match against the browser profile name. Returns the FIRST matching store plus a list of Alternatives (other browser/profile pairs that also have cookies for the same domain). The chosen Result now carries Profile and Alternatives fields. List(ctx, domain) — new helper that enumerates every (browser, profile) pair with at least one cookie for the domain, sorted in kooky's traversal order. Used by the new `x auth browsers` subcommand. Match struct gained a Count field so the diagnostic listing can show "12 cookies" per row. cmd/auth.go: --profile <name> pin a browser profile (substring match) x auth browsers list every browser/profile with x.com cookies, marked with `*` for the one auto-detect would pick acquireCookieString reorganised. The browser-cookie path now runs whenever --from-browser OR --profile is set (not just both). After a successful browser read, x-cli prints the chosen browser/profile and a list of alternatives so the user immediately sees "you picked Profile 8 but Profile 6 also has cookies — re-run with --profile 'Profile 6' if you want that one". No more silent wrong-profile selection. internal/browsercookies/browsercookies_test.go: Updated Load callsites for the new signature, added TestListEmptyDomain. Verified locally: ./bin/x auth browsers # lists profiles ./bin/x auth import --profile "Profile 6" ./bin/x auth import --from-browser chrome --profile work
1 parent b25c239 commit 39091ff

3 files changed

Lines changed: 210 additions & 64 deletions

File tree

cmd/auth.go

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
var (
1919
authFromBrowser string
20+
authProfile string
2021
authCookieFlag string
2122
authForcePaste bool
2223
)
@@ -73,6 +74,8 @@ var authLogoutCmd = &cobra.Command{
7374
func init() {
7475
authImportCmd.Flags().StringVar(&authFromBrowser, "from-browser", "",
7576
"pin to a specific browser: chrome | firefox | brave | edge | chromium")
77+
authImportCmd.Flags().StringVar(&authProfile, "profile", "",
78+
"pin to a specific browser profile (substring match, e.g. \"Profile 6\", \"Default\", \"work\")")
7679
authImportCmd.Flags().StringVar(&authCookieFlag, "cookie", "",
7780
"non-interactive: pass the full cookie header (visible in shell history)")
7881
authImportCmd.Flags().BoolVar(&authForcePaste, "paste", false,
@@ -81,9 +84,47 @@ func init() {
8184
authCmd.AddCommand(authImportCmd)
8285
authCmd.AddCommand(authStatusCmd)
8386
authCmd.AddCommand(authLogoutCmd)
87+
authCmd.AddCommand(authBrowsersCmd)
8488
rootCmd.AddCommand(authCmd)
8589
}
8690

91+
var authBrowsersCmd = &cobra.Command{
92+
Use: "browsers",
93+
Short: "List local browser profiles that have an x.com session",
94+
Long: `Enumerate every browser cookie store on this machine that contains
95+
at least one cookie for x.com. Use this to discover what to pass to
96+
` + "`--from-browser`" + ` and ` + "`--profile`" + ` when ` + "`auth import`" + `'s auto-detect picks
97+
the wrong profile.`,
98+
RunE: runAuthBrowsers,
99+
}
100+
101+
func runAuthBrowsers(cmd *cobra.Command, _ []string) error {
102+
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
103+
defer cancel()
104+
matches, err := browsercookies.List(ctx, "x.com")
105+
if err != nil {
106+
return err
107+
}
108+
if len(matches) == 0 {
109+
cmdutil.Warn("no browser cookie stores have an x.com session")
110+
return nil
111+
}
112+
tw := cmdutil.NewTabPrinter(os.Stdout)
113+
for i, m := range matches {
114+
marker := " "
115+
if i == 0 {
116+
marker = "*" // first match is what auto-detect would pick
117+
}
118+
tw.Row(marker+" "+m.Browser, fmt.Sprintf("%-20s %d cookie(s) %s", m.Profile, m.Count, m.Source))
119+
}
120+
if err := tw.Flush(); err != nil {
121+
return err
122+
}
123+
fmt.Fprintln(os.Stdout)
124+
fmt.Fprintln(os.Stdout, "* = auto-detect default. Override with --from-browser and --profile.")
125+
return nil
126+
}
127+
87128
// cookieNamesWanted is the subset of browser cookies x-cli imports.
88129
// auth_token and ct0 are required; twid carries the user id so we can
89130
// skip a UserByScreenName roundtrip on `auth status`; the others are
@@ -152,14 +193,18 @@ func runAuthImport(cmd *cobra.Command, _ []string) error {
152193

153194
// acquireCookieString resolves the cookie string in priority order:
154195
//
155-
// 1. --cookie '...' (explicit one-shot)
156-
// 2. --from-browser <name> (explicit browser pin)
157-
// 3. --paste (explicit interactive paste)
158-
// 4. (default) auto-scan all browsers, fall through to paste if empty
196+
// 1. --cookie '...' (explicit one-shot)
197+
// 2. --paste (explicit interactive paste)
198+
// 3. --from-browser / --profile (pinned browser cookie read)
199+
// 4. (default) auto-scan, fall through to paste if empty
159200
//
160201
// The default mode is the only one that can SOFT-FAIL: if no browser
161202
// has an x.com session, we silently drop to the paste prompt without
162203
// error. Explicit modes hard-fail on their own terms.
204+
//
205+
// In every browser-cookie path, alternatives (other browser/profile
206+
// pairs that also have x.com cookies) are surfaced so the user can
207+
// re-run with `--profile` if auto-detect picked the wrong one.
163208
func acquireCookieString() (string, error) {
164209
switch {
165210
case authCookieFlag != "":
@@ -168,55 +213,65 @@ func acquireCookieString() (string, error) {
168213
case authForcePaste:
169214
return promptCookiePaste()
170215

171-
case authFromBrowser != "":
172-
raw, _, err := readBrowserCookies(authFromBrowser, false)
216+
case authFromBrowser != "" || authProfile != "":
217+
raw, err := readBrowserCookies(authFromBrowser, authProfile, false)
173218
return raw, err
174219

175220
default:
176-
// Auto-detect: any browser. Falls through to paste on miss.
177-
raw, source, err := readBrowserCookies("", true)
221+
raw, err := readBrowserCookies("", "", true)
178222
if err == nil && raw != "" {
179-
cmdutil.Success("auto-detected x.com session in %s", source)
180223
return raw, nil
181224
}
182225
if err != nil {
183226
cmdutil.Warn("auto-detect: %v", err)
184227
}
185-
cmdutil.Info("falling back to interactive paste (use --from-browser to pin a specific browser)")
228+
cmdutil.Info("falling back to interactive paste (use --from-browser / --profile to pin)")
186229
return promptCookiePaste()
187230
}
188231
}
189232

190-
// readBrowserCookies runs kooky against the named browser (or all
191-
// browsers when name == ""). On success returns the formatted cookie
192-
// header and a friendly source description. On no-cookies-found returns
193-
// an empty string and a non-nil error if `hardError` is false (caller
194-
// will treat as soft miss); when `hardError` is true the error is
195-
// surfaced verbatim.
196-
func readBrowserCookies(browser string, softMiss bool) (cookieHeader, source string, err error) {
233+
// readBrowserCookies runs kooky against the named browser/profile (both
234+
// optional — empty string means "any"). softMiss controls whether the
235+
// "no cookies found" path returns an error (false → silently miss for
236+
// auto-detect fallthrough; true → return the error verbatim).
237+
//
238+
// On success it prints the chosen (browser, profile, source) and any
239+
// alternatives so the user knows what auto-detect picked AND what
240+
// other profiles are available.
241+
func readBrowserCookies(browser, profile string, softMiss bool) (string, error) {
197242
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
198243
defer cancel()
199244

200-
res, err := browsercookies.Load(ctx, browser, "x.com")
245+
res, err := browsercookies.Load(ctx, browser, profile, "x.com")
201246
if err != nil {
202247
if softMiss {
203-
return "", "", err
248+
return "", err
204249
}
205-
if browser != "" {
206-
return "", "", fmt.Errorf("--from-browser %s: %w", browser, err)
250+
switch {
251+
case browser != "" && profile != "":
252+
return "", fmt.Errorf("--from-browser %s --profile %q: %w", browser, profile, err)
253+
case browser != "":
254+
return "", fmt.Errorf("--from-browser %s: %w", browser, err)
255+
case profile != "":
256+
return "", fmt.Errorf("--profile %q: %w", profile, err)
207257
}
208-
return "", "", err
258+
return "", err
209259
}
210260

211261
raw := browsercookies.FormatCookieHeader(res.Cookies, cookieNamesWanted)
212262
if raw == "" {
213-
miss := fmt.Errorf("required cookies (auth_token, ct0) not in %s store — are you logged in?", res.Browser)
214-
if softMiss {
215-
return "", "", miss
263+
return "", fmt.Errorf("required cookies (auth_token, ct0) not in %s/%s — are you logged in to x.com on that profile?", res.Browser, res.Profile)
264+
}
265+
266+
cmdutil.Success("using %s / %s (%s)", res.Browser, res.Profile, res.Source)
267+
if len(res.Alternatives) > 0 {
268+
cmdutil.Warn("also found x.com sessions in:")
269+
for _, a := range res.Alternatives {
270+
cmdutil.Warn(" %s / %s (%d cookies)", a.Browser, a.Profile, a.Count)
216271
}
217-
return "", "", miss
272+
cmdutil.Warn("re-run with --profile <name> if you want a different one")
218273
}
219-
return raw, fmt.Sprintf("%s (%s)", res.Browser, res.Source), nil
274+
return raw, nil
220275
}
221276

222277
func promptCookiePaste() (string, error) {

internal/browsercookies/browsercookies.go

Lines changed: 119 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -46,72 +46,156 @@ var Browsers = []string{"chrome", "firefox", "brave", "edge", "chromium"}
4646
// Result is what Load returns: a flat map of cookie name → value, plus
4747
// some diagnostic context for the caller to render or log.
4848
type Result struct {
49-
Cookies map[string]string
50-
Source string // human-readable description of the cookie store path
51-
Browser string // matched browser name
49+
Cookies map[string]string
50+
Source string // file path of the chosen cookie store
51+
Browser string // matched browser name (chrome, firefox, ...)
52+
Profile string // matched profile name (Default, Profile 6, ...)
53+
Alternatives []Match
54+
}
55+
56+
// Match identifies one cookie store that contains the requested domain.
57+
// Returned in Result.Alternatives so the caller can warn the user when
58+
// auto-detect picked one of several candidates.
59+
type Match struct {
60+
Browser string
61+
Profile string
62+
Source string
63+
Count int // number of cookies matched at this store
5264
}
5365

5466
// Load reads cookies for the given domain from one or more local cookie
55-
// stores belonging to the named browser. Returns the merged cookie map.
67+
// stores belonging to the named browser. Returns the merged cookie map
68+
// for the FIRST matching store, plus a list of the other stores it saw
69+
// (Result.Alternatives) so the caller can warn the user about ambiguity.
5670
//
5771
// browser is matched case-insensitively against the known list. Pass ""
58-
// to scan ALL detected browsers — useful when you don't care which
59-
// browser is logged in, just that some browser is.
72+
// to scan ALL detected browsers.
73+
//
74+
// profile is matched case-insensitively as a substring against the
75+
// browser profile name (e.g. "default", "profile 6", "work"). Pass ""
76+
// to accept any profile.
6077
//
6178
// domain is matched as a host suffix (so "x.com" picks up cookies on
6279
// ".x.com" too).
63-
func Load(ctx context.Context, browser, domain string) (*Result, error) {
80+
func Load(ctx context.Context, browser, profile, domain string) (*Result, error) {
6481
if domain == "" {
6582
return nil, errors.New("browsercookies: domain required")
6683
}
6784

68-
out := map[string]string{}
69-
matchedBrowser := ""
70-
matchedSource := ""
85+
// Group cookies by (browser, profile, source) so we can build a
86+
// stable list of all matching stores in the order kooky yields them.
87+
type storeKey struct {
88+
browser string
89+
profile string
90+
source string
91+
}
92+
type bucket struct {
93+
key storeKey
94+
cookies map[string]string
95+
}
96+
var order []storeKey
97+
stores := map[storeKey]*bucket{}
7198

72-
// kooky.TraverseCookies returns an iter.Seq2[*Cookie, error] that
73-
// walks every registered cookie store finder and yields cookies as
74-
// it goes. We filter by browser in our own code so the caller can
75-
// pass an empty string to mean "any browser".
7699
for c, err := range kooky.TraverseCookies(ctx, kooky.Valid, kooky.DomainHasSuffix(domain)) {
77-
if err != nil {
78-
// Skip unreadable stores instead of bailing — kooky reports
79-
// per-store failures as soft errors. We surface only "found
80-
// nothing" at the end.
100+
if err != nil || c == nil || c.Browser == nil {
81101
continue
82102
}
83-
if c == nil || c.Browser == nil {
103+
actualBrowser := c.Browser.Browser()
104+
actualProfile := c.Browser.Profile()
105+
if browser != "" && !strings.EqualFold(actualBrowser, browser) {
84106
continue
85107
}
86-
actual := c.Browser.Browser()
87-
if browser != "" && !strings.EqualFold(actual, browser) {
108+
if profile != "" && !strings.Contains(strings.ToLower(actualProfile), strings.ToLower(profile)) {
88109
continue
89110
}
90-
// First write wins so the most recently traversed store's
91-
// values are preserved. kooky lists default profiles first.
92-
if _, exists := out[c.Name]; !exists {
93-
out[c.Name] = c.Value
111+
key := storeKey{browser: actualBrowser, profile: actualProfile, source: c.Browser.FilePath()}
112+
b, ok := stores[key]
113+
if !ok {
114+
b = &bucket{key: key, cookies: map[string]string{}}
115+
stores[key] = b
116+
order = append(order, key)
94117
}
95-
if matchedBrowser == "" {
96-
matchedBrowser = actual
97-
matchedSource = c.Browser.FilePath()
118+
// First-write-wins per store so later cookies in the same store
119+
// don't overwrite earlier ones.
120+
if _, exists := b.cookies[c.Name]; !exists {
121+
b.cookies[c.Name] = c.Value
98122
}
99123
}
100124

101-
if len(out) == 0 {
102-
if browser != "" {
103-
return nil, fmt.Errorf("no cookies for %s in any %s cookie store — make sure %s is installed and you're logged in", domain, browser, browser)
125+
if len(order) == 0 {
126+
switch {
127+
case browser != "" && profile != "":
128+
return nil, fmt.Errorf("no cookies for %s in %s/%s — check `x auth browsers`", domain, browser, profile)
129+
case browser != "":
130+
return nil, fmt.Errorf("no cookies for %s in any %s profile — check `x auth browsers`", domain, browser)
131+
case profile != "":
132+
return nil, fmt.Errorf("no cookies for %s in any profile matching %q — check `x auth browsers`", domain, profile)
133+
default:
134+
return nil, fmt.Errorf("no cookies for %s in any local browser cookie store", domain)
104135
}
105-
return nil, fmt.Errorf("no cookies for %s in any local browser cookie store", domain)
136+
}
137+
138+
// Choose the first match. The rest are "alternatives" the caller
139+
// can surface to the user.
140+
chosen := stores[order[0]]
141+
alts := make([]Match, 0, len(order)-1)
142+
for _, k := range order[1:] {
143+
b := stores[k]
144+
alts = append(alts, Match{
145+
Browser: k.browser,
146+
Profile: k.profile,
147+
Source: k.source,
148+
Count: len(b.cookies),
149+
})
106150
}
107151

108152
return &Result{
109-
Cookies: out,
110-
Source: matchedSource,
111-
Browser: matchedBrowser,
153+
Cookies: chosen.cookies,
154+
Source: chosen.key.source,
155+
Browser: chosen.key.browser,
156+
Profile: chosen.key.profile,
157+
Alternatives: alts,
112158
}, nil
113159
}
114160

161+
// List enumerates every cookie store that has at least one cookie for
162+
// the given domain. Used by `x auth browsers` to show the user which
163+
// (browser, profile) pairs are available before they pin one.
164+
func List(ctx context.Context, domain string) ([]Match, error) {
165+
if domain == "" {
166+
return nil, errors.New("browsercookies: domain required")
167+
}
168+
type key struct {
169+
browser, profile, source string
170+
}
171+
seen := map[key]int{}
172+
var order []key
173+
for c, err := range kooky.TraverseCookies(ctx, kooky.Valid, kooky.DomainHasSuffix(domain)) {
174+
if err != nil || c == nil || c.Browser == nil {
175+
continue
176+
}
177+
k := key{
178+
browser: c.Browser.Browser(),
179+
profile: c.Browser.Profile(),
180+
source: c.Browser.FilePath(),
181+
}
182+
if _, ok := seen[k]; !ok {
183+
order = append(order, k)
184+
}
185+
seen[k]++
186+
}
187+
out := make([]Match, 0, len(order))
188+
for _, k := range order {
189+
out = append(out, Match{
190+
Browser: k.browser,
191+
Profile: k.profile,
192+
Source: k.source,
193+
Count: seen[k],
194+
})
195+
}
196+
return out, nil
197+
}
198+
115199
// FormatCookieHeader joins the relevant subset of a cookie map into a
116200
// `name=value; name=value` string suitable for `Cookie:` headers and
117201
// for x-cli's auth-import parser. Only names in `wanted` are kept; pass

internal/browsercookies/browsercookies_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ func TestFormatCookieHeaderNilSafe(t *testing.T) {
6161
func TestLoadNoBrowsersHere(t *testing.T) {
6262
ctx, cancel := context.WithTimeout(context.Background(), 5_000_000_000)
6363
defer cancel()
64-
_, err := Load(ctx, "chrome", "x.com")
64+
_, err := Load(ctx, "chrome", "", "x.com")
6565
if err == nil {
6666
// Some CI runners actually have a Chrome installed (Ubuntu image
6767
// pulls google-chrome-stable). If so, we still expect zero cookies
@@ -71,7 +71,14 @@ func TestLoadNoBrowsersHere(t *testing.T) {
7171
}
7272

7373
func TestLoadEmptyDomain(t *testing.T) {
74-
_, err := Load(context.Background(), "chrome", "")
74+
_, err := Load(context.Background(), "chrome", "", "")
75+
if err == nil {
76+
t.Error("expected error for empty domain")
77+
}
78+
}
79+
80+
func TestListEmptyDomain(t *testing.T) {
81+
_, err := List(context.Background(), "")
7582
if err == nil {
7683
t.Error("expected error for empty domain")
7784
}

0 commit comments

Comments
 (0)