diff --git a/internal/opencodego/cache.go b/internal/opencodego/cache.go new file mode 100644 index 0000000..0c844a6 --- /dev/null +++ b/internal/opencodego/cache.go @@ -0,0 +1,138 @@ +package opencodego + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" +) + +const sessionCacheVersion = 1 + +type cachedSessionFile struct { + Version int `json:"version"` + Sessions []cachedSession `json:"sessions"` +} + +type cachedSession struct { + Label string `json:"label"` + Cookies []cachedCookie `json:"cookies"` +} + +type cachedCookie struct { + Name string `json:"name"` + Value string `json:"value"` + ExpiresAt time.Time `json:"expires_at"` +} + +func loadSessionCache(now time.Time) ([]browserSession, error) { + path, err := sessionCachePath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var cache cachedSessionFile + if err := json.Unmarshal(data, &cache); err != nil { + return nil, err + } + if cache.Version != sessionCacheVersion { + return nil, errors.New("unsupported OpenCode session cache version") + } + + var sessions []browserSession + for _, cached := range cache.Sessions { + var cookies []browserCookie + for _, cookie := range cached.Cookies { + if cookie.Value == "" || cookie.ExpiresAt.IsZero() || !cookie.ExpiresAt.After(now) { + continue + } + if cookie.Name != "auth" && cookie.Name != "__Host-auth" { + continue + } + cookies = append(cookies, browserCookie{ + name: cookie.Name, + value: cookie.Value, + expiresAt: cookie.ExpiresAt, + }) + } + if len(cookies) > 0 { + sessions = append(sessions, browserSession{label: cached.Label, cookies: cookies}) + } + } + return sessions, nil +} + +func saveSessionCache(sessions []browserSession) error { + cache := cachedSessionFile{Version: sessionCacheVersion} + for _, session := range sessions { + var cookies []cachedCookie + for _, cookie := range session.cookies { + if cookie.value == "" || cookie.expiresAt.IsZero() { + continue + } + cookies = append(cookies, cachedCookie{ + Name: cookie.name, + Value: cookie.value, + ExpiresAt: cookie.expiresAt, + }) + } + if len(cookies) > 0 { + cache.Sessions = append(cache.Sessions, cachedSession{Label: session.label, Cookies: cookies}) + } + } + if len(cache.Sessions) == 0 { + return nil + } + + path, err := sessionCachePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + if err := os.Chmod(filepath.Dir(path), 0o700); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".sessions-*.json") + if err != nil { + return err + } + temporaryPath := temporary.Name() + defer func() { _ = os.Remove(temporaryPath) }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if err := json.NewEncoder(temporary).Encode(cache); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return err + } + if err := temporary.Close(); err != nil { + return err + } + return os.Rename(temporaryPath, path) +} + +func sessionCachePath() (string, error) { + root := os.Getenv("XDG_CACHE_HOME") + if root == "" || !filepath.IsAbs(root) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + root = filepath.Join(home, ".cache") + } + return filepath.Join(root, "prism", "opencode-go", "sessions.json"), nil +} diff --git a/internal/opencodego/cache_test.go b/internal/opencodego/cache_test.go new file mode 100644 index 0000000..8b7d3b7 --- /dev/null +++ b/internal/opencodego/cache_test.go @@ -0,0 +1,181 @@ +package opencodego + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/circlesac/prism-cli/internal/api" +) + +func TestSessionCacheUsesXDGPathAndRestrictivePermissions(t *testing.T) { + root := t.TempDir() + t.Setenv("XDG_CACHE_HOME", root) + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + sessions := []browserSession{{ + label: "Chrome Default", + cookies: []browserCookie{{ + name: "auth", value: "example-session", expiresAt: now.Add(time.Hour), + }}, + }} + if err := saveSessionCache(sessions); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "prism", "opencode-go", "sessions.json") + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + directoryInfo, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if fileInfo.Mode().Perm() != 0o600 || directoryInfo.Mode().Perm() != 0o700 { + t.Fatalf("file mode = %o, directory mode = %o", fileInfo.Mode().Perm(), directoryInfo.Mode().Perm()) + } + loaded, err := loadSessionCache(now) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 1 || loaded[0].label != "Chrome Default" || len(loaded[0].cookies) != 1 || loaded[0].cookies[0].value != "example-session" { + t.Fatalf("sessions = %#v", loaded) + } +} + +func TestSessionCacheDropsExpiredAndNonPersistentCookies(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + now := time.Date(2026, 8, 10, 0, 0, 0, 0, time.UTC) + if err := saveSessionCache([]browserSession{{ + label: "Chrome Default", + cookies: []browserCookie{ + {name: "auth", value: "expired", expiresAt: now.Add(-time.Minute)}, + {name: "__Host-auth", value: "session-only"}, + }, + }}); err != nil { + t.Fatal(err) + } + loaded, err := loadSessionCache(now) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 0 { + t.Fatalf("sessions = %#v", loaded) + } +} + +func TestFetchUsesCacheWithoutScanningBrowsers(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + if err := saveSessionCache([]browserSession{{ + label: "Chrome Default", + cookies: []browserCookie{{ + name: "auth", value: "cached-session", expiresAt: time.Now().Add(time.Hour), + }}, + }}); err != nil { + t.Fatal(err) + } + + originalScan := scanBrowserSessions + originalFetch := fetchSessionUsage + defer func() { + scanBrowserSessions = originalScan + fetchSessionUsage = originalFetch + }() + scanBrowserSessions = func(time.Time) sessionScan { + t.Fatal("browser scan was called for a valid cached session") + return sessionScan{} + } + fetchSessionUsage = func(_ context.Context, _ time.Time, sessions []browserSession) (api.ProviderUsage, error) { + if len(sessions) != 1 || len(sessions[0].cookies) != 1 || sessions[0].cookies[0].value != "cached-session" { + t.Fatalf("sessions = %#v", sessions) + } + return api.ProviderUsage{Provider: "opencode-go"}, nil + } + if _, err := Fetch(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestFetchReplacesRejectedCacheFromBrowser(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + if err := saveSessionCache([]browserSession{{ + label: "Chrome Default", + cookies: []browserCookie{{ + name: "auth", value: "rejected-session", expiresAt: time.Now().Add(time.Hour), + }}, + }}); err != nil { + t.Fatal(err) + } + + originalScan := scanBrowserSessions + originalFetch := fetchSessionUsage + defer func() { + scanBrowserSessions = originalScan + fetchSessionUsage = originalFetch + }() + fresh := browserSession{ + label: "Chrome Profile 1", + cookies: []browserCookie{{ + name: "auth", value: "fresh-session", expiresAt: time.Now().Add(2 * time.Hour), + }}, + } + scanBrowserSessions = func(time.Time) sessionScan { + return sessionScan{sessions: []browserSession{fresh}} + } + requests := 0 + fetchSessionUsage = func(_ context.Context, _ time.Time, sessions []browserSession) (api.ProviderUsage, error) { + requests++ + if sessions[0].cookies[0].value == "rejected-session" { + return api.ProviderUsage{}, errOpenCodeSessionInvalid + } + if sessions[0].cookies[0].value != "fresh-session" { + t.Fatalf("sessions = %#v", sessions) + } + return api.ProviderUsage{Provider: "opencode-go"}, nil + } + if _, err := Fetch(context.Background()); err != nil { + t.Fatal(err) + } + if requests != 2 { + t.Fatalf("requests = %d", requests) + } + loaded, err := loadSessionCache(time.Now()) + if err != nil { + t.Fatal(err) + } + if len(loaded) != 1 || loaded[0].cookies[0].value != "fresh-session" { + t.Fatalf("sessions = %#v", loaded) + } +} + +func TestFetchDoesNotScanBrowsersAfterCachedNetworkFailure(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + if err := saveSessionCache([]browserSession{{ + label: "Chrome Default", + cookies: []browserCookie{{ + name: "auth", value: "cached-session", expiresAt: time.Now().Add(time.Hour), + }}, + }}); err != nil { + t.Fatal(err) + } + + originalScan := scanBrowserSessions + originalFetch := fetchSessionUsage + defer func() { + scanBrowserSessions = originalScan + fetchSessionUsage = originalFetch + }() + scanBrowserSessions = func(time.Time) sessionScan { + t.Fatal("browser scan was called after a network failure") + return sessionScan{} + } + fetchSessionUsage = func(context.Context, time.Time, []browserSession) (api.ProviderUsage, error) { + return api.ProviderUsage{}, errors.New("network unavailable") + } + _, err := Fetch(context.Background()) + if err == nil || err.Error() != "network unavailable" { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/opencodego/session_darwin.go b/internal/opencodego/session_darwin.go index beaebbf..ce5b0e7 100644 --- a/internal/opencodego/session_darwin.go +++ b/internal/opencodego/session_darwin.go @@ -126,7 +126,7 @@ func chromiumSession(product chromiumProduct, profile string, database string, n password := "" passwordLoaded := false - cookies := map[string]string{} + cookies := map[string]browserCookie{} for _, line := range strings.Split(strings.TrimSuffix(string(output), "\n"), "\n") { if line == "" { continue @@ -136,7 +136,8 @@ func chromiumSession(product chromiumProduct, profile string, database string, n scan.invalidCookies++ continue } - if chromeCookieExpired(fields[3], now) { + expiresAt, persistent := chromeCookieExpiresAt(fields[3]) + if persistent && !expiresAt.After(now) { scan.expiredCookies++ continue } @@ -154,7 +155,7 @@ func chromiumSession(product chromiumProduct, profile string, database string, n scan.invalidCookies++ continue } - cookies[fields[0]] = value + cookies[fields[0]] = browserCookie{name: fields[0], value: value, expiresAt: expiresAt} } return browserSession{label: product.name + " " + profile, cookies: sortedCookies(cookies)}, scan @@ -226,13 +227,17 @@ func decryptChromiumCookie(encoded string, password string) (string, error) { } func chromeCookieExpired(value string, now time.Time) bool { + expiresAt, persistent := chromeCookieExpiresAt(value) + return persistent && !expiresAt.After(now) +} + +func chromeCookieExpiresAt(value string) (time.Time, bool) { microseconds, err := strconv.ParseInt(value, 10, 64) if err != nil || microseconds == 0 { - return false + return time.Time{}, false } const windowsToUnixSeconds = 11_644_473_600 - expires := time.Unix(microseconds/1_000_000-windowsToUnixSeconds, microseconds%1_000_000*1_000) - return !expires.After(now) + return time.Unix(microseconds/1_000_000-windowsToUnixSeconds, microseconds%1_000_000*1_000), true } func firefoxSessions(root string, now time.Time) sessionScan { @@ -263,7 +268,7 @@ func firefoxSessions(root string, now time.Time) sessionScan { result.unreadableStores++ continue } - cookies := map[string]string{} + cookies := map[string]browserCookie{} for _, line := range strings.Split(strings.TrimSuffix(string(output), "\n"), "\n") { if line == "" { continue @@ -274,7 +279,11 @@ func firefoxSessions(root string, now time.Time) sessionScan { continue } expires, parseErr := strconv.ParseInt(fields[2], 10, 64) - if parseErr == nil && expires > 0 && !time.Unix(expires, 0).After(now) { + expiresAt := time.Time{} + if parseErr == nil && expires > 0 { + expiresAt = time.Unix(expires, 0) + } + if !expiresAt.IsZero() && !expiresAt.After(now) { result.expiredCookies++ continue } @@ -282,7 +291,7 @@ func firefoxSessions(root string, now time.Time) sessionScan { result.invalidCookies++ continue } - cookies[fields[0]] = fields[1] + cookies[fields[0]] = browserCookie{name: fields[0], value: fields[1], expiresAt: expiresAt} } if sessionCookies := sortedCookies(cookies); len(sessionCookies) > 0 { result.sessions = append(result.sessions, browserSession{label: "Firefox " + profile.name, cookies: sessionCookies}) @@ -331,7 +340,7 @@ func firefoxProfilePaths(configuration string, root string) []firefoxProfile { return result } -func sortedCookies(values map[string]string) []browserCookie { +func sortedCookies(values map[string]browserCookie) []browserCookie { names := make([]string, 0, len(values)) for name := range values { names = append(names, name) @@ -339,7 +348,7 @@ func sortedCookies(values map[string]string) []browserCookie { sort.Strings(names) result := make([]browserCookie, 0, len(names)) for _, name := range names { - result = append(result, browserCookie{name: name, value: values[name]}) + result = append(result, values[name]) } return result } diff --git a/internal/opencodego/usage.go b/internal/opencodego/usage.go index c80a056..9f97331 100644 --- a/internal/opencodego/usage.go +++ b/internal/opencodego/usage.go @@ -20,8 +20,9 @@ import ( const openCodeBaseURL = "https://opencode.ai" type browserCookie struct { - name string - value string + name string + value string + expiresAt time.Time } type browserSession struct { @@ -39,11 +40,27 @@ type sessionScan struct { } var scanBrowserSessions = browserSessions +var fetchSessionUsage = func(ctx context.Context, now time.Time, sessions []browserSession) (api.ProviderUsage, error) { + return fetchFromSessions(ctx, &http.Client{Timeout: 30 * time.Second}, openCodeBaseURL, now, sessions) +} + +var errOpenCodeSessionInvalid = errors.New("OpenCode browser login is not valid or no workspace is available") +var errOpenCodeSessionRejected = errors.New("OpenCode rejected the browser login session") -// Fetch reads OpenCode's authenticated browser session without persisting it -// and returns the Go plan usage shown by opencode.ai. +// Fetch reuses a validated cached session when possible and otherwise reads +// OpenCode's authenticated browser session to return the Go plan usage. func Fetch(ctx context.Context) (api.ProviderUsage, error) { now := time.Now() + if sessions, err := loadSessionCache(now); err == nil && len(sessions) > 0 { + usage, err := fetchSessionUsage(ctx, now, sessions) + if err == nil { + return usage, nil + } + if !errors.Is(err, errOpenCodeSessionInvalid) { + return api.ProviderUsage{}, err + } + } + scan := scanBrowserSessions(now) if scan.unsupported { return api.ProviderUsage{}, errors.New("OpenCode browser session reading is currently supported on macOS") @@ -61,7 +78,11 @@ func Fetch(ctx context.Context) (api.ProviderUsage, error) { } } - return fetchFromSessions(ctx, &http.Client{Timeout: 30 * time.Second}, openCodeBaseURL, now, scan.sessions) + usage, err := fetchSessionUsage(ctx, now, scan.sessions) + if err == nil { + _ = saveSessionCache(scan.sessions) + } + return usage, err } func fetchFromSessions( @@ -73,9 +94,13 @@ func fetchFromSessions( ) (api.ProviderUsage, error) { workspaceSessions := map[string][]browserSession{} requestSucceeded := false + sessionRejected := false for _, session := range sessions { body, err := fetchPage(ctx, client, baseURL+"/zen", session) if err != nil { + if errors.Is(err, errOpenCodeSessionRejected) { + sessionRejected = true + } continue } requestSucceeded = true @@ -85,9 +110,12 @@ func fetchFromSessions( } if len(workspaceSessions) == 0 { if !requestSucceeded { + if sessionRejected { + return api.ProviderUsage{}, errOpenCodeSessionInvalid + } return api.ProviderUsage{}, errors.New("OpenCode could not be reached") } - return api.ProviderUsage{}, errors.New("OpenCode browser login is not valid or no workspace is available") + return api.ProviderUsage{}, errOpenCodeSessionInvalid } workspaceIDs := make([]string, 0, len(workspaceSessions)) @@ -163,6 +191,9 @@ func fetchPage(ctx context.Context, client *http.Client, endpoint string, sessio defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { _, _ = io.Copy(io.Discard, response.Body) + if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden { + return "", fmt.Errorf("%w: HTTP %d", errOpenCodeSessionRejected, response.StatusCode) + } return "", fmt.Errorf("OpenCode returned HTTP %d", response.StatusCode) } body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) diff --git a/internal/opencodego/usage_test.go b/internal/opencodego/usage_test.go index cc913cf..8f266a7 100644 --- a/internal/opencodego/usage_test.go +++ b/internal/opencodego/usage_test.go @@ -2,6 +2,7 @@ package opencodego import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -111,7 +112,22 @@ monthlyUsage:{status:"active",resetInSec:180,usagePercent:3}} } } +func TestFetchFromSessionsClassifiesRejectedLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(output http.ResponseWriter, _ *http.Request) { + http.Error(output, "unauthorized", http.StatusUnauthorized) + })) + defer server.Close() + + _, err := fetchFromSessions(context.Background(), server.Client(), server.URL, time.Now(), []browserSession{{ + label: "Chrome Default", cookies: []browserCookie{{name: "auth", value: "rejected-session"}}, + }}) + if !errors.Is(err, errOpenCodeSessionInvalid) { + t.Fatalf("error = %v", err) + } +} + func TestFetchSeparatesSessionDiscoveryFailures(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) original := scanBrowserSessions defer func() { scanBrowserSessions = original }() for _, test := range []struct {