diff --git a/cmd/onwatch/menubar_runtime.go b/cmd/onwatch/menubar_runtime.go index 14c2cc5b..3e15e5b4 100644 --- a/cmd/onwatch/menubar_runtime.go +++ b/cmd/onwatch/menubar_runtime.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -232,6 +233,7 @@ func runMenubarCommand() error { mbCfg := settings.ToConfig(cfg.Port, httpSnapshotProvider(cfg.Port)) mbCfg.TestMode = cfg.TestMode + mbCfg.MistralRetry = httpMistralRetry(cfg.Port, cfg.BasePath) pidPath := menubarPIDPath(cfg.TestMode) if err := writeRuntimePID(pidPath); err != nil { @@ -283,6 +285,29 @@ func httpSnapshotProvider(port int) menubar.SnapshotProvider { } } +func httpMistralRetry(port int, basePath string) func() error { + url := fmt.Sprintf("http://localhost:%d%s/api/menubar/mistral/retry", port, basePath) + client := &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + return func() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + return err + } + req.Header.Set("X-Requested-With", "onWatch") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("Mistral retry request failed") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("Mistral retry returned HTTP %d", resp.StatusCode) + } + return nil + } +} + // portFilePath is the discovery file thin clients (GNOME extension, VS Code // extension, tray companion) read to find the dashboard port. func portFilePath() string { diff --git a/cmd/onwatch/mistral_retry_test.go b/cmd/onwatch/mistral_retry_test.go new file mode 100644 index 00000000..016ae32f --- /dev/null +++ b/cmd/onwatch/mistral_retry_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "net" + "net/http" + "net/http/httptest" + "strconv" + "testing" +) + +func TestMistralNativeRetryRequest(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/watch/api/menubar/mistral/retry" || r.Header.Get("X-Requested-With") == "" { + t.Errorf("bad retry request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(202) + })) + defer s.Close() + _, p, err := net.SplitHostPort(s.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(p) + if err != nil { + t.Fatal(err) + } + if err := httpMistralRetry(port, "/watch")(); err != nil { + t.Fatal(err) + } +} diff --git a/docs/MISTRAL_SETUP.md b/docs/MISTRAL_SETUP.md index 1f0fb8d8..1aada39e 100644 --- a/docs/MISTRAL_SETUP.md +++ b/docs/MISTRAL_SETUP.md @@ -20,9 +20,9 @@ Settings > Providers > Mistral > Manual, then paste your Mistral Cookie header. ## macOS permissions -Automatic import needs two one-time permissions on macOS: +Automatic import needs two permissions on macOS: -1. **Browser folder access.** macOS blocks background apps from reading another app's data folder. Right-click the onWatch tray icon and choose **Grant Browser Access...**, then pick your browser in the folder panel. A locally rebuilt binary will need re-granting. +1. **Browser folder access.** macOS blocks background apps from reading another app's data folder. In the native macOS menubar card, click **Grant Browser Access** above **Retry connection**. Alternatively, right-click the onWatch tray icon and choose **Grant Browser Access...**. Confirm the browser folder in the system panel (selected by default). Once access is verified, onWatch automatically requests a Mistral retry; cancelling does not retry. A rebuilt or updated binary may need a new grant. Dashboard and browser fallback views retain the right-click guidance. 2. **Keychain access.** You'll see a prompt that `security` wants to use **Chrome Safe Storage** - this decrypts the cookie file, it's not asking for your Mistral password. Enter your Mac login password and choose **Allow** (or **Allow Once** if you'd rather be asked again next time). Prefer to skip both prompts? Sign into Mistral in Firefox instead and select Firefox as your browser, or use manual cookie mode. @@ -57,7 +57,11 @@ Settings saved in onWatch override these environment variables. The cookie field ## Refresh behaviour -onWatch polls every 120 seconds by default. If your session is rejected, it retries the import once immediately; after that it pauses polling until a fresh login is detected, without switching to a different account on its own. Usage history is kept for 90 days by default - change this with `MISTRAL_RETENTION` (a Go duration like `720h`, or `0` to keep everything). +onWatch polls every 120 seconds by default. If your session is rejected, it retries the import once immediately; after that it pauses polling until a fresh login is detected, without switching to a different account on its own. Repeated import failures increase the automatic retry delay from 20 minutes to at most 5 hours 20 minutes, to avoid repeatedly prompting for your password. + +Connection messages distinguish browser folder permissions, Keychain/keyring access, unreadable cookie storage, missing logins, and rejected sessions. After addressing the message, use **Retry connection** on the Mistral dashboard or menubar card. It retries the selected source immediately without restarting the daemon, even if the cookie has not changed. Repeated clicks are coalesced, with a 30-second cooldown after a request; Mistral's own rate-limit deadlines still apply. A queued retry may need you to answer a credential-store prompt. The ordinary refresh icon only reloads saved usage. + +The last successful values retain their original timestamps until a successful poll. Usage history is kept for 90 days by default - change this with `MISTRAL_RETENTION` (a Go duration like `720h`, or `0` to keep everything). ## Testing @@ -68,3 +72,11 @@ ONWATCH_MISTRAL_LIVE=1 GOFLAGS='-run=TestMistralLive -v' ./app.sh --test ``` The default test suite uses synthetic data only. A synthetic dashboard preview is also available with `ONWATCH_MISTRAL_PREVIEW=1 GOFLAGS='-run=TestMistralPreview -v' ./app.sh --test`. + +On macOS, run the native grant callback and UI regressions with race detection: + +```sh +GOFLAGS='-tags=menubar,desktop,production,granttest -run=TestBrowserGrant|TestMistralRecoveryUI' ./app.sh --test +``` + +The `granttest` harness exercises the real C-to-Go callback and Cocoa completion queue with simulated permission results, including host destruction during a request. It does not grant browser permissions and is excluded from production builds. The actual system permission picker still requires an in-situ check. diff --git a/internal/agent/manager.go b/internal/agent/manager.go index f05ce4df..49271159 100644 --- a/internal/agent/manager.go +++ b/internal/agent/manager.go @@ -21,10 +21,15 @@ type RunnerFactory func() (AgentRunner, error) type AgentManager struct { mu sync.RWMutex factories map[string]RunnerFactory - running map[string]context.CancelFunc + running map[string]*runningAgent logger *slog.Logger } +type runningAgent struct { + cancel context.CancelFunc + runner AgentRunner +} + // NewAgentManager creates a new manager. func NewAgentManager(logger *slog.Logger) *AgentManager { if logger == nil { @@ -32,7 +37,7 @@ func NewAgentManager(logger *slog.Logger) *AgentManager { } return &AgentManager{ factories: make(map[string]RunnerFactory), - running: make(map[string]context.CancelFunc), + running: make(map[string]*runningAgent), logger: logger, } } @@ -85,7 +90,8 @@ func (m *AgentManager) Start(key string) error { cancel() return nil } - m.running[key] = cancel + entry := &runningAgent{cancel: cancel, runner: runner} + m.running[key] = entry m.mu.Unlock() go func() { @@ -94,7 +100,9 @@ func (m *AgentManager) Start(key string) error { m.logger.Error("Agent error", "provider", key, "error", err) } m.mu.Lock() - delete(m.running, key) + if m.running[key] == entry { + delete(m.running, key) + } m.mu.Unlock() }() @@ -104,13 +112,13 @@ func (m *AgentManager) Start(key string) error { // Stop cancels the running provider agent, if present. func (m *AgentManager) Stop(key string) { m.mu.Lock() - cancel, running := m.running[key] + entry, running := m.running[key] if running { delete(m.running, key) } m.mu.Unlock() if running { - cancel() + entry.cancel() m.logger.Info("Stopped agent", "provider", key) } } @@ -119,10 +127,10 @@ func (m *AgentManager) Stop(key string) { func (m *AgentManager) StopAll() { m.mu.Lock() cancels := make([]context.CancelFunc, 0, len(m.running)) - for key, cancel := range m.running { + for key, entry := range m.running { delete(m.running, key) m.logger.Info("Stopped agent", "provider", key) - cancels = append(cancels, cancel) + cancels = append(cancels, entry.cancel) } m.mu.Unlock() diff --git a/internal/agent/manager_retry_test.go b/internal/agent/manager_retry_test.go new file mode 100644 index 00000000..2a126c63 --- /dev/null +++ b/internal/agent/manager_retry_test.go @@ -0,0 +1,69 @@ +package agent + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +type managedRetryRunner struct { + started, release, exited chan struct{} + retries atomic.Int32 +} + +func (r *managedRetryRunner) Run(ctx context.Context) error { + close(r.started) + <-ctx.Done() + <-r.release + close(r.exited) + return nil +} +func (r *managedRetryRunner) RequestRetry() error { r.retries.Add(1); return nil } +func (r *managedRetryRunner) ConnectionState() api.MistralConnection { + return api.MistralConnection{CanRetry: true} +} + +func TestAgentManagerRetryUsesCurrentInstance(t *testing.T) { + m := NewAgentManager(nil) + makeRunner := func() *managedRetryRunner { + return &managedRetryRunner{started: make(chan struct{}), release: make(chan struct{}), exited: make(chan struct{})} + } + old, current := makeRunner(), makeRunner() + m.RegisterFactory("mistral", func() (AgentRunner, error) { return old, nil }) + if err := m.RequestRetry("mistral"); !errors.Is(err, ErrRetryUnavailable) { + t.Fatal(err) + } + if err := m.Start("mistral"); err != nil { + t.Fatal(err) + } + <-old.started + if err := m.RequestRetry("mistral"); err != nil { + t.Fatal(err) + } + m.Stop("mistral") + m.RegisterFactory("mistral", func() (AgentRunner, error) { return current, nil }) + if err := m.Start("mistral"); err != nil { + t.Fatal(err) + } + <-current.started + defer func() { m.StopAll(); close(current.release); <-current.exited }() + close(old.release) + <-old.exited + // Exercise retries throughout the old goroutine's deferred cleanup. + for deadline := time.Now().Add(30 * time.Millisecond); time.Now().Before(deadline); { + if err := m.RequestRetry("mistral"); err != nil { + t.Fatalf("old exit removed replacement: %v", err) + } + time.Sleep(time.Millisecond) + } + if old.retries.Load() != 1 || current.retries.Load() == 0 { + t.Fatal("retry routed to stale instance") + } + if c, ok := m.ConnectionState("mistral"); !ok || !c.CanRetry { + t.Fatal("missing running connection state") + } +} diff --git a/internal/agent/mistral_agent.go b/internal/agent/mistral_agent.go index 0d550977..94192a2a 100644 --- a/internal/agent/mistral_agent.go +++ b/internal/agent/mistral_agent.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "log/slog" + "sync" "time" "github.com/onllm-dev/onwatch/v2/internal/api" @@ -20,25 +21,33 @@ type mistralFetcher interface { FetchSnapshot(context.Context, api.MistralSession) (*api.MistralSnapshot, error) } type MistralAgent struct { - client mistralFetcher - store *store.Store - tr *tracker.MistralTracker - cfg *config.Config - logger *slog.Logger - sm *SessionManager - notifier *notify.NotificationEngine - pollingCheck func() bool - read api.MistralCookieReader - session *api.MistralSession - source *api.MistralSource - imported time.Time - next time.Time - paused bool - failures int - importFailures int - lastPrune time.Time - sessionIdentity string - lastQuotas map[string]api.MistralQuota + client mistralFetcher + store *store.Store + tr *tracker.MistralTracker + cfg *config.Config + logger *slog.Logger + sm *SessionManager + notifier *notify.NotificationEngine + pollingCheck func() bool + read api.MistralCookieReader + session *api.MistralSession + source *api.MistralSource + imported time.Time + next time.Time + paused bool + failures int + importFailures int + lastPrune time.Time + sessionIdentity string + lastQuotas map[string]api.MistralQuota + retryMu sync.Mutex + retryCh chan struct{} + retryPending bool + retryRunning bool + pollActive bool + lastRetryRequest time.Time + serverNotBefore time.Time + connection api.MistralConnection } func NewMistralAgent(s *store.Store, cfg *config.Config, logger *slog.Logger) *MistralAgent { @@ -46,7 +55,7 @@ func NewMistralAgent(s *store.Store, cfg *config.Config, logger *slog.Logger) *M logger = slog.Default() } cfgCopy := *cfg - return &MistralAgent{client: api.NewMistralClient(), store: s, cfg: &cfgCopy, logger: logger, tr: tracker.NewMistralTracker(s), sm: NewSessionManager(s, "mistral", 15*time.Minute, logger)} + return &MistralAgent{client: api.NewMistralClient(), store: s, cfg: &cfgCopy, logger: logger, tr: tracker.NewMistralTracker(s), sm: NewSessionManager(s, "mistral", 15*time.Minute, logger), retryCh: make(chan struct{}, 1)} } func (a *MistralAgent) SetNotifier(n *notify.NotificationEngine) { a.notifier = n @@ -88,6 +97,8 @@ func (a *MistralAgent) Run(ctx context.Context) error { if e := a.store.SetSetting("mistral_identity", identity); e != nil { a.logger.Warn("Mistral identity selection deferred to first poll", "error", e) } + a.connectionError(nil) + a.persistConnection() a.poll(ctx) for { select { @@ -95,6 +106,8 @@ func (a *MistralAgent) Run(ctx context.Context) error { return nil case <-ticker.C: a.poll(ctx) + case <-a.retryCh: + a.retryPoll(ctx) } } } @@ -116,6 +129,7 @@ func (a *MistralAgent) importSession(ctx context.Context) (*api.MistralSnapshot, return nil, api.ErrMistralAuth } var sources []api.MistralSource + var importError error if a.source != nil { sources = []api.MistralSource{*a.source} } else { @@ -125,12 +139,13 @@ func (a *MistralAgent) importSession(ctx context.Context) (*api.MistralSnapshot, // whatever profiles were found. if e != nil { a.logger.Warn("Mistral profile discovery incomplete", "error", e) + importError = e } if len(sources) == 0 { if e != nil { return nil, e } - return nil, api.ErrMistralAuth + return nil, &api.MistralConnectionError{Reason: "no_session", Browser: a.cfg.MistralBrowser} } } // Covers a prompted credential-store read per candidate source; each @@ -145,6 +160,7 @@ func (a *MistralAgent) importSession(ctx context.Context) (*api.MistralSnapshot, return nil, ctx.Err() } a.logger.Warn("Mistral source unreadable", "browser", source.Browser, "error", e) + importError = e continue } a.logger.Info("Mistral source read", "browser", source.Browser, "sessions", len(sessions)) @@ -160,6 +176,7 @@ func (a *MistralAgent) importSession(ctx context.Context) (*api.MistralSnapshot, } // Cookies were read but Mistral would not serve this session. a.logger.Warn("Mistral session not usable", "browser", source.Browser, "cookies", session.CookieNames(), "error", e) + importError = e continue } a.source = &session.Source @@ -177,12 +194,19 @@ func (a *MistralAgent) importSession(ctx context.Context) (*api.MistralSnapshot, return snap, nil } } - return nil, api.ErrMistralAuth + if importError != nil { + return nil, importError + } + return nil, &api.MistralConnectionError{Reason: "no_session", Browser: a.cfg.MistralBrowser} } func (a *MistralAgent) poll(ctx context.Context) { - if a.pollingCheck != nil && !a.pollingCheck() || time.Now().Before(a.next) { + if ctx.Err() != nil || a.pollingCheck != nil && !a.pollingCheck() || time.Now().Before(a.next) { return } + a.retryMu.Lock() + a.pollActive = true + a.retryMu.Unlock() + defer func() { a.retryMu.Lock(); a.pollActive = false; a.retryMu.Unlock(); a.persistConnection() }() var snap *api.MistralSnapshot var e error // Only re-read the browser when there is no usable session, or while paused @@ -207,7 +231,8 @@ func (a *MistralAgent) poll(ctx context.Context) { a.status("reconnect") // Without this the provider sits in "reconnect" with no way to tell // a denied keychain prompt from a signed-out browser. - a.logger.Warn("Mistral credential import failed; sign in to Mistral in the selected browser or use manual cookies", "error", e, "browser", a.cfg.MistralBrowser, "profileSet", a.cfg.MistralBrowserProfile != "") + a.connectionError(e) + a.logger.Warn("Mistral credential import failed", "error", e, "browser", a.cfg.MistralBrowser, "profileSet", a.cfg.MistralBrowserProfile != "") // Back off progressively. A retry re-reads the credential store, // which can prompt for a password, so a setup that is simply not // signed in must not keep asking every ten minutes. @@ -233,11 +258,22 @@ func (a *MistralAgent) poll(ctx context.Context) { _, importErr := a.importSession(ctx) if importErr == nil { snap, e = a.client.FetchSnapshot(ctx, *a.session) + if e != nil && !errors.Is(e, api.ErrMistralAuth) { + if ctx.Err() == nil { + a.backoff(e) + } + return + } } if snap == nil && partial != nil { snap = partial } if e != nil || mistralSessionRejected(snap, nil) { + if importErr != nil { + a.connectionError(importErr) + } else { + a.connectionError(api.ErrMistralAuth) + } a.paused = true a.status("reconnect") a.next = time.Now().Add(10 * time.Minute) @@ -259,11 +295,14 @@ func (a *MistralAgent) poll(ctx context.Context) { a.status("reconnect") } else { a.status(snap.Status) + a.connectionError(nil) } if snap.RetryAfter > 0 { a.next = time.Now().Add(min(snap.RetryAfter, time.Hour)) + a.respectServerDelay(snap.RetryAfter) } if e = a.store.SaveMistral(ctx, snap); e != nil { + a.connectionError(e) a.logger.Error("Mistral storage failed") return } @@ -320,10 +359,14 @@ func (a *MistralAgent) backoff(e error) { a.failures = min(a.failures+1, 6) delay := time.Duration(1< delay { delay = he.RetryAfter } a.next = time.Now().Add(min(delay, time.Hour)) a.status("stale") + a.connectionError(e) a.logger.Warn("Mistral polling failed", "error", e) } diff --git a/internal/agent/mistral_retry.go b/internal/agent/mistral_retry.go new file mode 100644 index 00000000..39d74eee --- /dev/null +++ b/internal/agent/mistral_retry.go @@ -0,0 +1,108 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +const mistralRetryCooldown = 30 * time.Second + +func (a *MistralAgent) RequestRetry() error { + if !a.cfg.HasProvider("mistral") || a.pollingCheck != nil && !a.pollingCheck() { + return ErrRetryUnavailable + } + a.retryMu.Lock() + defer a.retryMu.Unlock() + if a.retryPending || a.retryRunning { + return nil + } + until := a.lastRetryRequest.Add(mistralRetryCooldown) + if a.serverNotBefore.After(until) { + until = a.serverNotBefore + } + if delay := time.Until(until); delay > 0 { + return &RetryCooldownError{RetryAfter: delay} + } + a.lastRetryRequest = time.Now() + a.retryPending = true + a.retryCh <- struct{}{} + return nil +} + +func (a *MistralAgent) ConnectionState() api.MistralConnection { + a.retryMu.Lock() + c := a.connection + c.Retrying = a.retryPending || a.retryRunning || a.pollActive + c.CanRetry = !c.Retrying && time.Since(a.lastRetryRequest) >= mistralRetryCooldown && !time.Now().Before(a.serverNotBefore) + a.retryMu.Unlock() + c.CanRetry = c.CanRetry && a.cfg.HasProvider("mistral") && (a.pollingCheck == nil || a.pollingCheck()) + return c +} + +// Called only by the polling loop. All state changes stay on that goroutine. +func (a *MistralAgent) retryPoll(ctx context.Context) { + a.retryMu.Lock() + a.retryPending = false + a.retryRunning = true + limited := time.Now().Before(a.serverNotBefore) + a.retryMu.Unlock() + defer func() { a.retryMu.Lock(); a.retryRunning = false; a.retryMu.Unlock(); a.persistConnection() }() + if ctx.Err() != nil || limited { + return + } + a.next = time.Time{} + a.paused = false + a.session = nil + a.importFailures = 0 + a.failures = 0 + a.poll(ctx) +} + +func (a *MistralAgent) connectionError(err error) { + c := api.MistralConnection{} + if err != nil { + var diagnostic *api.MistralConnectionError + if errors.As(err, &diagnostic) { + c = diagnostic.Connection() + } else if errors.Is(err, api.ErrMistralAuth) { + c = (&api.MistralConnectionError{Reason: "session_rejected", Browser: a.cfg.MistralBrowser}).Connection() + } else { + c.Reason = "request_failed" + c.Message = "Mistral usage is temporarily unavailable. Retrying automatically." + } + } + a.retryMu.Lock() + a.connection = c + a.retryMu.Unlock() +} + +func (a *MistralAgent) persistConnection() { + a.retryMu.Lock() + if a.next.After(time.Now()) { + next := a.next + a.connection.NextRetryAt = &next + } else { + a.connection.NextRetryAt = nil + } + a.retryMu.Unlock() + data, err := json.Marshal(a.ConnectionState()) + if err == nil { + _ = a.store.SetSetting("mistral_connection", string(data)) + } +} + +func (a *MistralAgent) respectServerDelay(delay time.Duration) { + if delay <= 0 { + return + } + until := time.Now().Add(min(delay, time.Hour)) + a.retryMu.Lock() + if until.After(a.serverNotBefore) { + a.serverNotBefore = until + } + a.retryMu.Unlock() +} diff --git a/internal/agent/mistral_retry_test.go b/internal/agent/mistral_retry_test.go new file mode 100644 index 00000000..bd3ad59d --- /dev/null +++ b/internal/agent/mistral_retry_test.go @@ -0,0 +1,156 @@ +package agent + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" + "github.com/steipete/sweetcookie" +) + +func TestMistralRetryRecoversWithoutRestart(t *testing.T) { + db, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{MistralEnabled: true, PollInterval: time.Hour}, slog.New(slog.NewTextHandler(io.Discard, nil))) + a.source = &api.MistralSource{Browser: "chrome", Profile: "synthetic", Container: 0} + var permitted atomic.Bool + var imports atomic.Int32 + a.read = func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + imports.Add(1) + if !permitted.Load() { + return sweetcookie.Result{Warnings: []string{"failed to copy cookies DB: permission denied"}}, nil + } + return sweetcookie.Result{Cookies: []sweetcookie.Cookie{{Name: "ory_session_test", Value: "same-cookie", Domain: ".mistral.ai", Path: "/"}}}, nil + } + a.client = &fakeMistralFetcher{} + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { defer close(done); _ = a.Run(ctx) }() + defer func() { cancel(); <-done }() + waitMistral(t, func() bool { c := a.ConnectionState(); return c.Reason == "browser_access_denied" && !c.Retrying }) + permitted.Store(true) + if err := a.RequestRetry(); err != nil { + t.Fatal(err) + } + waitMistral(t, func() bool { + snap, _ := db.LatestMistral(context.Background()) + return snap != nil && !a.ConnectionState().Retrying + }) + if imports.Load() != 2 { + t.Fatalf("imports=%d", imports.Load()) + } + if c := a.ConnectionState(); c.Reason != "" { + t.Fatalf("stale diagnostic: %+v", c) + } + var cooldown *RetryCooldownError + if err := a.RequestRetry(); !errors.As(err, &cooldown) { + t.Fatalf("missing cooldown: %v", err) + } +} + +func waitMistral(t *testing.T, predicate func() bool) { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for !predicate() { + if time.Now().After(deadline) { + t.Fatal("timed out waiting for Mistral") + } + time.Sleep(time.Millisecond) + } +} + +func TestMistralRetryCoalescesAndRespectsRateLimit(t *testing.T) { + db, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{MistralEnabled: true}, nil) + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if err := a.RequestRetry(); err != nil { + t.Errorf("coalesce: %v", err) + } + }() + } + wg.Wait() + if len(a.retryCh) != 1 { + t.Fatalf("queued %d", len(a.retryCh)) + } + <-a.retryCh + a.retryMu.Lock() + a.retryPending = false + a.lastRetryRequest = time.Time{} + a.retryMu.Unlock() + a.backoff(&api.MistralHTTPError{Status: 429, RetryAfter: time.Hour}) + var cooldown *RetryCooldownError + if err := a.RequestRetry(); !errors.As(err, &cooldown) || cooldown.RetryAfter < 59*time.Minute { + t.Fatalf("rate limit bypassed: %v", err) + } + a.SetPollingCheck(func() bool { return false }) + if err := a.RequestRetry(); !errors.Is(err, ErrRetryUnavailable) { + t.Fatalf("disabled polling: %v", err) + } +} + +func TestMistralRetryUnchangedRejectedCookie(t *testing.T) { + db, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{MistralEnabled: true, MistralAuthMode: "manual", MistralAuthCookie: "ory_session_test=same-cookie"}, nil) + defer a.sm.Close() + f := &fakeMistralFetcher{authFail: true} + a.client = f + a.poll(context.Background()) + if !a.paused || f.calls != 2 { + t.Fatalf("paused=%v calls=%d", a.paused, f.calls) + } + f.authFail = false + if err := a.RequestRetry(); err != nil { + t.Fatal(err) + } + <-a.retryCh + a.retryPoll(context.Background()) + if a.paused || f.calls != 3 || a.ConnectionState().Reason != "" { + t.Fatalf("did not recover: %+v calls=%d", a.ConnectionState(), f.calls) + } +} + +func TestMistralRetryCancelledDoesNotImport(t *testing.T) { + db, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + a := NewMistralAgent(db, &config.Config{MistralEnabled: true}, nil) + a.read = func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + t.Fatal("cancelled retry imported cookies") + return sweetcookie.Result{}, nil + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := a.RequestRetry(); err != nil { + t.Fatal(err) + } + <-a.retryCh + a.retryPoll(ctx) + if a.ConnectionState().Retrying { + t.Fatal("cancelled request still pending") + } +} diff --git a/internal/agent/retry.go b/internal/agent/retry.go new file mode 100644 index 00000000..d59b1ade --- /dev/null +++ b/internal/agent/retry.go @@ -0,0 +1,46 @@ +package agent + +import ( + "errors" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +var ErrRetryUnavailable = errors.New("provider retry is unavailable") + +type RetryCooldownError struct{ RetryAfter time.Duration } + +func (e *RetryCooldownError) Error() string { return "provider retry is cooling down" } + +// RetryableRunner is optional; providers opt in without changing AgentRunner. +type RetryableRunner interface { + RequestRetry() error + ConnectionState() api.MistralConnection +} + +func (m *AgentManager) RequestRetry(key string) error { + m.mu.RLock() + defer m.mu.RUnlock() + entry := m.running[key] + if entry == nil { + return ErrRetryUnavailable + } + runner, ok := entry.runner.(RetryableRunner) + if !ok { + return ErrRetryUnavailable + } + return runner.RequestRetry() +} + +func (m *AgentManager) ConnectionState(key string) (api.MistralConnection, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + entry := m.running[key] + if entry != nil { + if runner, ok := entry.runner.(RetryableRunner); ok { + return runner.ConnectionState(), true + } + } + return api.MistralConnection{}, false +} diff --git a/internal/api/mistral_connection.go b/internal/api/mistral_connection.go new file mode 100644 index 00000000..0bcfc696 --- /dev/null +++ b/internal/api/mistral_connection.go @@ -0,0 +1,92 @@ +package api + +import ( + "errors" + "io/fs" + "strings" + "time" +) + +// MistralConnection contains only safe diagnostics, never cookies or OS errors. +type MistralConnection struct { + Reason string `json:"reason"` + Message string `json:"message"` + Retrying bool `json:"retrying"` + NextRetryAt *time.Time `json:"nextRetryAt,omitempty"` + CanRetry bool `json:"canRetry"` +} + +// MistralConnectionError deliberately does not retain the raw library error: +// warning strings can contain cookie values, command output, or private paths. +type MistralConnectionError struct { + Reason string + Browser string + permission bool +} + +func (e *MistralConnectionError) Error() string { return "mistral: " + e.Connection().Message } +func (e *MistralConnectionError) Is(target error) bool { + return target == ErrMistralAuth || e.permission && target == fs.ErrPermission +} + +func (e *MistralConnectionError) Connection() MistralConnection { + browser := map[string]string{"chrome": "Chrome", "edge": "Microsoft Edge", "firefox": "Firefox", "safari": "Safari"}[e.Browser] + if browser == "" { + browser = "the selected browser" + } + message := "Could not import browser session. Retry connection or use manual cookies." + switch e.Reason { + case "browser_access_denied": + message = "Allow onWatch to access " + browser + "'s data, then retry connection. On macOS, use Grant Browser Access in the tray menu." + case "credential_store_denied": + message = "Access to the browser credential store was denied. Allow the Keychain or keyring prompt, then retry connection." + case "credential_store_unavailable": + message = "The browser credential store is unavailable. Unlock your Keychain or keyring, then retry connection, or use manual cookies." + case "browser_store_unavailable": + message = "Could not read " + browser + "'s cookie storage. Check that the selected profile exists and is readable, then retry connection." + case "no_session": + message = "No Mistral login found. Sign in to Mistral in " + browser + ", then retry connection, or update manual cookies." + case "session_rejected": + message = "Mistral rejected the saved session. Sign in again in " + browser + ", then retry connection, or update manual cookies." + } + return MistralConnection{Reason: e.Reason, Message: message} +} + +// ClassifyMistralImportError uses a small allowlist of known library diagnostics. +// Unknown failures must not be reported as a signed-out browser. +func ClassifyMistralImportError(browser string, err error, warnings []string) *MistralConnectionError { + var existing *MistralConnectionError + if errors.As(err, &existing) { + return existing + } + reason := "import_failed" + permission := errors.Is(err, fs.ErrPermission) + if err == nil && len(warnings) == 0 { + reason = "no_session" + } + messages := append([]string(nil), warnings...) + if err != nil { + messages = append(messages, err.Error()) + } + for _, message := range messages { + m := strings.ToLower(message) + credential := strings.Contains(m, "keychain") || strings.Contains(m, "keyring") || strings.Contains(m, "secret service") + denied := strings.Contains(m, "permission denied") || strings.Contains(m, "operation not permitted") || strings.Contains(m, "user canceled") || strings.Contains(m, "user cancelled") || strings.Contains(m, "access denied") + switch { + case credential && denied: + return &MistralConnectionError{Reason: "credential_store_denied", Browser: browser} + case credential: + reason = "credential_store_unavailable" + case denied: + permission = true + case strings.Contains(m, "database is locked") || strings.Contains(m, "failed to copy cookies db") || strings.Contains(m, "failed to open") || strings.Contains(m, "unable to open database") || errors.Is(err, fs.ErrNotExist): + if reason == "import_failed" { + reason = "browser_store_unavailable" + } + } + } + if permission { + reason = "browser_access_denied" + } + return &MistralConnectionError{Reason: reason, Browser: browser, permission: permission} +} diff --git a/internal/api/mistral_connection_test.go b/internal/api/mistral_connection_test.go new file mode 100644 index 00000000..1e2cf4a1 --- /dev/null +++ b/internal/api/mistral_connection_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "errors" + "io/fs" + "strings" + "testing" + + "github.com/steipete/sweetcookie" +) + +func TestMistralImportDiagnostics(t *testing.T) { + for _, tc := range []struct { + name, warning, reason string + err error + }{ + {"permission", "", "browser_access_denied", &fs.PathError{Op: "open", Path: "/private/person/Cookies", Err: fs.ErrPermission}}, + {"warning permission", "sweetcookie: failed to copy cookies DB: open /private/person/Cookies: operation not permitted", "browser_access_denied", nil}, + {"keychain denied", "sweetcookie: macOS keychain read failed (Chrome Safe Storage): user canceled the operation", "credential_store_denied", nil}, + {"keychain unavailable", "sweetcookie: macOS keychain read failed (Chrome Safe Storage): item could not be found", "credential_store_unavailable", nil}, + {"locked", "sweetcookie: failed to read Chrome cookies: database is locked", "browser_store_unavailable", nil}, + {"unknown", "unexpected failure with secret-cookie and /private/person", "import_failed", nil}, + {"empty", "", "no_session", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := ImportMistralSessions(context.Background(), MistralSource{Browser: "chrome", Profile: "synthetic", Container: 0}, func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + result := sweetcookie.Result{} + if tc.warning != "" { + result.Warnings = []string{tc.warning} + } + return result, tc.err + }) + var diagnostic *MistralConnectionError + if !errors.As(err, &diagnostic) || diagnostic.Reason != tc.reason { + t.Fatalf("got %v, want reason %s", err, tc.reason) + } + if strings.Contains(err.Error(), "private") || strings.Contains(err.Error(), "secret-cookie") { + t.Fatal("diagnostic leaks raw input") + } + if tc.err != nil && !errors.Is(err, fs.ErrPermission) { + t.Fatal("lost permission cause") + } + }) + } +} + +func TestMistralImportUsableSessionIgnoresWarnings(t *testing.T) { + sessions, err := ImportMistralSessions(context.Background(), MistralSource{Browser: "chrome", Profile: "synthetic", Container: 0}, func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + return sweetcookie.Result{Warnings: []string{"unrelated store failed"}, Cookies: []sweetcookie.Cookie{{Name: "ory_session_test", Value: "synthetic", Domain: ".mistral.ai", Path: "/"}}}, nil + }) + if err != nil || len(sessions) != 1 { + t.Fatalf("sessions=%d err=%v", len(sessions), err) + } +} + +func TestMistralImportCSRFOnlyIsNotSession(t *testing.T) { + _, err := ImportMistralSessions(context.Background(), MistralSource{Browser: "chrome", Profile: "synthetic", Container: 0}, func(context.Context, sweetcookie.Options) (sweetcookie.Result, error) { + return sweetcookie.Result{Cookies: []sweetcookie.Cookie{{Name: "csrftoken", Value: "synthetic", Domain: ".mistral.ai", Path: "/"}}}, nil + }) + var diagnostic *MistralConnectionError + if !errors.As(err, &diagnostic) || diagnostic.Reason != "no_session" { + t.Fatalf("got %v", err) + } +} diff --git a/internal/api/mistral_cookie.go b/internal/api/mistral_cookie.go index f4a6a175..28a8d321 100644 --- a/internal/api/mistral_cookie.go +++ b/internal/api/mistral_cookie.go @@ -151,6 +151,9 @@ func MistralSources(browser, profile string) ([]MistralSource, error) { var profiles []string if b == "firefox" { cfg, err := ini.Load(filepath.Join(root, "profiles.ini")) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + scanErrs = append(scanErrs, mistralScanError(b, err)) + } if err == nil { for _, section := range cfg.Sections() { if !strings.HasPrefix(section.Name(), "Profile") { @@ -198,10 +201,7 @@ func MistralSources(browser, profile string) ([]MistralSource, error) { // directory into the daemon log. A permission failure stays detectable // through fs.ErrPermission, so callers can still tell it apart. func mistralScanError(browser string, err error) error { - if errors.Is(err, fs.ErrPermission) { - return fmt.Errorf("%s: profile folder not readable: %w", browser, fs.ErrPermission) - } - return fmt.Errorf("%s: profile folder not readable", browser) + return ClassifyMistralImportError(browser, err, nil) } func ImportMistralSessions(ctx context.Context, source MistralSource, read MistralCookieReader) ([]MistralSession, error) { @@ -218,24 +218,22 @@ func ImportMistralSessions(ctx context.Context, source MistralSource, read Mistr ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() b := sweetcookie.Browser(source.Browser) - if restoreScope && b == sweetcookie.BrowserSafari { - if _, e := readMistralSafariScopes(ctx, source.Profile); e != nil { - return nil, ErrMistralAuth - } - } // Cookie names and hosts are stored in the clear; only values are // encrypted. Checking them first means a profile that has never signed in // to Mistral is skipped without touching the platform credential store, // which is what raises a password prompt. - if scopes, e := readMistralScopes(ctx, mistralStorePath(source), b); e == nil && len(scopes) == 0 { - return nil, nil + scopes, scopeErr := readMistralScopes(ctx, mistralStorePath(source), b) + if scopeErr == nil && len(scopes) == 0 { + return nil, ClassifyMistralImportError(source.Browser, nil, nil) + } else if scopeErr != nil && restoreScope { + return nil, ClassifyMistralImportError(source.Browser, scopeErr, nil) } result, err := read(ctx, sweetcookie.Options{URL: "https://admin.mistral.ai/subscription", Origins: []string{"https://admin.mistral.ai/api/billing/v2/usage", "https://console.mistral.ai/api-ui/trpc/billing.vibeUsage"}, Browsers: []sweetcookie.Browser{b}, Profiles: map[sweetcookie.Browser]string{b: source.Profile}, Timeout: 90 * time.Second}) if ctx.Err() != nil { return nil, ctx.Err() } if err != nil { - return nil, ErrMistralAuth + return nil, ClassifyMistralImportError(source.Browser, err, result.Warnings) } if restoreScope { result.Cookies, err = restoreMistralScopes(ctx, result.Cookies) @@ -261,10 +259,27 @@ func ImportMistralSessions(ctx context.Context, source MistralSource, read Mistr sort.Ints(ids) var sessions []MistralSession for _, id := range ids { + hasSession := false + for _, c := range groups[id] { + if strings.HasPrefix(c.Name, "ory_session_") && c.Value != "" { + hasSession = true + } + } + if !hasSession { + continue + } s := source s.Container = id sessions = append(sessions, MistralSession{s, groups[id]}) } + if len(sessions) == 0 { + // The library silently drops cookies it cannot decrypt. When matching + // metadata exists, an empty result alone is not evidence of logout. + if len(scopes) > 0 && len(result.Warnings) == 0 { + return nil, &MistralConnectionError{Reason: "import_failed", Browser: source.Browser} + } + return nil, ClassifyMistralImportError(source.Browser, nil, result.Warnings) + } return sessions, nil } @@ -278,6 +293,10 @@ func mistralStorePath(source MistralSource) string { case sweetcookie.BrowserFirefox: return filepath.Join(source.Profile, "cookies.sqlite") default: + path := filepath.Join(source.Profile, "Network", "Cookies") + if file, err := os.Stat(path); err == nil && !file.IsDir() { + return path + } return filepath.Join(source.Profile, "Cookies") } } diff --git a/internal/api/mistral_cookie_test.go b/internal/api/mistral_cookie_test.go index a306511d..bfc5ddb8 100644 --- a/internal/api/mistral_cookie_test.go +++ b/internal/api/mistral_cookie_test.go @@ -180,7 +180,7 @@ func TestMistralProfileWithoutMistralCookiesIsNotDecrypted(t *testing.T) { return sweetcookie.Result{}, nil } sessions, err := ImportMistralSessions(context.Background(), MistralSource{Browser: "chrome", Profile: dir, Container: -1}, read) - if err != nil || len(sessions) != 0 { + if !errors.Is(err, ErrMistralAuth) || len(sessions) != 0 { t.Fatalf("sessions=%d err=%v", len(sessions), err) } } diff --git a/internal/api/mistral_scope.go b/internal/api/mistral_scope.go index fadac877..6cd59876 100644 --- a/internal/api/mistral_scope.go +++ b/internal/api/mistral_scope.go @@ -32,11 +32,11 @@ func restoreMistralScopes(ctx context.Context, cookies []sweetcookie.Cookie) ([] return nil, ctx.Err() } if path == "" { - return nil, ErrMistralAuth + return nil, &MistralConnectionError{Reason: "import_failed"} } scopes, e := readMistralScopes(ctx, path, group[0].Source.Browser) if e != nil { - return nil, ErrMistralAuth + return nil, ClassifyMistralImportError(string(group[0].Source.Browser), e, nil) } for _, c := range group { key := mistralScopeKey(c.Domain, c.Name, c.Path, c.Container.ID) @@ -72,6 +72,13 @@ func readMistralScopes(ctx context.Context, path string, browser sweetcookie.Bro if browser == sweetcookie.BrowserSafari { return readMistralSafariScopes(ctx, path) } + // Preserve OS permission errors before SQLite turns them into a generic + // "unable to open database" error. Only metadata is read below. + f, err := os.Open(path) + if err != nil { + return nil, err + } + _ = f.Close() db, e := sql.Open("sqlite", readOnlySQLiteURI(path)) if e != nil { return nil, e diff --git a/internal/menubar/browser_grant.go b/internal/menubar/browser_grant.go new file mode 100644 index 00000000..4a968577 --- /dev/null +++ b/internal/menubar/browser_grant.go @@ -0,0 +1,32 @@ +package menubar + +import "sync" + +// Both native entry points share one gate for the lifetime of the picker. +type browserGrantFlow struct{ mu sync.Mutex } + +func (g *browserGrantFlow) run(blocked func() string, request func(string) (bool, string), verify func(string) error, retry func() error) string { + if !g.mu.TryLock() { + return "busy" + } + defer g.mu.Unlock() + if path := blocked(); path != "" { + granted, detail := request(path) + if !granted { + if detail == "cancelled" { + return "cancelled" + } + return "unavailable" + } + if err := verify(path); err != nil { + return "unavailable" + } + } + if retry == nil { + return "retry_failed" + } + if err := retry(); err != nil { + return "retry_failed" + } + return "retrying" +} diff --git a/internal/menubar/browser_grant_bridge_darwin.go b/internal/menubar/browser_grant_bridge_darwin.go new file mode 100644 index 00000000..c36f68b5 --- /dev/null +++ b/internal/menubar/browser_grant_bridge_darwin.go @@ -0,0 +1,60 @@ +//go:build menubar && darwin && cgo + +package menubar + +/* +#include +#include +#include +void onwatch_grant_finish(uint64_t token, const char *result); +bool onwatch_grant_origin_allowed(const char *configuredURL, const char *scheme, const char *host, int port, bool mainFrame); +*/ +import "C" + +import ( + "sync" + "unsafe" +) + +func grantOriginAllowed(configured, scheme, host string, port int, mainFrame bool) bool { + cURL, cScheme, cHost := C.CString(configured), C.CString(scheme), C.CString(host) + defer C.free(unsafe.Pointer(cURL)) + defer C.free(unsafe.Pointer(cScheme)) + defer C.free(unsafe.Pointer(cHost)) + return bool(C.onwatch_grant_origin_allowed(cURL, cScheme, cHost, C.int(port), C.bool(mainFrame))) +} + +var grantHandlers = struct { + sync.Mutex + next uint64 + items map[uint64]func() string +}{items: make(map[uint64]func() string)} + +func registerGrantHandler(fn func() string) uint64 { + grantHandlers.Lock() + defer grantHandlers.Unlock() + grantHandlers.next++ + grantHandlers.items[grantHandlers.next] = fn + return grantHandlers.next +} + +func unregisterGrantHandler(token uint64) { + grantHandlers.Lock() + defer grantHandlers.Unlock() + delete(grantHandlers.items, token) +} + +//export onwatchGoGrantBrowserAccess +func onwatchGoGrantBrowserAccess(token C.uint64_t) { + grantHandlers.Lock() + fn := grantHandlers.items[uint64(token)] + grantHandlers.Unlock() + if fn == nil { + return + } + go func() { + result := C.CString(fn()) + defer C.free(unsafe.Pointer(result)) + C.onwatch_grant_finish(token, result) + }() +} diff --git a/internal/menubar/browser_grant_bridge_darwin_test.go b/internal/menubar/browser_grant_bridge_darwin_test.go new file mode 100644 index 00000000..ff646ca4 --- /dev/null +++ b/internal/menubar/browser_grant_bridge_darwin_test.go @@ -0,0 +1,37 @@ +//go:build menubar && darwin && cgo + +package menubar + +import "testing" + +func TestBrowserGrantOrigin(t *testing.T) { + for _, tc := range []struct { + scheme, host string + port int + main, want bool + }{ + {"http", "127.0.0.1", 9211, true, true}, + {"http", "127.0.0.1", 9211, false, false}, + {"http", "127.0.0.1", 9212, true, false}, + {"https", "127.0.0.1", 9211, true, false}, + {"http", "example.com", 9211, true, false}, + {"", "", 0, true, false}, + } { + if got := grantOriginAllowed("http://127.0.0.1:9211/watch/menubar", tc.scheme, tc.host, tc.port, tc.main); got != tc.want { + t.Fatalf("%+v: got %v", tc, got) + } + } + if grantOriginAllowed("http://example.com:9211/menubar", "http", "example.com", 9211, true) { + t.Fatal("nonlocal configured host accepted") + } +} + +func TestBrowserGrantHandlerRemoval(t *testing.T) { + token := registerGrantHandler(func() string { return "cancelled" }) + unregisterGrantHandler(token) + grantHandlers.Lock() + defer grantHandlers.Unlock() + if _, ok := grantHandlers.items[token]; ok { + t.Fatal("destroyed host retained callback") + } +} diff --git a/internal/menubar/browser_grant_callback_darwin_test.go b/internal/menubar/browser_grant_callback_darwin_test.go new file mode 100644 index 00000000..9c6476fc --- /dev/null +++ b/internal/menubar/browser_grant_callback_darwin_test.go @@ -0,0 +1,84 @@ +//go:build menubar && darwin && cgo && granttest + +package menubar + +import ( + "sync/atomic" + "testing" + "time" +) + +func grantTestHost(t *testing.T, fn func() string) *webViewPopover { + t.Helper() + var host menubarPopover + var err error + runOnMainThread(t, func() { + host, err = newMenubarPopover(320, 240) + if err == nil { + host.(*webViewPopover).SetBrowserGrantHandler(fn) + } + }) + if err != nil { + t.Fatal(err) + } + p := host.(*webViewPopover) + t.Cleanup(func() { runOnMainThread(t, p.Destroy) }) + return p +} + +func awaitGrantResult(t *testing.T, p *webViewPopover, want string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + var got string + runOnMainThread(t, func() { pumpNativeGrantTest(); got = nativeGrantTestResult(p) }) + if got == want { + return + } + } + t.Fatalf("native completion did not deliver %q", want) +} + +func TestBrowserGrantNativeCallback(t *testing.T) { + for _, result := range []string{"retrying", "cancelled", "unavailable", "retry_failed"} { + t.Run(result, func(t *testing.T) { + var calls atomic.Int32 + p := grantTestHost(t, func() string { calls.Add(1); return result }) + runOnMainThread(t, func() { startNativeGrantTest(p.grantToken) }) + awaitGrantResult(t, p, result) + if calls.Load() != 1 { + t.Fatalf("callback ran %d times", calls.Load()) + } + }) + } +} + +func TestBrowserGrantNativeCallbackAfterDestroy(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + var calls atomic.Int32 + p := grantTestHost(t, func() string { + calls.Add(1) + close(started) + <-release + return "cancelled" + }) + token := p.grantToken + runOnMainThread(t, func() { startNativeGrantTest(token) }) + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("callback not started") + } + runOnMainThread(t, p.Destroy) + close(release) + // A stale native message must not invoke the removed handler again. + startNativeGrantTest(token) + // Pump real completions with a replacement host. The old completion must + // neither crash nor overwrite the replacement host's result. + replacement := grantTestHost(t, func() string { return "retrying" }) + startNativeGrantTest(replacement.grantToken) + awaitGrantResult(t, replacement, "retrying") + if calls.Load() != 1 { + t.Fatal("destroyed handler invoked again") + } +} diff --git a/internal/menubar/browser_grant_test.go b/internal/menubar/browser_grant_test.go new file mode 100644 index 00000000..0cc3c52c --- /dev/null +++ b/internal/menubar/browser_grant_test.go @@ -0,0 +1,53 @@ +package menubar + +import ( + "errors" + "testing" +) + +func TestBrowserGrantFlow(t *testing.T) { + for _, outcome := range []string{"granted", "cancelled", "panel was not answered"} { + t.Run(outcome, func(t *testing.T) { + calls := 0 + g := browserGrantFlow{} + result := g.run(func() string { return "/browser" }, func(string) (bool, string) { return outcome == "granted", outcome }, func(string) error { return nil }, func() error { calls++; return nil }) + if outcome == "granted" && (calls != 1 || result != "retrying") { + t.Fatalf("%s calls=%d", result, calls) + } + if outcome != "granted" && calls != 0 { + t.Fatal("retried without grant") + } + }) + } + g := browserGrantFlow{} + calls := 0 + result := g.run(func() string { return "/browser" }, func(string) (bool, string) { return true, "granted" }, func(string) error { return errors.New("denied") }, func() error { calls++; return nil }) + if result != "unavailable" || calls != 0 { + t.Fatalf("%s calls=%d", result, calls) + } +} + +func TestBrowserGrantCoalesces(t *testing.T) { + g := browserGrantFlow{} + started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) + go func() { + defer close(done) + g.run(func() string { return "/browser" }, func(string) (bool, string) { close(started); <-release; return false, "cancelled" }, nil, nil) + }() + <-started + result := g.run(func() string { t.Error("duplicate reached picker"); return "" }, nil, nil, nil) + close(release) + <-done + if result != "busy" { + t.Fatal(result) + } +} + +func TestBrowserGrantAlreadyReadable(t *testing.T) { + g := browserGrantFlow{} + calls := 0 + result := g.run(func() string { return "" }, nil, nil, func() error { calls++; return errors.New("cooldown") }) + if calls != 1 || result != "retry_failed" { + t.Fatalf("%s calls=%d", result, calls) + } +} diff --git a/internal/menubar/browser_grant_test_support_darwin.go b/internal/menubar/browser_grant_test_support_darwin.go new file mode 100644 index 00000000..ae2ca6f3 --- /dev/null +++ b/internal/menubar/browser_grant_test_support_darwin.go @@ -0,0 +1,34 @@ +//go:build menubar && darwin && cgo && granttest + +package menubar + +// This harness is excluded from release binaries. Tests use the real C -> Go +// callback and Cocoa completion queue, substituting the permission-flow result. + +/* +#import +#include +#include +#include +extern void onwatchGoGrantBrowserAccess(uint64_t token); +static void test_grant_start(uint64_t token) { onwatchGoGrantBrowserAccess(token); } +static void test_grant_pump(void) { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]]; +} +static char *test_grant_result(void *handle) { + id host = (__bridge id)handle; + NSString *result = [host valueForKey:@"grantResult"]; + return strdup(result ? result.UTF8String : ""); +} +*/ +import "C" + +import "unsafe" + +func startNativeGrantTest(token uint64) { C.test_grant_start(C.uint64_t(token)) } +func pumpNativeGrantTest() { C.test_grant_pump() } +func nativeGrantTestResult(p *webViewPopover) string { + value := C.test_grant_result(p.handle) + defer C.free(unsafe.Pointer(value)) + return C.GoString(value) +} diff --git a/internal/menubar/companion.go b/internal/menubar/companion.go index 046b78fe..fb514b1b 100644 --- a/internal/menubar/companion.go +++ b/internal/menubar/companion.go @@ -35,6 +35,7 @@ type trayController struct { mistralMu sync.RWMutex mistralEnabled bool + grantFlow browserGrantFlow } func (c *trayController) setMistralEnabled(enabled bool) { @@ -91,6 +92,9 @@ func (c *trayController) onReady() { logger.Warn("native menubar host unavailable, using browser fallback", "error", err) } else { c.popover = popover + if host, ok := popover.(interface{ SetBrowserGrantHandler(func() string) }); ok { + host.SetBrowserGrantHandler(c.grantBrowserAccess) + } // Warm the WebView so the first tray click does not flash a blank page // while /menubar navigates. Subsequent opens reuse the loaded document. if err := popover.Preload(c.menubarURL()); err != nil { @@ -142,6 +146,20 @@ func (c *trayController) onExit() { // watchBrowserAccess shows the grant action only while a browser data // directory exists but cannot be read, and re-checks after each attempt so the // item disappears once access has been granted. +func (c *trayController) grantBrowserAccess() string { + if !c.isMistralEnabled() { + return "unavailable" + } + home, err := os.UserHomeDir() + if err != nil { + return "unavailable" + } + return c.grantFlow.run(func() string { return blockedBrowserRoot(browserDataRoots(home)) }, + func(path string) (bool, string) { + return requestFolderAccess(path, "Select this folder to let onWatch read your browser cookies for Mistral. onWatch reads only Mistral session cookies from it.") + }, func(path string) error { _, err := os.ReadDir(path); return err }, c.cfg.MistralRetry) +} + func (c *trayController) watchBrowserAccess(grantItem *systray.MenuItem) { logger := slog.Default() previous := "\x00" @@ -182,8 +200,7 @@ func (c *trayController) watchBrowserAccess(grantItem *systray.MenuItem) { continue } } - granted, detail := requestFolderAccess(blocked, "Select this folder to let onWatch read your browser cookies for Mistral. onWatch reads only Mistral session cookies from it.") - logger.Info("Browser access grant requested", "folder", filepath.Base(blocked), "chosen", granted, "detail", detail) + logger.Info("Browser access request finished", "result", c.grantBrowserAccess()) blocked = refresh() } } diff --git a/internal/menubar/config.go b/internal/menubar/config.go index eee66670..af860f70 100644 --- a/internal/menubar/config.go +++ b/internal/menubar/config.go @@ -4,6 +4,8 @@ import ( "sort" "strings" "time" + + "github.com/onllm-dev/onwatch/v2/internal/api" ) // SnapshotProvider returns the latest menubar snapshot. @@ -21,6 +23,7 @@ type Config struct { BinaryPath string TestMode bool SnapshotProvider SnapshotProvider + MistralRetry func() error } // Settings holds persisted menubar preferences. @@ -104,17 +107,18 @@ type Aggregate struct { // ProviderCard is the top-level card rendered for each provider. type ProviderCard struct { - ID string `json:"id"` - BaseProvider string `json:"base_provider"` - Label string `json:"label"` - Subtitle string `json:"subtitle,omitempty"` - ConnectionStatus string `json:"connection_status,omitempty"` - Status string `json:"status"` - HighestPercent float64 `json:"highest_percent"` - UpdatedAt string `json:"updated_at,omitempty"` - Quotas []QuotaMeter `json:"quotas"` - Trends []TrendSeries `json:"trends,omitempty"` - Promo *ProviderPromo `json:"promo,omitempty"` + ID string `json:"id"` + BaseProvider string `json:"base_provider"` + Label string `json:"label"` + Subtitle string `json:"subtitle,omitempty"` + ConnectionStatus string `json:"connection_status,omitempty"` + Connection *api.MistralConnection `json:"connection,omitempty"` + Status string `json:"status"` + HighestPercent float64 `json:"highest_percent"` + UpdatedAt string `json:"updated_at,omitempty"` + Quotas []QuotaMeter `json:"quotas"` + Trends []TrendSeries `json:"trends,omitempty"` + Promo *ProviderPromo `json:"promo,omitempty"` } // ProviderPromo carries promo metadata for a provider card. diff --git a/internal/menubar/popover_darwin.m b/internal/menubar/popover_darwin.m index 37d99185..09036828 100644 --- a/internal/menubar/popover_darwin.m +++ b/internal/menubar/popover_darwin.m @@ -28,7 +28,23 @@ static void onwatch_run_on_main_sync(dispatch_block_t block) { dispatch_sync(dispatch_get_main_queue(), block); } +extern void onwatchGoGrantBrowserAccess(uint64_t token); +static NSMapTable *onwatchGrantHosts; + +bool onwatch_grant_origin_allowed(const char *configuredURL, const char *scheme, const char *host, int port, bool mainFrame) { + if (!configuredURL || !scheme || !host || !mainFrame) return false; + NSURL *configured = [NSURL URLWithString:[NSString stringWithUTF8String:configuredURL]]; + return [configured.scheme isEqualToString:@"http"] && + ([configured.host isEqualToString:@"localhost"] || [configured.host isEqualToString:@"127.0.0.1"]) && + [configured.scheme isEqualToString:[NSString stringWithUTF8String:scheme]] && + [configured.host isEqualToString:[NSString stringWithUTF8String:host]] && + port == (configured.port ? configured.port.integerValue : 80); +} + @interface OnWatchPopoverController : NSObject +@property(nonatomic, assign) uint64_t grantToken; +@property(nonatomic, assign) BOOL grantPending; +@property(nonatomic, copy) NSString *grantResult; @property(nonatomic, strong) OnWatchBorderlessPanel *panel; @property(nonatomic, strong) NSView *containerView; @property(nonatomic, strong) WKWebView *webView; @@ -447,6 +463,24 @@ - (void)userContentController:(WKUserContentController *)userContentController return; } + if ([action isEqualToString:@"grant_browser_access"] || [action isEqualToString:@"browser_access_status"]) { + WKSecurityOrigin *origin = message.frameInfo.securityOrigin; + if (!self.grantToken || !onwatch_grant_origin_allowed(self.loadedURLString.UTF8String, + origin.protocol.UTF8String, origin.host.UTF8String, (int)origin.port, message.frameInfo.isMainFrame)) { + return; + } + if ([action isEqualToString:@"grant_browser_access"] && !self.grantPending) { + self.grantPending = YES; + self.grantResult = @"pending"; + onwatchGoGrantBrowserAccess(self.grantToken); + } + NSString *result = self.grantResult ?: @"idle"; + // Status replay accompanies the page's own refresh, so it must not fetch + // another snapshot. Only a new completion requests an extra refresh. + [self.webView evaluateJavaScript:[NSString stringWithFormat:@"window.__onwatchBrowserGrantResult && window.__onwatchBrowserGrantResult('%@', false)", result] completionHandler:nil]; + return; + } + if ([action isEqualToString:@"close"]) { [self close]; return; @@ -499,10 +533,34 @@ void onwatch_popover_destroy(void *handle) { onwatch_run_on_main_sync(^{ OnWatchPopoverController *controller = (__bridge_transfer OnWatchPopoverController *)handle; + [onwatchGrantHosts removeObjectForKey:@(controller.grantToken)]; [controller close]; }); } +void onwatch_grant_register(void *handle, uint64_t token) { + onwatch_run_on_main_sync(^{ + if (!onwatchGrantHosts) onwatchGrantHosts = [NSMapTable strongToWeakObjectsMapTable]; + OnWatchPopoverController *controller = onwatch_popover_controller(handle); + [onwatchGrantHosts removeObjectForKey:@(controller.grantToken)]; + controller.grantToken = token; + [onwatchGrantHosts setObject:controller forKey:@(token)]; + WKUserScript *capability = [[WKUserScript alloc] initWithSource:@"window.__onwatchCanGrantBrowserAccess = true;" injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES]; + [controller.webView.configuration.userContentController addUserScript:capability]; + }); +} + +void onwatch_grant_finish(uint64_t token, const char *result) { + NSString *value = result ? [NSString stringWithUTF8String:result] : @"unavailable"; + dispatch_async(dispatch_get_main_queue(), ^{ + OnWatchPopoverController *controller = [onwatchGrantHosts objectForKey:@(token)]; + if (!controller) return; + controller.grantPending = NO; + controller.grantResult = value; + [controller.webView evaluateJavaScript:[NSString stringWithFormat:@"window.__onwatchBrowserGrantResult && window.__onwatchBrowserGrantResult('%@')", value] completionHandler:nil]; + }); +} + bool onwatch_popover_show(void *handle) { __block BOOL shown = NO; onwatch_run_on_main_sync(^{ diff --git a/internal/menubar/webview_darwin.go b/internal/menubar/webview_darwin.go index 4bc44665..931c5f04 100644 --- a/internal/menubar/webview_darwin.go +++ b/internal/menubar/webview_darwin.go @@ -8,6 +8,9 @@ package menubar #include #include +#include + +void onwatch_grant_register(void* handle, uint64_t token); void* onwatch_popover_create(int width, int height); void onwatch_popover_destroy(void* handle); @@ -25,8 +28,15 @@ import ( ) type webViewPopover struct { - handle unsafe.Pointer - loadedURL string + handle unsafe.Pointer + loadedURL string + grantToken uint64 +} + +func (p *webViewPopover) SetBrowserGrantHandler(fn func() string) { + unregisterGrantHandler(p.grantToken) + p.grantToken = registerGrantHandler(fn) + C.onwatch_grant_register(p.handle, C.uint64_t(p.grantToken)) } func cBool(value C.bool) bool { @@ -82,6 +92,7 @@ func (p *webViewPopover) Destroy() { return } C.onwatch_popover_destroy(p.handle) + unregisterGrantHandler(p.grantToken) p.handle = nil } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 533cd540..bf6eccf6 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -700,21 +700,21 @@ func NewHandler(store *store.Store, tracker *tracker.Tracker, logger *slog.Logge } // Parse dashboard template (layout + dashboard) - dashboardTmpl, err := template.New("").ParseFS(templatesFS, "templates/layout.html", "templates/dashboard.html") + dashboardTmpl, err := template.New("").Funcs(recoveryTemplateFuncs).ParseFS(templatesFS, "templates/layout.html", "templates/dashboard.html") if err != nil { logger.Error("failed to parse dashboard template", "error", err) dashboardTmpl = template.New("empty") } // Parse login template (layout + login) - loginTmpl, err := template.New("").ParseFS(templatesFS, "templates/layout.html", "templates/login.html") + loginTmpl, err := template.New("").Funcs(recoveryTemplateFuncs).ParseFS(templatesFS, "templates/layout.html", "templates/login.html") if err != nil { logger.Error("failed to parse login template", "error", err) loginTmpl = template.New("empty") } // Parse settings template (layout + settings) - settingsTmpl, err := template.New("").ParseFS(templatesFS, "templates/layout.html", "templates/settings.html") + settingsTmpl, err := template.New("").Funcs(recoveryTemplateFuncs).ParseFS(templatesFS, "templates/layout.html", "templates/settings.html") if err != nil { logger.Error("failed to parse settings template", "error", err) settingsTmpl = template.New("empty") diff --git a/internal/web/menubar.go b/internal/web/menubar.go index dfc940fb..7a166cea 100644 --- a/internal/web/menubar.go +++ b/internal/web/menubar.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "github.com/onllm-dev/onwatch/v2/internal/api" + "html" "net" "net/http" "os" @@ -233,6 +234,7 @@ func isLocalMenubarPublicPath(path string) bool { path == "/api/menubar/summary" || path == "/api/menubar/preferences" || path == "/api/menubar/refresh" || + path == "/api/menubar/mistral/retry" || path == "/api/menubar/tray-title" } @@ -577,12 +579,17 @@ func (h *Handler) renderMenubarHTML(view menubar.ViewType, settings *menubar.Set if err != nil { return "", err } - html := strings.Replace(string(page), "__ONWATCH_MENUBAR_BOOTSTRAP__", string(bootstrap), 1) + content := strings.Replace(string(page), "__ONWATCH_MENUBAR_BOOTSTRAP__", string(bootstrap), 1) + content = strings.ReplaceAll(content, "__ONWATCH_MISTRAL_ASSET_VERSION__", mistralRecoveryAssetVersion) + basePath := h.getBasePath() + baseJSON, _ := json.Marshal(basePath) + content = strings.ReplaceAll(content, "__ONWATCH_BASE_PATH_JSON__", string(baseJSON)) + content = strings.ReplaceAll(content, "__ONWATCH_BASE_PATH__", html.EscapeString(basePath)) version := strings.TrimSpace(h.version) if version == "" { version = "dev" } - return strings.Replace(html, "__ONWATCH_MENUBAR_VERSION__", version, 1), nil + return strings.Replace(content, "__ONWATCH_MENUBAR_VERSION__", version, 1), nil } func (h *Handler) buildMenubarProviderOptions(settings *menubar.Settings) ([]menubarProviderOption, error) { @@ -745,7 +752,13 @@ func providerQuotaKey(provider menubarProviderOption, quotaKey string) string { func normalizeProviderCard(id, label, subtitle string, payload map[string]interface{}, warningPercent, criticalPercent int) *menubar.ProviderCard { quotas := normalizeQuotas(payload, warningPercent, criticalPercent) - if len(quotas) == 0 { + var connection *api.MistralConnection + if id == "mistral" { + if c, ok := payload["connection"].(api.MistralConnection); ok { + connection = &c + } + } + if len(quotas) == 0 && (connection == nil || connection.Reason == "" && !connection.CanRetry && !connection.Retrying) { return nil } status := "healthy" @@ -753,6 +766,9 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf if connectionStatus == "reconnect" || connectionStatus == "stale" { status = "warning" } + if connection != nil && connection.Reason != "" { + status = "warning" + } highest := 0.0 trends := make([]menubar.TrendSeries, 0, len(quotas)) for _, quota := range quotas { @@ -780,6 +796,7 @@ func normalizeProviderCard(id, label, subtitle string, payload map[string]interf Label: label, Subtitle: subtitle, ConnectionStatus: connectionStatus, + Connection: connection, Status: status, HighestPercent: highest, UpdatedAt: timeAgo(parseCapturedAt(payload)), diff --git a/internal/web/middleware.go b/internal/web/middleware.go index 068d37da..bfcb1cca 100644 --- a/internal/web/middleware.go +++ b/internal/web/middleware.go @@ -255,7 +255,7 @@ func sessionAuthMiddlewareWithTrustedProxy(sessions *SessionStore, basePath stri } // Local tray surface is intentionally public for localhost requests. - if isLocalMenubarPublicPath(path) && isLoopbackRequest(r) { + if isLocalMenubarPublicPath(strings.TrimPrefix(path, basePath)) && isLoopbackRequest(r) { next.ServeHTTP(w, r) return } diff --git a/internal/web/mistral_asset_test.go b/internal/web/mistral_asset_test.go new file mode 100644 index 00000000..7f1fb1d7 --- /dev/null +++ b/internal/web/mistral_asset_test.go @@ -0,0 +1,36 @@ +package web + +import ( + "crypto/sha256" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestMistralRecoveryAssetCacheKeys(t *testing.T) { + h, db := newMenubarTestHandler(t) + defer db.Close() + h.version = "unchanged-local-version" + digest := sha256.New() + for _, name := range []string{"mistral-recovery.js", "mistral-recovery.css"} { + data, err := staticFS.ReadFile("static/" + name) + if err != nil { + t.Fatal(err) + } + digest.Write(data) + } + key := fmt.Sprintf("%x", digest.Sum(nil)) + for name, handler := range map[string]http.HandlerFunc{"menubar": h.MenubarPage, "dashboard": h.Dashboard} { + t.Run(name, func(t *testing.T) { + rr := httptest.NewRecorder() + handler(rr, httptest.NewRequest("GET", "/?provider=mistral", nil)) + for _, asset := range []string{"mistral-recovery.js", "mistral-recovery.css"} { + if !strings.Contains(rr.Body.String(), "/static/"+asset+"?v="+key) { + t.Fatalf("%s lacks content-based URL for %s", name, asset) + } + } + }) + } +} diff --git a/internal/web/mistral_assets.go b/internal/web/mistral_assets.go new file mode 100644 index 00000000..edd1fa24 --- /dev/null +++ b/internal/web/mistral_assets.go @@ -0,0 +1,25 @@ +package web + +import ( + "crypto/sha256" + "fmt" + "html/template" +) + +// Local rebuilds commonly keep the release version. Fingerprint the embedded +// recovery assets so immutable browser caches cannot retain an older controller. +var mistralRecoveryAssetVersion = func() string { + digest := sha256.New() + for _, name := range []string{"mistral-recovery.js", "mistral-recovery.css"} { + data, err := staticFS.ReadFile("static/" + name) + if err != nil { + panic(err) + } + digest.Write(data) + } + return fmt.Sprintf("%x", digest.Sum(nil)) +}() + +var recoveryTemplateFuncs = template.FuncMap{ + "mistralRecoveryVersion": func() string { return mistralRecoveryAssetVersion }, +} diff --git a/internal/web/mistral_handlers.go b/internal/web/mistral_handlers.go index 92418be8..cc211c46 100644 --- a/internal/web/mistral_handlers.go +++ b/internal/web/mistral_handlers.go @@ -16,6 +16,7 @@ var mistralDisplayNames = map[string]string{"api_included": "Included API usage" func (h *Handler) buildMistralCurrent() map[string]interface{} { showBilling := h.showMistralBilling() result := map[string]interface{}{"quotas": []interface{}{}, "status": "waiting", "showBilling": showBilling, "billing": map[string]interface{}{"amount": nil, "status": "unavailable"}} + result["connection"] = h.mistralConnection() if h.store == nil { return result } diff --git a/internal/web/mistral_preview_test.go b/internal/web/mistral_preview_test.go index d9eabde2..7a68e5c9 100644 --- a/internal/web/mistral_preview_test.go +++ b/internal/web/mistral_preview_test.go @@ -10,10 +10,46 @@ import ( "net/http" "net/http/httptest" "os" + "strings" + "sync" "testing" "time" ) +type mistralPreviewRecovery struct { + mu sync.Mutex + started time.Time + db *store.Store + snapshot *api.MistralSnapshot +} + +func (p *mistralPreviewRecovery) Start(string) error { return nil } +func (p *mistralPreviewRecovery) Stop(string) {} +func (p *mistralPreviewRecovery) IsRunning(string) bool { return true } +func (p *mistralPreviewRecovery) RequestRetry(string) error { + p.mu.Lock() + defer p.mu.Unlock() + p.started = time.Now() + p.snapshot.CapturedAt = time.Now().UTC() + for i := range p.snapshot.Quotas { + p.snapshot.Quotas[i].CapturedAt = p.snapshot.CapturedAt + } + if err := p.db.SaveMistral(context.Background(), p.snapshot); err != nil { + return err + } + return p.db.SetSetting("mistral_status", "ok") +} +func (p *mistralPreviewRecovery) ConnectionState(string) (api.MistralConnection, bool) { + p.mu.Lock() + defer p.mu.Unlock() + if p.started.IsZero() { + c := (&api.MistralConnectionError{Reason: "browser_access_denied", Browser: "chrome"}).Connection() + c.CanRetry = true + return c, true + } + return api.MistralConnection{Retrying: time.Since(p.started) < 3*time.Second, CanRetry: time.Since(p.started) >= 3*time.Second}, true +} + // Opt-in local preview uses synthetic data only and never reads browser cookies. func TestMistralPreview(t *testing.T) { if os.Getenv("ONWATCH_MISTRAL_PREVIEW") != "1" { @@ -31,10 +67,29 @@ func TestMistralPreview(t *testing.T) { t.Fatal(e) } h := NewHandler(db, nil, nil, nil, &config.Config{MistralEnabled: true, PollInterval: 120 * time.Second}) + if os.Getenv("ONWATCH_MISTRAL_PREVIEW_RECOVERY") == "1" { + h.SetAgentManager(&mistralPreviewRecovery{db: db, snapshot: snap}) + if err := db.SetSetting("mistral_status", "reconnect"); err != nil { + t.Fatal(err) + } + } mux := http.NewServeMux() + mux.HandleFunc("/api/mistral/retry", h.RetryMistral) + mux.HandleFunc("/api/menubar/mistral/retry", h.RetryMistral) assets, _ := fs.Sub(staticFS, "static") mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(assets)))) for path, fn := range map[string]http.HandlerFunc{"/": h.Dashboard, "/menubar": h.MenubarPage, "/api/current": h.Current, "/api/history": h.History, "/api/insights": h.Insights, "/api/summary": h.Summary, "/api/cycles": h.Cycles, "/api/cycle-overview": h.CycleOverview, "/api/logging-history": h.LoggingHistory, "/api/menubar/summary": h.MenubarSummary, "/api/menubar/preferences": h.MenubarPreferences, "/api/menubar/tray-title": h.MenubarTrayTitle, "/api/settings": h.GetSettings, "/api/providers": h.Providers, "/api/providers/status": h.ProvidersStatus, "/api/sessions": h.Sessions, "/api/capabilities": h.Capabilities} { + if path == "/menubar" && os.Getenv("ONWATCH_MISTRAL_PREVIEW_GRANT") == "1" { + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + recorder := httptest.NewRecorder() + h.MenubarPage(recorder, r) + // Fake the native host in this opt-in, synthetic-only visual fixture. + const script = `` + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, strings.Replace(recorder.Body.String(), "", ""+script, 1)) + }) + continue + } mux.HandleFunc(path, fn) } server := httptest.NewServer(mux) diff --git a/internal/web/mistral_retry.go b/internal/web/mistral_retry.go new file mode 100644 index 00000000..dff0c66c --- /dev/null +++ b/internal/web/mistral_retry.go @@ -0,0 +1,59 @@ +package web + +import ( + "encoding/json" + "errors" + "math" + "net/http" + "strconv" + + "github.com/onllm-dev/onwatch/v2/internal/agent" + "github.com/onllm-dev/onwatch/v2/internal/api" +) + +type mistralRetryControllerAPI interface { + RequestRetry(string) error + ConnectionState(string) (api.MistralConnection, bool) +} + +func (h *Handler) mistralConnection() api.MistralConnection { + if controller, ok := h.agentManager.(mistralRetryControllerAPI); ok { + if c, running := controller.ConnectionState("mistral"); running { + return c + } + } + var c api.MistralConnection + if h.store != nil { + if raw, err := h.store.GetSetting("mistral_connection"); err == nil { + _ = json.Unmarshal([]byte(raw), &c) + } + } + // A persisted pending flag must never outlive the running agent. + c.Retrying = false + c.CanRetry = false + c.NextRetryAt = nil + return c +} + +func (h *Handler) RetryMistral(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, 405, "method not allowed") + return + } + controller, ok := h.agentManager.(mistralRetryControllerAPI) + if !ok || h.config == nil || !h.config.HasProvider("mistral") || !h.agentManager.IsRunning("mistral") { + respondError(w, 409, "Mistral polling is not running. Enable it before retrying.") + return + } + if err := controller.RequestRetry("mistral"); err != nil { + var cooldown *agent.RetryCooldownError + if errors.As(err, &cooldown) { + w.Header().Set("Retry-After", strconv.Itoa(max(1, int(math.Ceil(cooldown.RetryAfter.Seconds()))))) + respondError(w, 429, "Please wait before retrying Mistral.") + return + } + respondError(w, 409, "Mistral retry is unavailable.") + return + } + respondJSON(w, http.StatusAccepted, map[string]string{"status": "queued"}) +} diff --git a/internal/web/mistral_retry_test.go b/internal/web/mistral_retry_test.go new file mode 100644 index 00000000..2f1561e2 --- /dev/null +++ b/internal/web/mistral_retry_test.go @@ -0,0 +1,114 @@ +package web + +import ( + "log/slog" + "net/http/httptest" + "testing" + "time" + + "github.com/onllm-dev/onwatch/v2/internal/agent" + "github.com/onllm-dev/onwatch/v2/internal/api" + "github.com/onllm-dev/onwatch/v2/internal/config" + "github.com/onllm-dev/onwatch/v2/internal/store" +) + +type mistralRetryController struct { + calls int + err error + running bool +} + +func (m *mistralRetryController) Start(string) error { return nil } +func (m *mistralRetryController) Stop(string) {} +func (m *mistralRetryController) IsRunning(string) bool { return m.running } +func (m *mistralRetryController) RequestRetry(key string) error { + if key != "mistral" { + panic(key) + } + m.calls++ + return m.err +} +func (m *mistralRetryController) ConnectionState(string) (api.MistralConnection, bool) { + return api.MistralConnection{Reason: "browser_access_denied", Message: "Allow browser access.", CanRetry: true}, m.running +} + +func TestMistralRetryHTTP(t *testing.T) { + for _, bp := range []string{"", "/watch"} { + t.Run(bp, func(t *testing.T) { + db, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + h := NewHandler(db, nil, nil, nil, &config.Config{MistralEnabled: true, BasePath: bp}) + controller := &mistralRetryController{running: true} + h.SetAgentManager(controller) + hash, err := HashPassword("test-password") + if err != nil { + t.Fatal(err) + } + s := NewServer(9211, h, slog.Default(), "admin", hash, "", bp, "", nil) + for _, tc := range []struct { + name, path, method, addr string + header, auth bool + want int + }{ + {"local tray", "/api/menubar/mistral/retry", "POST", "127.0.0.1:9", true, false, 202}, + {"remote tray", "/api/menubar/mistral/retry", "POST", "192.0.2.1:9", true, false, 401}, + {"dashboard unauth", "/api/mistral/retry", "POST", "127.0.0.1:9", true, false, 401}, + {"dashboard auth", "/api/mistral/retry", "POST", "192.0.2.1:9", true, true, 202}, + {"csrf", "/api/menubar/mistral/retry", "POST", "127.0.0.1:9", false, false, 403}, + {"method", "/api/menubar/mistral/retry", "GET", "127.0.0.1:9", true, false, 405}, + } { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(tc.method, bp+tc.path, nil) + r.RemoteAddr = tc.addr + if tc.header { + r.Header.Set("X-Requested-With", "XMLHttpRequest") + } + if tc.auth { + r.SetBasicAuth("admin", "test-password") + } + w := httptest.NewRecorder() + s.httpServer.Handler.ServeHTTP(w, r) + if w.Code != tc.want { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + }) + } + for _, tc := range []struct { + err error + want int + }{{&agent.RetryCooldownError{RetryAfter: 29 * time.Second}, 429}, {agent.ErrRetryUnavailable, 409}} { + controller.err = tc.err + w := httptest.NewRecorder() + h.RetryMistral(w, httptest.NewRequest("POST", "/api/mistral/retry", nil)) + if w.Code != tc.want { + t.Fatalf("%d %s", w.Code, w.Body.String()) + } + if tc.want == 429 && w.Header().Get("Retry-After") != "29" { + t.Fatal("missing cooldown header") + } + } + }) + } +} + +func TestMistralConnectionWithoutHistory(t *testing.T) { + db, err := store.New(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + h := NewHandler(db, nil, nil, nil, &config.Config{MistralEnabled: true}) + h.SetAgentManager(&mistralRetryController{running: true}) + current := h.buildMistralCurrent() + c := current["connection"].(api.MistralConnection) + if c.Reason != "browser_access_denied" || !c.CanRetry { + t.Fatalf("%+v", c) + } + card := normalizeProviderCard("mistral", "Mistral", "", current, 80, 95) + if card == nil || card.Connection == nil || len(card.Quotas) != 0 { + t.Fatalf("missing no-history recovery card: %+v", card) + } +} diff --git a/internal/web/mistral_ui_test.go b/internal/web/mistral_ui_test.go new file mode 100644 index 00000000..7484d050 --- /dev/null +++ b/internal/web/mistral_ui_test.go @@ -0,0 +1,90 @@ +package web + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" +) + +func TestMistralRecoveryUI(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node required for browser controller tests") + } + code, err := staticFS.ReadFile("static/mistral-recovery.js") + if err != nil { + t.Fatal(err) + } + page, err := staticFS.ReadFile("static/menubar.html") + if err != nil { + t.Fatal(err) + } + start := strings.Index(string(page), "window.__onwatchBrowserGrantResult =") + end := strings.Index(string(page)[start:], "if (footerVersion)") + start + bridge := string(page)[start:end] + test := ` +const assert = require('node:assert/strict'); +const timers = new Map(); let timerID = 0; +global.setTimeout = (fn, ms) => { timers.set(++timerID, {fn,ms}); return timerID; }; +global.clearTimeout = id => timers.delete(id); +global.window = new EventTarget(); global.document = new EventTarget(); document.hidden = false; +` + string(code) + ` +(async () => { + const root = new EventTarget(); let refreshes = 0; let renders = 0; + const recovery = new window.MistralRecovery({root,endpoint:'/watch/api/mistral/retry',refresh:async()=>{refreshes++;return {canRetry:true,reason:'browser_access_denied'}},render:()=>{renders++;}}); + const html = recovery.markup({canRetry:true,reason:'import_failed',message:''},'reconnect'); + assert(!html.includes(' @@ -22,6 +23,7 @@ {{template "content" .}} +