diff --git a/.gitignore b/.gitignore index db81811..a126271 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ web/src/sample/ .mcp.json opencode.json AGENTS.md +.worktrees/ +.superpowers/ # Release artifacts dist/ diff --git a/cmd/antares/main.go b/cmd/antares/main.go index 8c61152..2a9bc0b 100644 --- a/cmd/antares/main.go +++ b/cmd/antares/main.go @@ -763,10 +763,19 @@ func cmdModel(args []string) error { return nil } prevProvider := cfg.Model.Provider - cfg.Model.Default = args[0] + resultProvider := prevProvider if len(args) > 1 { - cfg.Model.Provider = args[1] + resultProvider = args[1] + } + // An agent integration can never become the active chat model — refused + // here before any mutation, exactly as /model, the API, and the TUI do. + if providers.CapabilityOf(cfg, resultProvider) == providers.CapabilityAgent { + return fmt.Errorf( + "%s is an agent integration, not a chat model provider; use the cursor_agent tool", + resultProvider) } + cfg.Model.Default = args[0] + cfg.Model.Provider = resultProvider if cfg.Model.Provider != prevProvider { cfg.ClearInlineModelCredentials() } else if p, ok := cfg.Providers[cfg.Model.Provider]; ok && @@ -821,6 +830,9 @@ func cmdProvider(args []string) error { return fmt.Errorf("usage: antares provider use ") } id := args[1] + if providers.CapabilityOf(cfg, id) == providers.CapabilityAgent { + return fmt.Errorf("%s is an agent integration, not a chat model provider; use the cursor_agent tool", id) + } if !providers.Connected(cfg, id) { return fmt.Errorf("%s is not connected — run `antares provider add %s `", id, id) } @@ -844,6 +856,15 @@ func cmdProvider(args []string) error { if known && info.NeedsKey && key == "" && !providers.Connected(cfg, id) { return fmt.Errorf("%s needs an API key: antares provider add %s ", info.Label, id) } + if providers.CapabilityOf(cfg, id) == providers.CapabilityAgent { + providers.Connect(cfg, id, key) + if err := config.Save(cfg); err != nil { + return err + } + fmt.Printf("connected %s agent integration; active model remains %s (%s)\n", + id, cfg.Model.Default, cfg.Model.Provider) + return nil + } providers.Activate(cfg, id, key) if err := config.Save(cfg); err != nil { return err diff --git a/cmd/antares/model_test.go b/cmd/antares/model_test.go new file mode 100644 index 0000000..d5dc29a --- /dev/null +++ b/cmd/antares/model_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "strings" + "testing" + + "github.com/enowdev/antares/internal/config" +) + +// `antares model cursor` used to persist an agent integration as the +// active chat provider, which every other selector already refuses. +func TestModelCommandRejectsAgentProvider(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + beforeProvider, beforeModel := cfg.Model.Provider, cfg.Model.Default + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + + err := cmdModel([]string{"claude-sonnet-5", "cursor"}) + if err == nil || !strings.Contains(err.Error(), "cursor_agent") { + t.Fatalf("model set to cursor error = %v, want an agent-integration refusal", err) + } + + after, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if after.Model.Provider != beforeProvider || after.Model.Default != beforeModel { + t.Fatalf("active model changed to %s (%s), want %s (%s)", + after.Model.Default, after.Model.Provider, beforeModel, beforeProvider) + } +} + +func TestModelCommandStillSwitchesLLMProvider(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + if err := config.Save(config.Default()); err != nil { + t.Fatal(err) + } + + output := captureProviderStdout(t, func() { + if err := cmdModel([]string{"gpt-5", "openai"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(output, "active model: gpt-5 (openai)") { + t.Fatalf("model set output = %q", output) + } + + after, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if after.Model.Provider != "openai" || after.Model.Default != "gpt-5" { + t.Fatalf("active model = %s (%s), want gpt-5 (openai)", after.Model.Default, after.Model.Provider) + } +} diff --git a/cmd/antares/provider_test.go b/cmd/antares/provider_test.go new file mode 100644 index 0000000..c731ded --- /dev/null +++ b/cmd/antares/provider_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/enowdev/antares/internal/config" +) + +func TestProviderAddAndUseCursorPreserveActiveModel(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + beforeProvider, beforeModel := cfg.Model.Provider, cfg.Model.Default + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + + output := captureProviderStdout(t, func() { + if err := cmdProvider([]string{"add", "cursor", "synthetic-key"}); err != nil { + t.Fatal(err) + } + }) + if !strings.Contains(output, "connected cursor agent integration") { + t.Fatalf("add output = %q", output) + } + connected, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if connected.Model.Provider != beforeProvider || connected.Model.Default != beforeModel { + t.Fatalf("model changed to %s/%s", connected.Model.Provider, connected.Model.Default) + } + if p := connected.Providers["cursor"]; !p.Enabled || p.APIKey != "synthetic-key" || p.Kind != "cursor-agent" { + t.Fatalf("cursor provider = %+v", p) + } + + err = cmdProvider([]string{"use", "cursor"}) + if err == nil || !strings.Contains(err.Error(), "cursor_agent") { + t.Fatalf("use cursor error = %v", err) + } + afterUse, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if afterUse.Model.Provider != beforeProvider || afterUse.Model.Default != beforeModel { + t.Fatalf("model changed to %s/%s", afterUse.Model.Provider, afterUse.Model.Default) + } +} + +func captureProviderStdout(t *testing.T, f func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = old }) + f() + if err := w.Close(); err != nil { + t.Fatal(err) + } + out, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return string(out) +} diff --git a/docs/configuration.md b/docs/configuration.md index 93942eb..311a06b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -88,6 +88,29 @@ release. A model family Antares does not recognise defaults to the OpenAI format, which is correct for the GLM, Kimi, DeepSeek, MiMo, GPT, Grok and Hunyuan families it currently serves. +### Cursor Cloud Agents + +Cursor is an agent integration, not a primary Antares model provider. Configure +the deployment-owned key through the environment: + +```yaml +providers: + cursor: + kind: cursor-agent + base_url: https://api.cursor.com + api_key_env: CURSOR_API_KEY + enabled: true + timeout_seconds: 900 +``` + +Cursor is a built-in cloud-only agent integration. Existing configuration and +providers need no migration and retain their current LLM behavior. The default +Cursor entry is enabled but disconnected; it becomes usable only when Antares +resolves its key. `CURSOR_API_KEY` works with that entry without writing a key +to YAML. One deployment key and its quota are shared by every user who can +invoke the Cursor tools. Repository-backed runs use the repository state +available to Cursor, so unpushed local changes are not included. + `model.auxiliary` is worth setting. Titles, compaction summaries, verification, and goal judging all use it, and a small model does those as well as a large one for a fraction of the cost. @@ -100,6 +123,7 @@ OPENAI_API_KEY=… OPENROUTER_API_KEY=… OPENCODE_API_KEY=… GEMINI_API_KEY=… +CURSOR_API_KEY=… ``` ## Storage diff --git a/docs/superpowers/plans/2026-08-12-cursor-agent-provider.md b/docs/superpowers/plans/2026-08-12-cursor-agent-provider.md new file mode 100644 index 0000000..e5b2df4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-cursor-agent-provider.md @@ -0,0 +1,1764 @@ +# Cursor Agent Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Cursor Cloud Agents as a first-class agent-capability provider that can be configured from Antares and invoked through safe, observable delegation tools. + +**Architecture:** A new `internal/cursor` package implements Cursor's official REST and SSE protocols without pretending they are chat completions. Provider metadata distinguishes LLM providers from agent integrations, server handlers validate and enumerate Cursor without changing the active Antares model, and two tools separate mutating run operations from read-only status/stream operations. + +**Tech Stack:** Go 1.26 standard library (`net/http`, `httptest`, `encoding/json`, `bufio`), Cursor Cloud Agents v1 REST/SSE API, React 19 + TypeScript, Bun tests. + +## Global Constraints + +- Cursor remains an agent integration and must never implement or enter `llm.Client`. +- Use only documented endpoints under `https://api.cursor.com/v1`. +- A deployment owns one shared credential resolved from `CURSOR_API_KEY` or the existing provider-key store. +- Never emit an API key in logs, errors, test output, tool results, browser responses, fixtures, docs, or command arguments. +- Connecting Cursor must not modify `model.provider` or `model.default`. +- Cursor models must not enter Antares' primary model picker. +- Initial support is cloud-only; do not add SDK Bridge, local-agent execution, per-user keys, arbitrary environment variables, MCP definitions, or worker targets. +- `cursor_agent` is always approval-gated; `cursor_agent_status` is read-only and not approval-gated. +- Do not automatically retry create-agent or create-run requests. Retry only idempotent reads and SSE reconnections. +- All non-live tests must run without network access. + +--- + +## File Structure + +### New files + +- `internal/cursor/types.go` — public request/response and stream-event types. +- `internal/cursor/client.go` — authenticated REST transport, metadata, agent, run, cancellation, and typed API errors. +- `internal/cursor/stream.go` — SSE parser and resumable run streaming. +- `internal/cursor/client_test.go` — metadata, lifecycle, auth redaction, and error tests. +- `internal/cursor/stream_test.go` — SSE parsing, reconnect, expiry, and cancellation tests. +- `internal/cursor/live_test.go` — opt-in `/v1/me` and `/v1/models` smoke test. +- `internal/providers/catalog_test.go` — capability and non-activation regression tests. +- `internal/server/cursor_provider_test.go` — provider connection/model-list/onboarding regression tests. +- `internal/tools/cursor_agent.go` — mutating and read-only Cursor tools. +- `internal/tools/cursor_agent_test.go` — schemas, approval, validation, API, progress, and secret-redaction tests. +- `internal/agent/cursor_timeout_test.go` — tool-envelope timeout regression test. +- `web/src/lib/providerCapabilities.ts` — small UI capability helpers. +- `web/src/lib/providerCapabilities.test.mjs` — Bun tests for provider classification and model endpoint selection. + +### Modified files + +- `internal/config/defaults.go` — built-in enabled-but-disconnected Cursor entry. +- `internal/config/config.go` — provider comment/kind documentation includes agent integrations. +- `internal/providers/catalog.go` — provider capability type, Cursor catalogue entry, and connect-without-LLM-activation behavior. +- `cmd/antares/main.go` — CLI messaging and refusal to make an agent integration the active LLM. +- `internal/tui/pickers.go` — connect Cursor without switching the active model. +- `internal/server/server.go` — injectable Cursor metadata-client factory for handler tests. +- `internal/server/handlers_setup.go` — Cursor catalogue entry, credential verification, and onboarding filtering. +- `internal/server/handlers_config.go` — provider capability and resolved-key status in model options; exclude agent providers from model aggregation. +- `internal/server/handlers_providers.go` — Cursor model catalogue endpoint. +- `internal/server/routes.go` — route for provider-specific agent models. +- `internal/tools/register.go` — register both tools. +- `internal/tools/registry.go` — include both tools in `coding`, `vibecoder`, and `default`. +- `web/src/pages/ProvidersPage.tsx` — agent-integration badge and read-only Cursor model catalogue. +- `web/src/lib/i18n.tsx` — English and Indonesian agent-integration copy. +- `docs/configuration.md` — Cursor configuration and environment variable. +- `docs/tools.md` — tool actions, approval, and long-run behavior. +- `docs/verification.md` — safe live metadata test. + +--- + +### Task 1: Cursor Metadata Client and Secret-Safe Errors + +**Files:** +- Create: `internal/cursor/types.go` +- Create: `internal/cursor/client.go` +- Create: `internal/cursor/client_test.go` + +**Interfaces:** +- Produces: `cursor.New(Options) (*Client, error)` +- Produces: `(*Client).Me(context.Context) (*Me, error)` +- Produces: `(*Client).Models(context.Context) (*ModelCatalog, error)` +- Produces: `APIError`, `IsAuthError(error)`, `IsRateLimit(error)`, and `IsStatus(error, int)` +- Consumes: only Go standard library + +- [ ] **Step 1: Write failing metadata/auth tests** + +Add tests that assert the exact bearer header, decode metadata, and prove a +malicious upstream body cannot echo the key: + +```go +func TestMeAndModelsUseBearerAndDecodeCatalog(t *testing.T) { + const key = "synthetic-cursor-key" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer "+key { + t.Fatalf("Authorization = %q", got) + } + switch r.URL.Path { + case "/v1/me": + _ = json.NewEncoder(w).Encode(map[string]any{ + "apiKeyName": "test key", "createdAt": "2026-08-12T00:00:00Z", + }) + case "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{ + map[string]any{ + "id": "composer-2", "displayName": "Composer 2", + "parameters": []any{map[string]any{ + "id": "fast", "values": []any{map[string]any{"value": "true"}}, + }}, + }, + }}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client, err := New(Options{BaseURL: srv.URL, APIKey: key, HTTPClient: srv.Client()}) + if err != nil { + t.Fatal(err) + } + me, err := client.Me(context.Background()) + if err != nil || me.APIKeyName != "test key" { + t.Fatalf("Me = %+v, %v", me, err) + } + models, err := client.Models(context.Background()) + if err != nil || len(models.Items) != 1 || models.Items[0].ID != "composer-2" { + t.Fatalf("Models = %+v, %v", models, err) + } +} + +func TestAPIErrorNeverLeaksAPIKey(t *testing.T) { + const key = "synthetic-secret" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":{"message":"rejected synthetic-secret"}}`) + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: key, HTTPClient: srv.Client()}) + _, err := client.Me(context.Background()) + if err == nil || !IsAuthError(err) { + t.Fatalf("expected auth error, got %v", err) + } + if strings.Contains(err.Error(), key) { + t.Fatalf("error leaked key: %v", err) + } +} + +func TestAPIErrorClassificationAndRetryAfter(t *testing.T) { + for _, status := range []int{400, 404, 409, 429, 500} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "7") + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"code":"synthetic","message":"request failed"}`) + })) + defer srv.Close() + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + _, err := client.Me(context.Background()) + if !IsStatus(err, status) { + t.Fatalf("status %d classified as %v", status, err) + } + if status == 429 { + if !IsRateLimit(err) { + t.Fatalf("429 not classified as rate limit: %v", err) + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.RetryAfter != 7*time.Second { + t.Fatalf("RetryAfter = %v, want 7s", apiErr) + } + } + }) + } +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +go test ./internal/cursor -run 'Test(MeAndModels|APIError)' -count=1 -v +``` + +Expected: compilation fails because package/types/functions do not exist. + +- [ ] **Step 3: Define exact metadata and model types** + +Create `types.go` with JSON names matching Cursor: + +```go +package cursor + +import "encoding/json" + +type Me struct { + APIKeyName string `json:"apiKeyName"` + CreatedAt string `json:"createdAt"` + UserID int64 `json:"userId,omitempty"` + UserEmail string `json:"userEmail,omitempty"` + UserFirstName string `json:"userFirstName,omitempty"` + UserLastName string `json:"userLastName,omitempty"` +} + +type ModelParameterValue struct { + Value string `json:"value"` + DisplayName string `json:"displayName,omitempty"` +} + +type ModelParameter struct { + ID string `json:"id"` + DisplayName string `json:"displayName,omitempty"` + Values []ModelParameterValue `json:"values"` +} + +type ModelParameterSelection struct { + ID string `json:"id"` + Value string `json:"value"` +} + +type ModelVariant struct { + Params []ModelParameterSelection `json:"params"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + IsDefault bool `json:"isDefault,omitempty"` +} + +type Model struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Parameters []ModelParameter `json:"parameters,omitempty"` + Variants []ModelVariant `json:"variants,omitempty"` +} + +type ModelCatalog struct { + Items []Model `json:"items"` +} + +type StreamEvent struct { + ID string + Type string + Status string + Text string + ToolName string + Raw json.RawMessage +} +``` + +- [ ] **Step 4: Implement the transport and typed/redacted errors** + +Create `client.go` around this interface: + +```go +type Options struct { + BaseURL string + APIKey string + HTTPClient *http.Client +} + +type Client struct { + baseURL string + apiKey string + http *http.Client +} + +type APIError struct { + Status int + Code string + Message string + RetryAfter time.Duration +} + +func New(o Options) (*Client, error) { + base := strings.TrimRight(strings.TrimSpace(o.BaseURL), "/") + if base == "" { + base = "https://api.cursor.com" + } + u, err := url.Parse(base) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { + return nil, fmt.Errorf("invalid Cursor base URL") + } + hc := o.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 30 * time.Second} + } + return &Client{baseURL: base, apiKey: strings.TrimSpace(o.APIKey), http: hc}, nil +} + +func (c *Client) doJSON(ctx context.Context, method, path string, in, out any) error { + var body io.Reader + if in != nil { + raw, err := json.Marshal(in) + if err != nil { + return err + } + body = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return c.decodeAPIError(resp) + } + if out == nil { + _, err = io.Copy(io.Discard, resp.Body) + return err + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) Me(ctx context.Context) (*Me, error) { + var out Me + return &out, c.doJSON(ctx, http.MethodGet, "/v1/me", nil, &out) +} + +func (c *Client) Models(ctx context.Context) (*ModelCatalog, error) { + var out ModelCatalog + return &out, c.doJSON(ctx, http.MethodGet, "/v1/models", nil, &out) +} +``` + +`decodeAPIError` must read at most 64 KiB, decode top-level +`code`/`message` and nested `error.code`/`error.message`, replace every +occurrence of `c.apiKey` with `[REDACTED]`, and cap the final message at 240 +characters. Parse `Retry-After` as either integer seconds or an HTTP date. +`IsAuthError` accepts 401/403; `IsRateLimit` accepts 429; `IsStatus` uses +`errors.As` against `*APIError`. + +- [ ] **Step 5: Run metadata tests and package tests** + +Run: + +```bash +gofmt -w internal/cursor/types.go internal/cursor/client.go internal/cursor/client_test.go +go test ./internal/cursor -count=1 +``` + +Expected: PASS. + +- [ ] **Step 6: Commit Task 1** + +```bash +git add internal/cursor/types.go internal/cursor/client.go internal/cursor/client_test.go +git commit -m "Add secret-safe Cursor metadata client" +``` + +--- + +### Task 2: Cursor Agent Lifecycle and Resumable SSE + +**Files:** +- Modify: `internal/cursor/types.go` +- Modify: `internal/cursor/client.go` +- Create: `internal/cursor/stream.go` +- Modify: `internal/cursor/client_test.go` +- Create: `internal/cursor/stream_test.go` + +**Interfaces:** +- Consumes: `cursor.Client` and `APIError` from Task 1 +- Produces: `CreateAgent`, `CreateRun`, `GetAgent`, `GetRun`, `CancelRun` +- Produces: `StreamRun(ctx, agentID, runID string, emit func(StreamEvent) error) (*Run, error)` + +- [ ] **Step 1: Write failing lifecycle request/response tests** + +Use an `httptest.Server` that records method/path/body and returns fixed agent +and run IDs: + +```go +func TestCreateAgentRepoAndFollowUpPayloads(t *testing.T) { + var seen []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + seen = append(seen, body) + switch r.URL.Path { + case "/v1/agents": + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "bc-agent", "status": "ACTIVE", + "url": "https://cursor.com/agents/bc-agent", + "latestRunId": "run-one", + }, + "run": map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "CREATING", + }, + }) + case "/v1/agents/bc-agent/runs": + _ = json.NewEncoder(w).Encode(map[string]any{ + "run": map[string]any{ + "id": "run-two", "agentId": "bc-agent", "status": "CREATING", + }, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + created, err := client.CreateAgent(context.Background(), CreateAgentRequest{ + Prompt: Prompt{Text: "fix it"}, + Model: &ModelSelection{ID: "composer-2"}, + Repos: []Repository{{URL: "https://github.com/acme/repo", StartingRef: "main"}}, + AutoCreatePR: true, + }) + if err != nil || created.Agent.ID != "bc-agent" || created.Run.ID != "run-one" { + t.Fatalf("CreateAgent = %+v, %v", created, err) + } + run, err := client.CreateRun(context.Background(), "bc-agent", CreateRunRequest{ + Prompt: Prompt{Text: "add tests"}, Mode: "agent", + }) + if err != nil || run.ID != "run-two" { + t.Fatalf("CreateRun = %+v, %v", run, err) + } + if seen[0]["autoCreatePR"] != true { + t.Fatalf("create payload = %#v", seen[0]) + } +} +``` + +Add tests for no-repo omission, `GetAgent`, `GetRun`, and +`POST /v1/agents/{agentID}/runs/{runID}/cancel`. + +- [ ] **Step 2: Write failing SSE and reconnect tests** + +The test server must close the first stream after one event, verify the second +request carries `Last-Event-ID`, then send a terminal result: + +```go +func TestStreamRunReconnectsFromLastEventID(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + switch calls.Add(1) { + case 1: + _, _ = io.WriteString(w, "id: evt-1\nevent: assistant\ndata: {\"text\":\"hello\"}\n\n") + default: + if got := r.Header.Get("Last-Event-ID"); got != "evt-1" { + t.Fatalf("Last-Event-ID = %q", got) + } + _, _ = io.WriteString(w, + "id: evt-2\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-3\nevent: done\ndata: {}\n\n") + } + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + var events []StreamEvent + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { + events = append(events, e) + return nil + }) + if err != nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } + if len(events) != 2 { + t.Fatalf("events = %#v", events) + } +} +``` + +Also test multiline `data:`, heartbeat ignoring, tool-call envelope parsing, +context cancellation, a line larger than the parser limit returning an explicit +error, `410 stream_expired` fallback to `GetRun`, and one-time reset after +`400 invalid_last_event_id`. + +- [ ] **Step 3: Run lifecycle/stream tests and verify RED** + +Run: + +```bash +go test ./internal/cursor -run 'Test(CreateAgent|GetAgent|GetRun|CancelRun|StreamRun)' -count=1 -v +``` + +Expected: compilation failures for lifecycle and stream APIs. + +- [ ] **Step 4: Add exact lifecycle types and methods** + +Extend `types.go`: + +```go +type Prompt struct { + Text string `json:"text"` +} + +type ModelSelection struct { + ID string `json:"id"` + Params []ModelParameterSelection `json:"params,omitempty"` +} + +type Repository struct { + URL string `json:"url"` + StartingRef string `json:"startingRef,omitempty"` + PRURL string `json:"prUrl,omitempty"` +} + +type CreateAgentRequest struct { + Prompt Prompt `json:"prompt"` + Model *ModelSelection `json:"model,omitempty"` + Name string `json:"name,omitempty"` + Repos []Repository `json:"repos,omitempty"` + WorkOnCurrentBranch bool `json:"workOnCurrentBranch,omitempty"` + AutoCreatePR bool `json:"autoCreatePR,omitempty"` + SkipReviewerRequest bool `json:"skipReviewerRequest,omitempty"` + Mode string `json:"mode,omitempty"` +} + +type CreateRunRequest struct { + Prompt Prompt `json:"prompt"` + Mode string `json:"mode,omitempty"` +} + +type GitBranch struct { + RepoURL string `json:"repoUrl"` + Branch string `json:"branch,omitempty"` + PRURL string `json:"prUrl,omitempty"` +} + +type GitState struct { + Branches []GitBranch `json:"branches"` +} + +type Agent struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + URL string `json:"url"` + LatestRunID string `json:"latestRunId"` + Git *GitState `json:"git,omitempty"` + Repos []Repository `json:"repos,omitempty"` +} + +type Run struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + DurationMS int64 `json:"durationMs,omitempty"` + Result string `json:"result,omitempty"` + Git *GitState `json:"git,omitempty"` +} + +type CreateAgentResponse struct { + Agent Agent `json:"agent"` + Run Run `json:"run"` +} + +type CreateRunResponse struct { + Run Run `json:"run"` +} +``` + +Implement methods with `url.PathEscape` for IDs: + +```go +func (c *Client) CreateAgent(ctx context.Context, in CreateAgentRequest) (*CreateAgentResponse, error) +func (c *Client) CreateRun(ctx context.Context, agentID string, in CreateRunRequest) (*Run, error) +func (c *Client) GetAgent(ctx context.Context, agentID string) (*Agent, error) +func (c *Client) GetRun(ctx context.Context, agentID, runID string) (*Run, error) +func (c *Client) CancelRun(ctx context.Context, agentID, runID string) error +``` + +`CreateRun` decodes `CreateRunResponse` and returns its `Run`; the other +response shapes are direct. Each method calls `doJSON` exactly once. Validate +non-empty prompt/IDs before network I/O. Do not add retries. + +- [ ] **Step 5: Implement SSE parsing and bounded reconnection** + +Create `stream.go` with: + +```go +var errStreamDone = errors.New("Cursor stream done") + +func parseSSE(r io.Reader, emit func(StreamEvent) error) (lastID string, terminal *Run, err error) + +func (c *Client) streamOnce( + ctx context.Context, + agentID, runID, lastID string, + emit func(StreamEvent) error, +) (nextID string, terminal *Run, done bool, err error) + +func (c *Client) StreamRun( + ctx context.Context, + agentID, runID string, + emit func(StreamEvent) error, +) (*Run, error) +``` + +`parseSSE` uses a `bufio.Scanner` with a 1 MiB maximum token, checks +`scanner.Err()`, and collects `id`, `event`, and all `data` lines until a blank +line. It must never silently return partial success after an overlong event. +Decode simplified events as follows: + +```go +switch eventName { +case "assistant", "thinking": + var payload struct{ Text string `json:"text"` } + err = json.Unmarshal(raw, &payload) + out.Text = payload.Text +case "status": + var payload struct { + RunID string `json:"runId"` + Status string `json:"status"` + } + err = json.Unmarshal(raw, &payload) + out.Status = payload.Status +case "tool_call": + var payload struct { + Name string `json:"name"` + Status string `json:"status"` + } + err = json.Unmarshal(raw, &payload) + out.ToolName, out.Status = payload.Name, payload.Status +case "result": + var payload struct { + RunID string `json:"runId"` + Status string `json:"status"` + Text string `json:"text"` + DurationMS int64 `json:"durationMs"` + Git *GitState `json:"git,omitempty"` + } + err = json.Unmarshal(raw, &payload) + terminal = &Run{ + ID: payload.RunID, Status: payload.Status, Result: payload.Text, + DurationMS: payload.DurationMS, Git: payload.Git, + } +case "error": + var payload struct { + Code string `json:"code"` + Message string `json:"message"` + } + err = json.Unmarshal(raw, &payload) + if err == nil { + err = &APIError{Code: payload.Code, Message: payload.Message} + } +case "done", "heartbeat", "interaction_update": +} +``` + +`streamOnce` clones `c.http`, sets the clone's `Timeout` to zero, sends +`Accept: text/event-stream`, and leaves lifetime control to `ctx`; Cursor +heartbeats prevent intermediaries from treating a healthy run as idle. The +metadata/lifecycle client's ordinary 30-second timeout remains unchanged. + +`StreamRun` makes at most four connection attempts, sleeps 250 ms, 500 ms, and +1 second between retryable disconnects, preserves the last event ID, resets an +invalid event ID only once, and calls `GetRun` when the stream returns 410 or +ends with `done` but no terminal `result`. It must return immediately on caller +cancellation or an `emit` error. + +- [ ] **Step 6: Run all Cursor package tests** + +Run: + +```bash +gofmt -w internal/cursor +go test ./internal/cursor -count=1 -race +``` + +Expected: PASS with no network access. + +- [ ] **Step 7: Commit Task 2** + +```bash +git add internal/cursor +git commit -m "Add Cursor cloud agent lifecycle and streaming" +``` + +--- + +### Task 3: Provider Capability, Defaults, CLI, and TUI + +**Files:** +- Modify: `internal/config/config.go` +- Modify: `internal/config/defaults.go` +- Modify: `internal/providers/catalog.go` +- Create: `internal/providers/catalog_test.go` +- Modify: `cmd/antares/main.go` +- Modify: `internal/tui/pickers.go` + +**Interfaces:** +- Produces: `providers.CapabilityLLM`, `providers.CapabilityAgent` +- Produces: `providers.CapabilityForKind(string) Capability` +- Produces: `providers.CapabilityOf(*config.Config, string) Capability` +- Produces: `Info.Capability() Capability` +- Produces: `providers.Connect(*config.Config, id, key string) (Info, bool)` +- Changes: `providers.Activate` returns whether the provider can be an active LLM + +- [ ] **Step 1: Write provider capability regression tests** + +```go +func TestCursorConnectDoesNotChangeActiveModel(t *testing.T) { + cfg := config.Default() + beforeProvider, beforeModel := cfg.Model.Provider, cfg.Model.Default + + info, known := Connect(cfg, "cursor", "synthetic-key") + if !known || info.Capability() != CapabilityAgent { + t.Fatalf("cursor info = %+v, known=%v", info, known) + } + if activated := Activate(cfg, "cursor", ""); activated { + t.Fatal("agent provider was activated as an LLM") + } + if cfg.Model.Provider != beforeProvider || cfg.Model.Default != beforeModel { + t.Fatalf("model changed to %s/%s", cfg.Model.Provider, cfg.Model.Default) + } + if p := cfg.Providers["cursor"]; !p.Enabled || p.APIKey != "synthetic-key" { + t.Fatalf("cursor provider not connected: %+v", p) + } +} + +func TestDefaultCursorProviderUsesEnvironmentKey(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + t.Setenv("CURSOR_API_KEY", "synthetic-env-key") + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + _, p := cfg.ResolveProvider("cursor") + if !p.Enabled || p.APIKey != "synthetic-env-key" || p.Kind != "cursor-agent" { + t.Fatalf("cursor provider = %+v", p) + } +} +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +go test ./internal/providers ./internal/config -run Cursor -count=1 -v +``` + +Expected: failures because Cursor/capability/connect behavior is absent. + +- [ ] **Step 3: Add the default Cursor provider** + +Add this keyed entry to `config.Default().Providers`: + +```go +"cursor": { + Kind: "cursor-agent", Label: "Cursor Cloud Agents", Enabled: true, + BaseURL: "https://api.cursor.com", APIKeyEnv: "CURSOR_API_KEY", + TimeoutSecs: 900, +}, +``` + +No default Cursor model belongs in `Model` or `Provider.Models`. + +Change the `Provider` comment to “one configured external AI service” and add +`cursor-agent` to the `Kind` field's documented values; do not add new config +fields. + +- [ ] **Step 4: Add provider capabilities and separate connect from activate** + +Add: + +```go +type Capability string + +const ( + CapabilityLLM Capability = "llm" + CapabilityAgent Capability = "agent" +) + +func CapabilityForKind(kind string) Capability { + if strings.EqualFold(strings.TrimSpace(kind), "cursor-agent") { + return CapabilityAgent + } + return CapabilityLLM +} + +func (i Info) Capability() Capability { return CapabilityForKind(i.Kind) } + +func CapabilityOf(cfg *config.Config, id string) Capability { + if info, ok := For(id); ok { + return info.Capability() + } + if cfg != nil { + if p, ok := cfg.Providers[id]; ok { + return CapabilityForKind(p.Kind) + } + } + return CapabilityLLM +} +``` + +Add Cursor to `catalog`: + +```go +{"cursor", "Cursor Cloud Agents", "cursor-agent", "CURSOR_API_KEY", + "https://api.cursor.com", true, nil}, +``` + +Extract the existing provider-population portion of `Activate` into: + +```go +func Connect(cfg *config.Config, id, key string) (Info, bool) { + if cfg.Providers == nil { + cfg.Providers = map[string]config.Provider{} + } + info, known := For(id) + p := cfg.Providers[id] + if known { + if p.Kind == "" { + p.Kind = info.Kind + } + if p.BaseURL == "" { + p.BaseURL = info.BaseURL + } + if p.APIKeyEnv == "" { + p.APIKeyEnv = info.KeyEnv + } + if p.Label == "" { + p.Label = info.Label + } + if len(p.Models) == 0 { + p.Models = info.Models + } + } + if key != "" { + p.APIKey = key + } + p.Enabled = true + cfg.Providers[id] = p + return info, known +} +``` + +Keep context-window metadata population in `Connect`. Then make: + +```go +func Activate(cfg *config.Config, id, key string) bool { + _, _ = Connect(cfg, id, key) + if CapabilityOf(cfg, id) == CapabilityAgent { + return false + } + cfg.Model.Provider = id + p := cfg.Providers[id] + if !contains(p.Models, cfg.Model.Default) && len(p.Models) > 0 { + cfg.Model.Default = p.Models[0] + } + return true +} +``` + +- [ ] **Step 5: Make CLI/TUI agent-provider behavior explicit** + +In CLI `provider use`, reject agent providers: + +```go +if providers.CapabilityOf(cfg, id) == providers.CapabilityAgent { + return fmt.Errorf("%s is an agent integration, not a chat model provider; use the cursor_agent tool", id) +} +``` + +In CLI `provider add`, call `providers.Connect` for an agent provider and print: + +```go +fmt.Printf("connected %s agent integration; active model remains %s (%s)\n", + id, cfg.Model.Default, cfg.Model.Provider) +``` + +For LLM providers, retain current activation behavior. + +In TUI `selectProvider`, connected agent providers display a system message +pointing to `cursor_agent` instead of switching. Connecting one calls +`providers.Connect`, saves config, and reports that the active model is +unchanged. + +- [ ] **Step 6: Verify provider, config, CLI, and TUI packages** + +Run: + +```bash +gofmt -w internal/config/config.go internal/config/defaults.go internal/providers/catalog.go internal/providers/catalog_test.go cmd/antares/main.go internal/tui/pickers.go +go test ./internal/providers ./internal/config ./internal/tui ./cmd/antares -count=1 +``` + +Expected: PASS. + +- [ ] **Step 7: Commit Task 3** + +```bash +git add internal/config/config.go internal/config/defaults.go internal/providers/catalog.go internal/providers/catalog_test.go cmd/antares/main.go internal/tui/pickers.go +git commit -m "Add Cursor agent provider capability" +``` + +--- + +### Task 4: Provider API, Credential Verification, and Model Isolation + +**Files:** +- Modify: `internal/server/server.go` +- Modify: `internal/server/handlers_setup.go` +- Modify: `internal/server/handlers_config.go` +- Modify: `internal/server/handlers_providers.go` +- Modify: `internal/server/routes.go` +- Create: `internal/server/cursor_provider_test.go` + +**Interfaces:** +- Consumes: metadata client from Task 1 and capability metadata from Task 3 +- Produces: `GET /api/providers/{id}/models` +- Produces: provider JSON field `capability` +- Guarantees: Cursor connection cannot mutate the active model or enter `/api/model/list-all` + +- [ ] **Step 1: Write failing server regression tests** + +Define a fake metadata client in `cursor_provider_test.go`: + +```go +type fakeCursorMetadata struct { + me cursor.Me + models cursor.ModelCatalog + err error +} + +func (f *fakeCursorMetadata) Me(context.Context) (*cursor.Me, error) { + return &f.me, f.err +} + +func (f *fakeCursorMetadata) Models(context.Context) (*cursor.ModelCatalog, error) { + return &f.models, f.err +} +``` + +Add: + +```go +func TestConnectCursorPreservesActiveModel(t *testing.T) { + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + cfg.Server.AuthToken = "test-token" + cfg.Server.DashboardPasswordHash = "test-hash" + cfg.Model.Provider = "openrouter" + cfg.Model.Default = "openai/gpt-5" + if err := config.SaveAt(config.ConfigFile(), cfg); err != nil { + t.Fatal(err) + } + + s := &Server{cfg: cfg, agent: &agent.Agent{}} + s.agent.SetConfig(cfg) + s.cursorFactory = func(cursor.Options) (cursorMetadataClient, error) { + return &fakeCursorMetadata{ + me: cursor.Me{APIKeyName: "test"}, + models: cursor.ModelCatalog{Items: []cursor.Model{{ID: "composer-2"}}}, + }, nil + } + s.reloadFn = func() error { return nil } + + req := httptest.NewRequest(http.MethodPost, "/api/providers/cursor/key", + strings.NewReader(`{"api_key":"synthetic-key"}`)) + req.SetPathValue("id", "cursor") + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.handleSetProviderKey(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if saved.Model.Provider != "openrouter" || saved.Model.Default != "openai/gpt-5" { + t.Fatalf("active model changed: %+v", saved.Model) + } +} +``` + +Also add tests that: + +- `/api/setup/status` omits capability `agent`. +- `/api/setup/complete` rejects `provider=cursor`. +- `/api/model/options` reports Cursor `capability:"agent"` and environment + credentials as `has_key:true`. +- `/api/providers/cursor/models` returns Cursor model IDs/display names. +- `/api/model/list-all` does not call or include Cursor. +- Cursor auth errors never contain the supplied key. + +- [ ] **Step 2: Run server tests and verify RED** + +Run: + +```bash +go test ./internal/server -run Cursor -count=1 -v +``` + +Expected: compilation failures for factory/capability/handler behavior. + +- [ ] **Step 3: Add injectable metadata-client boundary** + +In `server.go`: + +```go +type cursorMetadataClient interface { + Me(context.Context) (*cursor.Me, error) + Models(context.Context) (*cursor.ModelCatalog, error) +} + +type cursorClientFactory func(cursor.Options) (cursorMetadataClient, error) +``` + +Add `cursorFactory cursorClientFactory` to `Server` and this helper: + +```go +func (s *Server) newCursorMetadataClient(o cursor.Options) (cursorMetadataClient, error) { + if s.cursorFactory != nil { + return s.cursorFactory(o) + } + return cursor.New(o) +} +``` + +No production caller injects a factory. + +- [ ] **Step 4: Add capability-aware provider catalogue behavior** + +Add `Capability string` to `setupProvider` and set `"llm"` for existing entries +and `"agent"` for Cursor: + +```go +{ + ID: "cursor", Label: "Cursor Cloud Agents", Kind: "cursor-agent", + Capability: "agent", + Hint: "Delegate coding tasks to durable Cursor Cloud Agents.", + KeyHint: "crsr_…", KeyURL: "https://cursor.com/dashboard/api", + BaseURL: "https://api.cursor.com", + Note: "This deployment key and Cursor quota are shared by users allowed to invoke Cursor tools.", +} +``` + +When populating `HasKey`, resolve the provider: + +```go +_, resolved := cfg.ResolveProvider(out[i].ID) +out[i].HasKey = strings.TrimSpace(resolved.APIKey) != "" +``` + +Filter `Capability == "agent"` out of `handleSetupStatus`, and reject it in +`handleSetupComplete` before any config mutation. This keeps initial onboarding +limited to actual chat-model providers. + +- [ ] **Step 5: Special-case Cursor credential verification** + +Factor provider verification in `handleSetProviderKey`: + +```go +func (s *Server) verifyCursorProvider( + ctx context.Context, + baseURL, apiKey string, +) (*cursor.ModelCatalog, error) { + client, err := s.newCursorMetadataClient(cursor.Options{ + BaseURL: baseURL, APIKey: apiKey, + }) + if err != nil { + return nil, err + } + if _, err := client.Me(ctx); err != nil { + return nil, err + } + return client.Models(ctx) +} +``` + +For `Capability == "agent"`, call this helper rather than `llm.New`. Map +`cursor.IsAuthError` to the same 200/`ok:false` form used by other providers and +map transport/invalid response failures to 502. Only save the provider after +both calls succeed. Do not touch `cfg.Model`. + +- [ ] **Step 6: Add provider-specific Cursor model endpoint and isolation** + +Register: + +```go +m.HandleFunc("GET /api/providers/{id}/models", s.handleProviderModels) +``` + +Implement `handleProviderModels` to require dashboard access, resolve +`providers.cursor`, call `cursor.Models`, and return: + +```json +{ + "models": [ + { + "id": "composer-2", + "name": "Composer 2", + "description": "", + "parameters": [] + } + ] +} +``` + +Return `needs_key:true` without network access when no resolved key exists. + +Add `capability` to `/api/model/options`. In `/api/model/list-all`, skip any +configured or catalogued provider whose kind maps to +`providers.CapabilityAgent`. + +- [ ] **Step 7: Run server and adjacent package tests** + +Run: + +```bash +gofmt -w internal/server +go test ./internal/server ./internal/providers ./internal/config -count=1 -race +``` + +Expected: PASS. + +- [ ] **Step 8: Commit Task 4** + +```bash +git add internal/server +git commit -m "Connect Cursor without changing the active LLM" +``` + +--- + +### Task 5: Cursor Agent Tools + +**Files:** +- Create: `internal/tools/cursor_agent.go` +- Create: `internal/tools/cursor_agent_test.go` +- Create: `internal/agent/cursor_timeout_test.go` +- Modify: `internal/config/defaults.go` +- Modify: `internal/tools/register.go` +- Modify: `internal/tools/registry.go` + +**Interfaces:** +- Consumes: all lifecycle/stream APIs from Tasks 1–2 and `providers.cursor` config from Task 3 +- Produces: tool `cursor_agent` with actions `start`, `follow_up`, `cancel` +- Produces: tool `cursor_agent_status` with snapshot/wait behavior + +- [ ] **Step 1: Write failing schema, approval, and validation tests** + +```go +func TestCursorToolApprovalClassification(t *testing.T) { + if !NeedsApproval(cursorAgentTool{}) { + t.Fatal("cursor_agent must require approval") + } + if NeedsApproval(cursorAgentStatusTool{}) { + t.Fatal("cursor_agent_status must be read-only") + } +} + +func TestCursorAgentRejectsMissingConfigAndInvalidRepo(t *testing.T) { + in := Input{ + Args: []byte(`{"action":"start","prompt":"fix it"}`), + Deps: &Deps{Config: config.Default()}, + Emit: func(Progress) {}, + } + result := (cursorAgentTool{}).Execute(context.Background(), in) + if !result.IsError || !strings.Contains(result.Content, "CURSOR_API_KEY") { + t.Fatalf("missing-key result = %+v", result) + } + + cfg := config.Default() + p := cfg.Providers["cursor"] + p.APIKey = "synthetic-key" + cfg.Providers["cursor"] = p + in.Deps.Config = cfg + in.Args = []byte(`{"action":"start","prompt":"fix it","repository_url":"http://github.com/acme/repo"}`) + result = (cursorAgentTool{}).Execute(context.Background(), in) + if !result.IsError || !strings.Contains(result.Content, "HTTPS GitHub") { + t.Fatalf("invalid repo result = %+v", result) + } +} +``` + +Add table tests for required fields per action, mode enum, ID prefixes, no-repo +start, status latest-run resolution, and context timeout preserving IDs. + +Add an agent-envelope regression test: + +```go +func TestCursorToolTimeoutsAllowLongCloudRuns(t *testing.T) { + a := agentWithConfig(config.Default()) + for _, name := range []string{"cursor_agent", "cursor_agent_status"} { + if got := a.toolTimeout(name); got < 16*time.Minute { + t.Fatalf("%s timeout = %s, want at least 16m", name, got) + } + } +} +``` + +- [ ] **Step 2: Write failing API/progress tests with `httptest.Server`** + +Configure `providers.cursor.BaseURL` to the server URL and assert: + +- `start` posts expected repo/model/mode fields. +- `follow_up` posts to the existing agent. +- `cancel` posts to the cancel endpoint. +- `cursor_agent_status(wait=false)` reads agent then run. +- `wait=true` emits bounded progress for status/assistant/thinking/tool calls + and returns final text/git metadata. +- A server error containing `synthetic-key` cannot leak it into `Result.Content`, + `Result.Display`, `Result.Meta`, or progress messages. + +- [ ] **Step 3: Run tool tests and verify RED** + +Run: + +```bash +go test ./internal/tools -run CursorAgent -count=1 -v +``` + +Expected: compilation failures because the tools are absent. + +- [ ] **Step 4: Implement shared client/config helpers** + +Create: + +```go +func cursorClientFromInput(in Input) (*cursor.Client, config.Provider, error) { + if in.Deps == nil || in.Deps.Config == nil { + return nil, config.Provider{}, errors.New("Cursor is unavailable in this runtime") + } + _, p := in.Deps.Config.ResolveProvider("cursor") + if !p.Enabled || strings.TrimSpace(p.APIKey) == "" { + return nil, p, errors.New("connect Cursor in Providers or set CURSOR_API_KEY") + } + client, err := cursor.New(cursor.Options{ + BaseURL: p.BaseURL, APIKey: p.APIKey, + }) + return client, p, err +} + +func validateCursorRepository(raw string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || !strings.EqualFold(u.Host, "github.com") || u.User != nil { + return errors.New("repository_url must be an HTTPS GitHub URL") + } + return nil +} + +func cursorWaitContext(ctx context.Context, p config.Provider) (context.Context, context.CancelFunc) { + timeout := time.Duration(p.TimeoutSecs) * time.Second + if timeout <= 0 { + timeout = 15 * time.Minute + } + return context.WithTimeout(ctx, timeout) +} +``` + +For `start`, require `repository_url` whenever `pull_request_url` or +`auto_create_pr` is set. Validate `pull_request_url` with the same HTTPS, +`github.com`, and no-userinfo rules and require a `/pull/` path. Build +`Repos` as an empty slice for no-repo runs and as one `Repository` for repo/PR +runs. Never derive repository data by executing git or shell commands. + +Create/read requests retain the client's 30-second HTTP timeout. Before calling +`waitCursorRun`, wrap the outer tool context with `cursorWaitContext`; the SSE +client itself has no transport timeout, but the provider timeout and outer +960-second agent envelope both remain effective. + +- [ ] **Step 5: Implement `cursor_agent`** + +Define its schema: + +```go +func (cursorAgentTool) Schema() map[string]any { + return schema(map[string]any{ + "action": propEnum("Operation to perform.", "start", "follow_up", "cancel"), + "prompt": prop("string", "Task for start/follow_up."), + "agent_id": prop("string", "Cursor bc- agent id for follow_up/cancel."), + "run_id": prop("string", "Cursor run- id for cancel."), + "model": prop("string", "Optional model id returned by Cursor."), + "repository_url": prop("string", "Optional HTTPS GitHub repository URL."), + "starting_ref": prop("string", "Optional branch or commit SHA."), + "pull_request_url": prop("string", "Optional GitHub pull request URL."), + "mode": propEnum("Cursor conversation mode.", "agent", "plan"), + "auto_create_pr": propDefault("boolean", "Open a PR when the run completes.", false), + "skip_reviewer_request": propDefault("boolean", "Do not request the key owner as reviewer.", true), + "wait": propDefault("boolean", "Stream until terminal status.", true), + }, "action") +} + +func (cursorAgentTool) RequiresApproval() bool { return true } +``` + +Bind into pointer booleans where omission has a semantic default: + +```go +var args struct { + Action string `json:"action"` + Prompt string `json:"prompt"` + AgentID string `json:"agent_id"` + RunID string `json:"run_id"` + Model string `json:"model"` + RepositoryURL string `json:"repository_url"` + StartingRef string `json:"starting_ref"` + PullRequestURL string `json:"pull_request_url"` + Mode string `json:"mode"` + AutoCreatePR bool `json:"auto_create_pr"` + SkipReviewerRequest *bool `json:"skip_reviewer_request"` + Wait *bool `json:"wait"` +} +wait := true +if args.Wait != nil { + wait = *args.Wait +} +skipReviewer := true +if args.SkipReviewerRequest != nil { + skipReviewer = *args.SkipReviewerRequest +} +``` + +Use `CreateAgent`, `CreateRun`, and `CancelRun`. For start/follow-up with +`wait=true`, call a shared: + +```go +func waitCursorRun( + ctx context.Context, + in Input, + client *cursor.Client, + agent cursor.Agent, + run cursor.Run, +) Result +``` + +For `follow_up`, fetch `GetAgent` first so both immediate and waited results +retain the Cursor URL, then call `CreateRun`. Reject start-only fields +(`model`, repository/PR/ref, and PR options) on follow-up/cancel rather than +silently ignoring them. Reject prompt/mode/wait fields on cancel except that +an omitted `wait` pointer is allowed. + +Map events to bounded progress: + +```go +func emitCursorEvent(in Input, event cursor.StreamEvent) { + message := "Cursor " + event.Type + chunk := event.Text + if event.ToolName != "" { + message = "Cursor tool " + event.ToolName + " " + event.Status + } + runes := []rune(chunk) + if len(runes) > 2000 { + chunk = string(runes[:2000]) + "…" + } + in.Emit(Progress{Tool: "cursor_agent", Message: message, Chunk: chunk}) +} +``` + +The final result includes `agent_id`, `run_id`, `status`, `cursor_url`, +`duration_ms`, final text, and git branches/PRs in both readable content and +`Meta`. `wait=false` returns IDs/URL immediately and explicitly says not to +busy-poll. + +- [ ] **Step 6: Map errors without retrying mutations** + +Add one formatter used by both tools: + +```go +func cursorResultError(err error, agentID, runID string) Result { + meta := map[string]any{"agent_id": agentID, "run_id": runID} + switch { + case cursor.IsAuthError(err): + return Result{Content: "Cursor API key was rejected.", Meta: meta, IsError: true} + case cursor.IsRateLimit(err): + var apiErr *cursor.APIError + _ = errors.As(err, &apiErr) + if apiErr != nil && apiErr.RetryAfter > 0 { + meta["retry_after_seconds"] = int(apiErr.RetryAfter.Seconds()) + } + return Result{Content: "Cursor rate limit reached; retry later.", Meta: meta, IsError: true} + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): + return Result{ + Content: "Stopped waiting; the remote Cursor run may still be active. Use cursor_agent_status with the returned IDs.", + Meta: meta, IsError: true, + } + default: + return Result{Content: "Cursor request failed: " + err.Error(), Meta: meta, IsError: true} + } +} +``` + +For 404, prefix the result with either `Cursor agent not found` or +`Cursor run not found` according to the operation. Return 409 conflicts +verbatim but do not retry. The already-redacted client error is the only +upstream text that may be included. + +- [ ] **Step 7: Implement `cursor_agent_status`** + +Schema: + +```go +func (cursorAgentStatusTool) Schema() map[string]any { + return schema(map[string]any{ + "agent_id": prop("string", "Cursor bc- agent id."), + "run_id": prop("string", "Optional run- id; latest run is used when omitted."), + "wait": propDefault("boolean", "Stream until terminal status.", false), + }, "agent_id") +} +``` + +Fetch the agent first, resolve `LatestRunID` when needed, then either call +`GetRun` or `waitCursorRun`. It has no `RequiresApproval` method. + +- [ ] **Step 8: Register tools, timeout defaults, and toolset membership** + +Register both tools in `init()` and add both names to `coding`, `vibecoder`, +and `default`. Do not add them to `minimal`, `research`, `browser`, `social`, +or offensive-security-specific toolsets. + +Add `"cursor_agent": 960` and `"cursor_agent_status": 960` to +`config.Default().Tools.Timeouts`. This keeps the agent envelope above the +provider's 900-second default while still allowing operator overrides. + +- [ ] **Step 9: Run tools tests and race tests** + +Run: + +```bash +gofmt -w internal/tools/cursor_agent.go internal/tools/cursor_agent_test.go internal/tools/register.go internal/tools/registry.go internal/config/defaults.go internal/agent/cursor_timeout_test.go +go test ./internal/tools ./internal/agent -run 'CursorAgent|CursorTool|Toolset' -count=1 -race +``` + +Expected: PASS. + +- [ ] **Step 10: Commit Task 5** + +```bash +git add internal/tools internal/config/defaults.go internal/agent/cursor_timeout_test.go +git commit -m "Add Cursor cloud agent delegation tools" +``` + +--- + +### Task 6: Providers Dashboard Agent UX + +**Files:** +- Create: `web/src/lib/providerCapabilities.ts` +- Create: `web/src/lib/providerCapabilities.test.mjs` +- Modify: `web/src/pages/ProvidersPage.tsx` +- Modify: `web/src/lib/i18n.tsx` + +**Interfaces:** +- Consumes: provider JSON `capability` and `GET /api/providers/{id}/models` from Task 4 +- Produces: visible agent-integration classification and read-only Cursor model catalogue + +- [ ] **Step 1: Write failing capability helper tests** + +```javascript +import { describe, expect, test } from 'bun:test' +import { isAgentProvider, providerModelsPath } from './providerCapabilities.ts' + +describe('provider capabilities', () => { + test('classifies Cursor as an agent integration', () => { + expect(isAgentProvider({ capability: 'agent' })).toBe(true) + expect(isAgentProvider({ capability: 'llm' })).toBe(false) + }) + + test('uses provider-specific models for agents only', () => { + expect(providerModelsPath({ id: 'cursor', capability: 'agent' })) + .toBe('/providers/cursor/models') + expect(providerModelsPath({ id: 'openai', capability: 'llm' })).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run Bun test and verify RED** + +Run: + +```bash +cd web && bun test src/lib/providerCapabilities.test.mjs +``` + +Expected: module-not-found failure. + +- [ ] **Step 3: Implement capability helpers** + +```ts +export type ProviderCapability = 'llm' | 'agent' + +export interface ProviderCapabilityInfo { + id: string + capability?: ProviderCapability +} + +export function isAgentProvider(provider: Pick): boolean { + return provider.capability === 'agent' +} + +export function providerModelsPath(provider: ProviderCapabilityInfo): string | null { + return isAgentProvider(provider) + ? `/providers/${encodeURIComponent(provider.id)}/models` + : null +} +``` + +- [ ] **Step 4: Update provider card and modal types** + +Add `capability: 'llm' | 'agent'` to `ProviderInfo` and: + +```tsx +{isAgentProvider(p) ? ( + {t('providers.agentIntegration')} +) : null} +``` + +Do not render the active-model badge for an agent provider. + +In `ProviderModal`, fetch: + +```tsx +interface AgentModel { + id: string + name: string + description?: string + parameters?: unknown[] +} + +const agentOnly = isAgentProvider(p) +const agentModelsState = useApi<{ models: AgentModel[]; needs_key?: boolean }>(providerModelsPath(p)) +const llmModelsState = useApi<{ models: AllModel[] }>(agentOnly ? null : '/model/list-all') +const myModels = (llmModelsState.data?.models ?? []).filter((m) => m.provider === p.id) +``` + +For an agent provider, the Models section is read-only: show ID, display name, +and description from `agentModelsState`; hide add, context-window, and delete +controls. For LLM providers, retain the existing UI unchanged and replace the +old `modelsState.reload()` calls with `llmModelsState.reload()`. +Render `t('models.needsKey')` when `needs_key` is true and render +`agentModelsState.error.message` in the existing destructive error style when +model discovery fails. + +Change modal description to: + +```tsx + + {agentOnly ? t('providers.agentManageDesc') : t('providers.manageDesc')} + +``` + +- [ ] **Step 5: Add source and Indonesian translations** + +Add English source keys: + +```ts +'providers.agentIntegration': 'Agent integration', +'providers.agentManageDesc': 'Credentials, available agent models, and advanced settings.', +'providers.agentModelsReadOnly': 'Models available to this Cursor API key. Cursor chooses the default when no model is specified.', +``` + +Add Indonesian overrides: + +```ts +'providers.agentIntegration': 'Integrasi agent', +'providers.agentManageDesc': 'Kredensial, model agent yang tersedia, dan pengaturan lanjutan.', +'providers.agentModelsReadOnly': 'Model yang tersedia untuk API key Cursor ini. Cursor memilih default bila model tidak ditentukan.', +``` + +Other locales fall back to English through the existing partial dictionaries. + +- [ ] **Step 6: Run frontend tests, typecheck, and build** + +Run: + +```bash +cd web +bun test +bun x tsc -b --noEmit +bun run build +``` + +Expected: all tests and typecheck pass; Vite build completes. + +- [ ] **Step 7: Commit Task 6** + +```bash +git add web/src/lib/providerCapabilities.ts web/src/lib/providerCapabilities.test.mjs web/src/pages/ProvidersPage.tsx web/src/lib/i18n.tsx +git commit -m "Show Cursor as an agent integration" +``` + +--- + +### Task 7: Documentation, Opt-in Live Test, and End-to-End Verification + +**Files:** +- Create: `internal/cursor/live_test.go` +- Modify: `docs/configuration.md` +- Modify: `docs/tools.md` +- Modify: `docs/verification.md` +- Track: `docs/superpowers/specs/2026-08-12-cursor-agent-provider-design.md` +- Track: `docs/superpowers/plans/2026-08-12-cursor-agent-provider.md` + +**Interfaces:** +- Consumes: final Cursor client, provider, tools, and UI behavior +- Produces: operator instructions and hermetic/live verification commands + +- [ ] **Step 1: Add an opt-in metadata-only live test** + +```go +func TestLiveCursorMetadata(t *testing.T) { + key := strings.TrimSpace(os.Getenv("CURSOR_API_KEY")) + if key == "" { + t.Skip("set CURSOR_API_KEY to run Cursor metadata smoke test") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + client, err := New(Options{APIKey: key}) + if err != nil { + t.Fatal(err) + } + me, err := client.Me(ctx) + if err != nil { + t.Fatalf("Cursor /v1/me: %v", err) + } + models, err := client.Models(ctx) + if err != nil { + t.Fatalf("Cursor /v1/models: %v", err) + } + if me.APIKeyName == "" || len(models.Items) == 0 { + t.Fatalf("incomplete metadata: me=%+v models=%d", me, len(models.Items)) + } + t.Logf("Cursor key %q exposes %d models", me.APIKeyName, len(models.Items)) +} +``` + +This test must not create an agent or print the key. + +- [ ] **Step 2: Document exact configuration and safety model** + +Add to `docs/configuration.md`: + +```yaml +providers: + cursor: + kind: cursor-agent + base_url: https://api.cursor.com + api_key_env: CURSOR_API_KEY + enabled: true + timeout_seconds: 900 +``` + +Explain that Cursor is an agent integration, not a primary Antares model; one +deployment key/quota is shared; repository-backed runs use the repo state +available to Cursor, not unpushed local changes. + +Document both tools and approval/background behavior in `docs/tools.md`. +Document the metadata-only command in `docs/verification.md`: + +```bash +read -rsp 'Cursor API key: ' CURSOR_API_KEY +export CURSOR_API_KEY +go test ./internal/cursor -run TestLiveCursorMetadata -count=1 -v +unset CURSOR_API_KEY +``` + +Never put a real credential in docs or shell history during implementation. + +- [ ] **Step 3: Run focused backend/frontend verification** + +Run: + +```bash +go test ./internal/cursor ./internal/providers ./internal/config ./internal/server ./internal/tools -count=1 -race +go vet ./internal/cursor ./internal/providers ./internal/server ./internal/tools +cd web && bun test && bun x tsc -b --noEmit +``` + +Expected: PASS. + +- [ ] **Step 4: Run the complete hermetic suite** + +Unset unrelated live credentials so pre-existing live provider tests skip: + +```bash +env -u OPENAI_API_KEY -u AZURE_OPENAI_KEY -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY \ + go test ./... +go build ./... +``` + +Expected: all packages pass and build succeeds. + +- [ ] **Step 5: Run Cursor live metadata test only when the operator has exported the key** + +Check without printing it: + +```bash +if test -n "${CURSOR_API_KEY:-}"; then + go test ./internal/cursor -run TestLiveCursorMetadata -count=1 -v +else + printf '%s\n' 'CURSOR_API_KEY is not set; live Cursor test skipped' +fi +``` + +Do not reconstruct the credential from chat content or put it on a command line. + +- [ ] **Step 6: Build/install and restart Antares after tests pass** + +Run: + +```bash +make install-cli +~/.local/bin/antares stop +~/.local/bin/antares serve +~/.local/bin/antares status +``` + +Expected: the daemon reports the newly built commit/version and `/api/health` +returns HTTP 200. + +Restore tracked build-only noise before committing: + +```bash +git restore internal/server/dist/.gitkeep web/tsconfig.tsbuildinfo +``` + +- [ ] **Step 7: Final secret and diff audit** + +Run: + +```bash +git diff --check +git status --short +git diff --stat origin/main...HEAD +rg -n 'crsr_[A-Za-z0-9]+' --glob '!docs/superpowers/**' . +``` + +Expected: no real Cursor key match, no unrelated generated artifacts, and only +Cursor integration files in the feature diff. + +- [ ] **Step 8: Commit Task 7** + +```bash +git add internal/cursor/live_test.go docs/configuration.md docs/tools.md docs/verification.md docs/superpowers +git commit -m "Document and verify Cursor agent integration" +``` + +--- + +## Final Acceptance Checklist + +- [ ] Cursor appears in Providers as an **Agent integration**. +- [ ] A valid key connects and lists account-available Cursor models. +- [ ] An invalid key is rejected without secret leakage. +- [ ] `CURSOR_API_KEY` works without writing a key to YAML. +- [ ] Connecting Cursor leaves the active Antares model/provider unchanged. +- [ ] Cursor models are absent from the primary model picker. +- [ ] `cursor_agent` starts, follows up, and cancels cloud runs with approval. +- [ ] `cursor_agent_status` snapshots or streams without approval. +- [ ] No-repo and HTTPS GitHub repo/PR runs encode correctly. +- [ ] SSE reconnect uses `Last-Event-ID`; expired streams fall back to run status. +- [ ] Context cancellation reports recoverable IDs and does not silently cancel remote work. +- [ ] Unit tests, race tests, frontend tests, typecheck, full Go suite, and build pass. +- [ ] Live Cursor metadata test passes only from an environment-provided key. +- [ ] Installed daemon is restarted and healthy. +- [ ] No credential or generated build noise appears in the git diff. diff --git a/docs/superpowers/plans/2026-08-12-dns64-provider-validation.md b/docs/superpowers/plans/2026-08-12-dns64-provider-validation.md new file mode 100644 index 0000000..e0aec90 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-dns64-provider-validation.md @@ -0,0 +1,740 @@ +# DNS64-Aware Provider Validation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow every public Antares provider to connect through standards-compliant DNS64/NAT64 without weakening provider URL SSRF protections. + +**Architecture:** Add small RFC 6052 extraction and RFC 7050 prefix-discovery helpers behind an injectable `LookupIP` interface. Provider validation will continue to reject every blocked address unless a blocked IPv6 result matches a locally discovered NAT64 prefix, embeds a public IPv4 address, and that IPv4 exactly matches the provider hostname's public A result. A per-server resolver override will make the real credential handler testable without external DNS. + +**Tech Stack:** Go standard library (`context`, `net`, `net/url`), existing `net/http/httptest` server tests, Bun/TypeScript verification, GitHub Actions. + +## Global Constraints + +- Support RFC 6052 prefix lengths `/32`, `/40`, `/48`, `/56`, `/64`, and `/96`. +- Keep literal private IPs, arbitrary ULAs, loopback, link-local, metadata, multicast, documentation, carrier-grade NAT, and reserved ranges blocked exactly as before. +- Preserve the existing exception that a built-in local provider may use loopback, but not another private-network address. +- Never accept a private AAAA record merely because the hostname also has a public A record. +- Accept a synthesized IPv6 address only when its embedded IPv4 is public and exactly matches a public IPv4 result for the same hostname. +- DNS64 discovery failures must fail closed with the original non-public-address error. +- Use no new dependencies and never log, return, or persist test credentials. +- Keep the existing two-second DNS validation deadline. + +--- + +### Task 1: Add RFC 6052 extraction and RFC 7050 discovery primitives + +**Files:** +- Modify: `internal/server/security.go:99-163` +- Modify: `internal/server/security_test.go:1-101` + +**Interfaces:** +- Produces: `type providerIPResolver interface { LookupIP(context.Context, string, string) ([]net.IP, error) }` +- Produces: `type nat64Prefix struct { network net.IP; bits int }` +- Produces: `extractRFC6052IPv4(ip net.IP, prefixBits int) (net.IP, bool)` +- Produces: `discoverNAT64Prefixes(ctx context.Context, resolver providerIPResolver) ([]nat64Prefix, error)` +- Produces: test-only `staticIPResolver` and `synthesizeRFC6052` + +- [ ] **Step 1: Add table-driven failing extraction tests** + +Add these imports and test helpers to `internal/server/security_test.go`: + +```go +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" +) + +type staticIPResolver map[string][]net.IP + +func (r staticIPResolver) LookupIP(_ context.Context, network, host string) ([]net.IP, error) { + ips, ok := r[network+" "+host] + if !ok { + return nil, &net.DNSError{Err: "not found", Name: host, IsNotFound: true} + } + out := make([]net.IP, len(ips)) + copy(out, ips) + return out, nil +} + +func synthesizeRFC6052(t *testing.T, prefix net.IP, bits int, v4 net.IP) net.IP { + t.Helper() + p := prefix.To16() + v := v4.To4() + if p == nil || v == nil { + t.Fatalf("invalid synthesis input: prefix=%v v4=%v", prefix, v4) + } + out := make(net.IP, net.IPv6len) + if bits == 96 { + copy(out[:12], p[:12]) + copy(out[12:], v) + return out + } + compact := make([]byte, 15) + prefixBytes := bits / 8 + copy(compact[:prefixBytes], p[:prefixBytes]) + copy(compact[prefixBytes:prefixBytes+net.IPv4len], v) + copy(out[:8], compact[:8]) + out[8] = 0 + copy(out[9:], compact[8:]) + return out +} +``` + +Then add: + +```go +func TestExtractRFC6052IPv4SupportsEveryPrefixLength(t *testing.T) { + prefix := net.ParseIP("fd00:aa:bb:2090::") + want := net.ParseIP("54.158.233.194") + for _, bits := range []int{32, 40, 48, 56, 64, 96} { + t.Run(fmt.Sprintf("/%d", bits), func(t *testing.T) { + synth := synthesizeRFC6052(t, prefix, bits, want) + got, ok := extractRFC6052IPv4(synth, bits) + if !ok || !got.Equal(want) { + t.Fatalf("extractRFC6052IPv4(%s, %d) = %v, %v; want %s, true", + synth, bits, got, ok, want) + } + }) + } +} + +func TestExtractRFC6052IPv4RejectsInvalidFormat(t *testing.T) { + ip := synthesizeRFC6052(t, net.ParseIP("fd00:aa:bb:2090::"), 64, net.ParseIP("54.158.233.194")) + ip[8] = 1 + for _, tc := range []struct { + ip net.IP + bits int + }{ + {ip: ip, bits: 64}, + {ip: net.ParseIP("54.158.233.194"), bits: 96}, + {ip: net.ParseIP("2001:db8::1"), bits: 72}, + } { + if _, ok := extractRFC6052IPv4(tc.ip, tc.bits); ok { + t.Fatalf("extractRFC6052IPv4(%s, %d) accepted invalid format", tc.ip, tc.bits) + } + } +} +``` + +- [ ] **Step 2: Run the extraction tests and verify RED** + +Run: + +```bash +go test ./internal/server -run '^TestExtractRFC6052' -count=1 +``` + +Expected: build failure because `extractRFC6052IPv4` does not exist. + +- [ ] **Step 3: Implement RFC 6052 extraction** + +Add to `internal/server/security.go`: + +```go +var rfc6052PrefixLengths = [...]int{32, 40, 48, 56, 64, 96} + +func validRFC6052PrefixLength(bits int) bool { + for _, candidate := range rfc6052PrefixLengths { + if bits == candidate { + return true + } + } + return false +} + +func extractRFC6052IPv4(ip net.IP, prefixBits int) (net.IP, bool) { + v6 := ip.To16() + if v6 == nil || ip.To4() != nil || !validRFC6052PrefixLength(prefixBits) { + return nil, false + } + // RFC 6052 reserves bits 64-71 as the zero-valued "u" octet. + if v6[8] != 0 { + return nil, false + } + if prefixBits == 96 { + return net.IPv4(v6[12], v6[13], v6[14], v6[15]), true + } + compact := make([]byte, 15) + copy(compact[:8], v6[:8]) + copy(compact[8:], v6[9:]) + offset := prefixBits / 8 + return net.IPv4( + compact[offset], + compact[offset+1], + compact[offset+2], + compact[offset+3], + ), true +} +``` + +- [ ] **Step 4: Run extraction tests and verify GREEN** + +Run: + +```bash +go test ./internal/server -run '^TestExtractRFC6052' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Add a failing RFC 7050 discovery test** + +Add: + +```go +func TestDiscoverNAT64PrefixesUsesIPv4OnlyARPA(t *testing.T) { + prefix := net.ParseIP("fd00:aa:bb:2090::") + resolver := staticIPResolver{ + "ip6 ipv4only.arpa": { + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.170")), + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.171")), + }, + } + prefixes, err := discoverNAT64Prefixes(context.Background(), resolver) + if err != nil { + t.Fatalf("discoverNAT64Prefixes: %v", err) + } + if len(prefixes) != 1 || prefixes[0].bits != 96 { + t.Fatalf("prefixes = %+v, want one /96 prefix", prefixes) + } + if !prefixMatches(prefix, prefixes[0].network, 96) { + t.Fatalf("prefix network = %s, want %s/96", prefixes[0].network, prefix) + } +} +``` + +- [ ] **Step 6: Run discovery test and verify RED** + +Run: + +```bash +go test ./internal/server -run '^TestDiscoverNAT64Prefixes' -count=1 +``` + +Expected: build failure because the resolver interface, `nat64Prefix`, +`prefixMatches`, and `discoverNAT64Prefixes` do not exist. + +- [ ] **Step 7: Implement resolver abstraction and prefix discovery** + +Add: + +```go +type providerIPResolver interface { + LookupIP(context.Context, string, string) ([]net.IP, error) +} + +type nat64Prefix struct { + network net.IP + bits int +} + +func prefixMatches(ip, network net.IP, bits int) bool { + left, right := ip.To16(), network.To16() + if left == nil || right == nil { + return false + } + mask := net.CIDRMask(bits, 128) + return left.Mask(mask).Equal(right.Mask(mask)) +} + +func isIPv4OnlyWKA(ip net.IP) bool { + return ip.Equal(net.IPv4(192, 0, 0, 170)) || ip.Equal(net.IPv4(192, 0, 0, 171)) +} + +func discoverNAT64Prefixes(ctx context.Context, resolver providerIPResolver) ([]nat64Prefix, error) { + ips, err := resolver.LookupIP(ctx, "ip6", "ipv4only.arpa") + if err != nil { + return nil, err + } + seen := map[string]bool{} + var out []nat64Prefix + for _, ip := range ips { + for _, bits := range rfc6052PrefixLengths { + embedded, ok := extractRFC6052IPv4(ip, bits) + if !ok || !isIPv4OnlyWKA(embedded) { + continue + } + mask := net.CIDRMask(bits, 128) + network := append(net.IP(nil), ip.To16().Mask(mask)...) + key := fmt.Sprintf("%d:%x", bits, []byte(network)) + if !seen[key] { + seen[key] = true + out = append(out, nat64Prefix{network: network, bits: bits}) + } + } + } + if len(out) == 0 { + return nil, errors.New("DNS64 prefix discovery returned no RFC 6052 prefix") + } + return out, nil +} +``` + +- [ ] **Step 8: Run helper tests and the existing security tests** + +Run: + +```bash +go test ./internal/server -run '^(TestExtractRFC6052|TestDiscoverNAT64Prefixes|TestValidateProviderBaseURLBlocksPrivateDestinations)$' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 9: Commit the primitives** + +```bash +git add internal/server/security.go internal/server/security_test.go +git commit -m "Add standards-based DNS64 prefix discovery" +``` + +--- + +### Task 2: Make provider URL validation DNS64-aware and exercise the Cursor handler + +**Files:** +- Modify: `internal/server/security.go:99-163` +- Modify: `internal/server/security_test.go:74-101` +- Modify: `internal/server/server.go:45-80` +- Modify: `internal/server/handlers_setup.go:248-253,381-386,556-561` +- Modify: `internal/server/handlers_providers.go:277-288` +- Modify: `internal/server/cursor_provider_test.go:102-145` + +**Interfaces:** +- Consumes: `providerIPResolver`, `nat64Prefix`, `discoverNAT64Prefixes`, `extractRFC6052IPv4` +- Produces: `validateProviderBaseURLWithResolver(context.Context, string, bool, providerIPResolver) error` +- Produces: `(*Server).validateProviderBaseURL(context.Context, string, bool) error` +- Produces: test-only `Server.providerResolver` + +- [ ] **Step 1: Add DNS64 acceptance and SSRF rejection tests** + +Add to `internal/server/security_test.go`: + +```go +func dns64Resolver(t *testing.T, targetHost string, targetV4 net.IP) staticIPResolver { + t.Helper() + prefix := net.ParseIP("fd00:aa:bb:2090::") + return staticIPResolver{ + "ip " + targetHost: { + targetV4, + synthesizeRFC6052(t, prefix, 96, targetV4), + }, + "ip6 ipv4only.arpa": { + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.170")), + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.171")), + }, + } +} + +func TestValidateProviderBaseURLAllowsDiscoveredDNS64(t *testing.T) { + resolver := dns64Resolver(t, "api.cursor.com", net.ParseIP("54.158.233.194")) + err := validateProviderBaseURLWithResolver( + context.Background(), "https://api.cursor.com", false, resolver) + if err != nil { + t.Fatalf("DNS64 provider rejected: %v", err) + } +} + +func TestValidateProviderBaseURLRejectsUnrelatedULAOnMixedDNS(t *testing.T) { + resolver := dns64Resolver(t, "provider.example", net.ParseIP("54.158.233.194")) + resolver["ip provider.example"] = append( + resolver["ip provider.example"], net.ParseIP("fd00:dead:beef::1")) + if err := validateProviderBaseURLWithResolver( + context.Background(), "https://provider.example", false, resolver); err == nil { + t.Fatal("mixed public DNS with an unrelated ULA was accepted") + } +} + +func TestValidateProviderBaseURLRejectsDNS64AddressForDifferentARecord(t *testing.T) { + prefix := net.ParseIP("fd00:aa:bb:2090::") + resolver := dns64Resolver(t, "provider.example", net.ParseIP("54.158.233.194")) + resolver["ip provider.example"] = []net.IP{ + net.ParseIP("54.158.233.194"), + synthesizeRFC6052(t, prefix, 96, net.ParseIP("54.225.153.71")), + } + if err := validateProviderBaseURLWithResolver( + context.Background(), "https://provider.example", false, resolver); err == nil { + t.Fatal("DNS64 address whose embedded IPv4 mismatched the A record was accepted") + } +} + +func TestValidateProviderBaseURLRejectsMissingOrMalformedDNS64Discovery(t *testing.T) { + for _, tc := range []struct { + name string + discovery []net.IP + }{ + {name: "missing"}, + {name: "malformed", discovery: []net.IP{net.ParseIP("2001:4860::1")}}, + } { + t.Run(tc.name, func(t *testing.T) { + resolver := dns64Resolver(t, "provider.example", net.ParseIP("54.158.233.194")) + if tc.discovery == nil { + delete(resolver, "ip6 ipv4only.arpa") + } else { + resolver["ip6 ipv4only.arpa"] = tc.discovery + } + if err := validateProviderBaseURLWithResolver( + context.Background(), "https://provider.example", false, resolver); err == nil { + t.Fatal("provider passed without a valid discovered DNS64 prefix") + } + }) + } +} + +func TestDNS64MatchRejectsEmbeddedPrivateIPv4(t *testing.T) { + prefix := nat64Prefix{network: net.ParseIP("fd00:aa:bb:2090::"), bits: 96} + ip := synthesizeRFC6052(t, prefix.network, prefix.bits, net.ParseIP("10.0.0.8")) + publicV4 := map[string]struct{}{"10.0.0.8": {}} + if dns64AddressMatches(ip, []nat64Prefix{prefix}, publicV4) { + t.Fatal("DNS64 address embedding a private IPv4 was accepted") + } +} +``` + +- [ ] **Step 2: Run DNS64 validator tests and verify RED** + +Run: + +```bash +go test ./internal/server -run '^(TestValidateProviderBaseURLAllowsDiscoveredDNS64|TestValidateProviderBaseURLRejects.*DNS|TestDNS64MatchRejectsEmbeddedPrivateIPv4)$' -count=1 +``` + +Expected: build failure because `validateProviderBaseURLWithResolver` and +`dns64AddressMatches` do not exist. + +- [ ] **Step 3: Implement fail-closed DNS64-aware validation** + +Refactor `validateProviderBaseURL` so it is a wrapper: + +```go +func validateProviderBaseURL(ctx context.Context, raw string, allowLocal bool) error { + return validateProviderBaseURLWithResolver(ctx, raw, allowLocal, net.DefaultResolver) +} +``` + +Add: + +```go +func providerIPError(ip net.IP) error { + return fmt.Errorf("provider base_url resolves to a non-public address (%s)", ip.String()) +} + +func dns64AddressMatches(ip net.IP, prefixes []nat64Prefix, publicV4 map[string]struct{}) bool { + for _, prefix := range prefixes { + if !prefixMatches(ip, prefix.network, prefix.bits) { + continue + } + embedded, ok := extractRFC6052IPv4(ip, prefix.bits) + if !ok || providerIPBlocked(embedded) { + continue + } + if _, ok := publicV4[embedded.String()]; ok { + return true + } + } + return false +} +``` + +Move the existing syntax checks into +`validateProviderBaseURLWithResolver`, then replace its hostname-resolution +loop with: + +```go + lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + ips, err := resolver.LookupIP(lookupCtx, "ip", host) + if err != nil { + return fmt.Errorf("provider host cannot be resolved: %w", err) + } + if len(ips) == 0 { + return errors.New("provider host has no address") + } + + publicV4 := map[string]struct{}{} + var blockedV6 []net.IP + for _, ip := range ips { + blocked := providerIPBlocked(ip) && !(allowLocal && ip.IsLoopback()) + if !blocked { + if v4 := ip.To4(); v4 != nil && !providerIPBlocked(v4) { + publicV4[v4.String()] = struct{}{} + } + continue + } + if ip.To4() != nil { + return providerIPError(ip) + } + blockedV6 = append(blockedV6, append(net.IP(nil), ip...)) + } + if len(blockedV6) == 0 { + return nil + } + + prefixes, err := discoverNAT64Prefixes(lookupCtx, resolver) + if err != nil { + return providerIPError(blockedV6[0]) + } + for _, ip := range blockedV6 { + if !dns64AddressMatches(ip, prefixes, publicV4) { + return providerIPError(ip) + } + } + return nil +``` + +Keep the literal-IP branch before any resolver call and route it through the +same existing private/loopback check. + +- [ ] **Step 4: Run security tests and verify GREEN** + +Run: + +```bash +go test ./internal/server -run '^(TestExtractRFC6052|TestDiscoverNAT64Prefixes|TestValidateProviderBaseURL|TestDNS64Match)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 5: Add a failing handler-level Cursor DNS64 regression** + +In `internal/server/server.go`, add a test-only resolver field beside +`cursorFactory`: + +```go + // providerResolver overrides provider hostname resolution in handler tests. + // Production uses net.DefaultResolver. + providerResolver providerIPResolver +``` + +Do not wire it yet. Add this tracking wrapper to +`internal/server/cursor_provider_test.go`: + +```go +type trackingIPResolver struct { + providerIPResolver + calls int +} + +func (r *trackingIPResolver) LookupIP( + ctx context.Context, network, host string, +) ([]net.IP, error) { + r.calls++ + return r.providerIPResolver.LookupIP(ctx, network, host) +} +``` + +In `TestConnectCursorPreservesActiveModel`, configure: + +```go + resolver := &trackingIPResolver{ + providerIPResolver: dns64Resolver( + t, "api.cursor.com", net.ParseIP("54.158.233.194")), + } + s.providerResolver = resolver +``` + +Add `net` to the test imports, and change the request body to omit the +hermetic IP-literal override: + +```go + req := httptest.NewRequest(http.MethodPost, "/api/providers/cursor/key", + strings.NewReader(`{"api_key":"synthetic-key"}`)) +``` + +After checking the response status, assert that the handler used the injected +resolver: + +```go + if resolver.calls == 0 { + t.Fatal("Cursor connection bypassed the server provider resolver") + } +``` + +- [ ] **Step 6: Run the handler test and verify RED** + +Run: + +```bash +go test ./internal/server -run '^TestConnectCursorPreservesActiveModel$' -count=1 +``` + +Expected: FAIL on every network because the handler bypasses the injected +resolver; on a non-DNS64 network the request can otherwise succeed, but +`resolver.calls` remains zero. + +- [ ] **Step 7: Route every provider handler through the server resolver** + +Add to `internal/server/security.go`: + +```go +func (s *Server) validateProviderBaseURL(ctx context.Context, raw string, allowLocal bool) error { + resolver := s.providerResolver + if resolver == nil { + resolver = net.DefaultResolver + } + return validateProviderBaseURLWithResolver(ctx, raw, allowLocal, resolver) +} +``` + +Replace all four production calls: + +```go +validateProviderBaseURL(r.Context(), baseURL, chosen.Local) +``` + +or: + +```go +validateProviderBaseURL(r.Context(), baseURL, allowLocal) +``` + +with: + +```go +s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local) +``` + +or: + +```go +s.validateProviderBaseURL(r.Context(), baseURL, allowLocal) +``` + +The affected files are `handlers_setup.go` (three calls) and +`handlers_providers.go` (one call). + +- [ ] **Step 8: Run focused handler and security tests** + +Run: + +```bash +go test ./internal/server -run '^(TestConnectCursorPreservesActiveModel|TestExtractRFC6052|TestDiscoverNAT64Prefixes|TestValidateProviderBaseURL|TestDNS64Match)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 9: Run touched-package race tests** + +Run: + +```bash +go test -race ./internal/server -run '^(TestConnectCursorPreservesActiveModel|TestValidateProviderBaseURL|TestDiscoverNAT64Prefixes)' -count=1 +``` + +Expected: PASS with no race report. + +- [ ] **Step 10: Commit the production wiring** + +```bash +git add internal/server/security.go internal/server/security_test.go \ + internal/server/server.go internal/server/handlers_setup.go \ + internal/server/handlers_providers.go internal/server/cursor_provider_test.go +git commit -m "Fix provider connections on DNS64 networks" +``` + +--- + +### Task 3: Verify, deploy locally, and update the existing pull request + +**Files:** +- Verify only: all Go and web packages +- Generated and restore after build: `internal/server/dist/.gitkeep`, `web/tsconfig.tsbuildinfo` + +**Interfaces:** +- Consumes: complete DNS64-aware provider validator +- Produces: green local/CI verification, clean branch, updated PR, healthy local daemon + +- [ ] **Step 1: Format and run focused tests** + +```bash +gofmt -w internal/server/security.go internal/server/security_test.go \ + internal/server/server.go internal/server/handlers_setup.go \ + internal/server/handlers_providers.go internal/server/cursor_provider_test.go +go test ./internal/server -run '^(TestConnectCursorPreservesActiveModel|TestExtractRFC6052|TestDiscoverNAT64Prefixes|TestValidateProviderBaseURL|TestDNS64Match)' -count=1 +``` + +Expected: PASS. + +- [ ] **Step 2: Run the full Go quality gate** + +```bash +env -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u GEMINI_API_KEY -u CURSOR_API_KEY \ + go test ./... -count=1 +go vet ./... +``` + +Expected: all packages PASS and vet exits zero. + +- [ ] **Step 3: Run web verification** + +```bash +cd web +bun test +bun x tsc -b --noEmit +bun run build +cd .. +``` + +Expected: tests, typecheck, and production build all exit zero. + +- [ ] **Step 4: Confirm the affected network still presents DNS64** + +```bash +getent ahosts api.cursor.com +curl -sS -o /dev/null -w 'remote_ip=%{remote_ip} http=%{http_code}\n' \ + --max-time 10 https://api.cursor.com/v1/me +``` + +Expected: DNS includes public IPv4 plus `fd00:aa:bb:2090::/96` synthetic IPv6; +the unauthenticated request reaches Cursor and returns HTTP 401. + +- [ ] **Step 5: Build a clean-version binary, install, and restart** + +```bash +git restore internal/server/dist/.gitkeep web/tsconfig.tsbuildinfo +version=$(git describe --tags --always) +commit=$(git rev-parse --short HEAD) +make install-cli VERSION="$version" COMMIT="$commit" +"$HOME/.local/bin/antares" stop +"$HOME/.local/bin/antares" serve +"$HOME/.local/bin/antares" status +curl -fsS -o /dev/null -w 'health_http=%{http_code}\n' \ + http://127.0.0.1:8787/api/health +git restore internal/server/dist/.gitkeep web/tsconfig.tsbuildinfo +git status --short --branch +``` + +Expected: installed version contains the final commit without `-dirty`, daemon +is running, health is HTTP 200, and only the plan/spec state expected for the +next commit remains. + +- [ ] **Step 6: Push the updated branch** + +```bash +git push origin HEAD +``` + +Expected: `feature/cursor-agent-provider-impl` updates the existing PR without +a force push. + +- [ ] **Step 7: Wait for and verify pull-request checks** + +```bash +gh pr checks 25 --repo enowdev/antares --watch --interval 5 +``` + +Expected: `go` and `web` both pass. + +- [ ] **Step 8: Hand off credential retry** + +Report that: + +- the daemon is running the final commit; +- the health endpoint returns 200; +- DNS64-aware validation is covered by hermetic tests and the live network + still resolves through NAT64; +- the user should retry Providers → Cursor with a newly rotated key; and +- no pasted credential was committed or used in test output. diff --git a/docs/superpowers/specs/2026-08-12-cursor-agent-provider-design.md b/docs/superpowers/specs/2026-08-12-cursor-agent-provider-design.md new file mode 100644 index 0000000..46457c9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-cursor-agent-provider-design.md @@ -0,0 +1,300 @@ +# Cursor Agent Provider Design + +## Summary + +Antares will support Cursor as a first-class external agent integration. Users +configure a Cursor API key from the existing Providers page, discover the +models available to that key, and delegate coding work to Cursor Cloud Agents +through `cursor_agent` and `cursor_agent_status` tools. + +Cursor is deliberately not implemented as an `llm.Client`. Cursor's public API +creates durable coding agents and runs; it does not expose an OpenAI-compatible +chat-completions endpoint. Treating one cloud-agent run as one model completion +would lose Antares tool calls, duplicate conversation state, increase latency, +and misrepresent the product in the model picker. + +## Goals + +- Show Cursor in Antares' existing provider-management experience. +- Authenticate with a deployment-owned `CURSOR_API_KEY`. +- Validate credentials and list models through Cursor's official REST API. +- Create no-repo or repository-backed Cursor Cloud Agents. +- Continue an existing Cursor agent with follow-up runs. +- Stream progress, inspect status/results, and cancel runs. +- Preserve Cursor agent/run IDs and return branch/PR information to Antares. +- Never log, return, or commit the API key. + +## Non-goals + +- Using Cursor as Antares' primary chat-completion model. +- Supporting undocumented Cursor endpoints. +- Running Cursor's local SDK Bridge in the initial release. +- Per-user Cursor credentials in a shared Antares deployment. +- Building a separate dashboard for all Cursor runs. +- Automatically exposing Antares tools to cloud agents over MCP. + +## Product Model + +Cursor appears on the Providers page because that is where Antares already +manages external AI credentials. It is labelled **Cursor Cloud Agents** and +marked as an **Agent integration**, rather than an LLM provider. + +Connecting Cursor enables the Cursor agent tools. Cursor models are shown inside +the Cursor provider modal for task configuration, but they are excluded from +Antares' primary Models picker because they cannot satisfy the `llm.Client` +contract. + +A deployment has one Cursor credential. Every user permitted to invoke tools on +that Antares instance consumes the same Cursor account/team quota. Existing +Antares tool approval and platform toolset controls remain the authorization +boundary. + +## Configuration + +Cursor uses the existing `providers` map: + +```yaml +providers: + cursor: + kind: cursor-agent + label: Cursor Cloud Agents + base_url: https://api.cursor.com + api_key_env: CURSOR_API_KEY + enabled: true + timeout_seconds: 900 +``` + +The dashboard may store a key through the existing credential endpoint for +consistency with other providers, but documentation recommends the environment +variable. Responses continue to expose only `has_key`; the secret is never +serialized to the browser. + +Default configuration includes this provider in an enabled-but-disconnected +state, matching the built-in OpenAI/Anthropic entries: `CURSOR_API_KEY` works +without first writing YAML, while the absent key leaves the integration +unavailable. Provider status computes `has_key` from the resolved provider +(stored key or populated key environment variable), rather than checking only +the literal YAML field. + +Provider catalogue entries gain a capability marker (`llm` or `agent`). This +prevents generic setup/model code from passing Cursor to `llm.New` or selecting +it as the active chat provider. + +## Components + +### `internal/cursor` REST client + +A focused client owns Cursor protocol details: + +- `Me` — `GET /v1/me`, used to validate a key. +- `Models` — `GET /v1/models`, including model parameter definitions. +- `CreateAgent` — `POST /v1/agents`. +- `CreateRun` — `POST /v1/agents/{agentID}/runs`. +- `GetAgent` and `GetRun`. +- `StreamRun` — SSE stream with `Last-Event-ID` reconnect support. +- `CancelRun`. + +Authentication uses `Authorization: Bearer `. Request errors are typed so +callers can distinguish rejected credentials, rate limits, invalid requests, +missing agents/runs, transport failures, and cancellation. Error messages are +bounded and sanitized; request headers are never included. + +The client accepts an injected base URL and `http.Client`, allowing all tests to +run against `httptest.Server`. + +### Provider catalogue and setup + +Cursor is added to the web setup catalogue with: + +- ID `cursor` +- kind `cursor-agent` +- key hint `crsr_…` +- key URL `https://cursor.com/dashboard/api` +- base URL `https://api.cursor.com` +- capability `agent` +- note explaining shared deployment usage + +Credential testing special-cases agent-capability providers: + +1. Call `GET /v1/me` to verify authentication. +2. Call `GET /v1/models` to verify model access. +3. Return the available models without activating Cursor as the default LLM. + +CLI/TUI provider metadata receives the same capability marker. Connecting an +agent-capability provider stores/enables it but does not mutate +`model.provider` or `model.default`. + +### `cursor_agent` and `cursor_agent_status` tools + +The mutating `cursor_agent` tool exposes a small action-based API: + +- `start` — create an agent and its first run. +- `follow_up` — create a run on an existing active agent. +- `cancel` — cancel an active run. + +`start` accepts: + +- required `prompt` +- optional `model` +- optional `repository_url`, `starting_ref`, and `pull_request_url` +- optional `mode` (`agent` or `plan`) +- optional `auto_create_pr` and `skip_reviewer_request` +- optional `wait` + +Omitting repository fields creates a documented no-repo agent. Repository URLs +must be HTTPS GitHub URLs; refs are passed as data, never interpolated into +shell commands. + +`follow_up` requires `agent_id` and `prompt`, and optionally accepts `mode` and +`wait`. `cancel` requires both agent and run IDs. + +The read-only `cursor_agent_status` tool accepts `agent_id`, an optional +`run_id`, and optional `wait`. Without a run ID it resolves the agent's latest +run. With `wait=false` it returns one snapshot. With `wait=true` it follows the +run to a terminal state. + +`cursor_agent.RequiresApproval()` is always true because every action it +supports either consumes paid resources or mutates remote state. +`cursor_agent_status` does not require approval. + +When `wait` is true on either tool, it consumes Cursor's SSE stream and forwards +bounded status, assistant, reasoning, and tool-call updates through +`Input.Emit`. Completion returns the final text, duration, Cursor URL, run +status, and any pushed branches/PRs. A caller context cancellation closes the +stream; it does not automatically cancel the remote run unless the caller +explicitly invokes `cancel`. + +When `wait` is false, `cursor_agent` returns `agent_id`, `run_id`, and the +Cursor URL immediately. Its description tells the model not to busy-poll; the +user or a later turn can call `cursor_agent_status`. + +### Registration and availability + +Both tools are registered with the standard tool registry, but execution checks +that the Cursor provider is enabled and has a resolved key. If not configured, +they return an actionable message directing the user to Providers or +`CURSOR_API_KEY`. + +The existing toolset controls can remove both tools. No Cursor credential is +sent to the model or included in tool metadata. + +## Data Flow + +### Connect + +1. Admin opens Providers and selects Cursor Cloud Agents. +2. Dashboard sends the key to the existing protected credential endpoint. +3. Backend validates it with `/v1/me` and fetches `/v1/models`. +4. Backend stores the provider configuration and reloads Antares. +5. Cursor becomes connected, while the current Antares LLM remains unchanged. + +### Start and wait + +1. Antares calls `cursor_agent(action=start, ...)`. +2. Tool resolves `providers.cursor` and constructs the REST client. +3. Client creates the Cursor agent and initial run. +4. Tool streams the run, emitting progress without exposing secrets. +5. On terminal status, tool returns final text and git metadata. + +### Follow-up + +1. Antares reuses the returned `agent_id`. +2. Client posts a new run; Cursor retains its conversation and cloud workspace. +3. The result follows the same wait/background behavior as the initial run. + +## Error Handling + +- `401/403`: “Cursor API key was rejected”; never echo the key. +- `429`: include a bounded retry-after hint; do not blindly create duplicate + agents. +- `409` on idempotent create: return the existing conflict clearly. +- `404`: distinguish missing agent from missing run. +- SSE disconnect: reconnect with the last Cursor event ID and a capped + exponential backoff while the caller context remains active. +- Invalid resume event ID: clear the event ID once, reconnect from Cursor's + retained stream, then fail if the server rejects it again. +- Terminal statuses (`FINISHED`, `ERROR`, `CANCELLED`) are returned as explicit + structured metadata. +- Context timeout: report that the remote run may still be active and provide + IDs for a later `cursor_agent_status` or cancel action. + +Automatic retries are limited to idempotent GET/stream reconnection. Create +agent/run calls are not retried unless a client-supplied idempotency identifier +can prove they will not duplicate work. + +## Security + +- No API key literal in source, tests, fixtures, docs, command arguments, or + logs. +- Prefer `CURSOR_API_KEY`; stored-key behavior remains consistent with current + Antares providers. +- Redact authorization headers from every error path. +- Enforce existing dashboard-password checks on credential mutation. +- Validate configurable base URLs with the existing provider URL policy. +- Require tool approval for paid or repository-mutating actions. +- Do not pass arbitrary environment variables, MCP servers, or worker targets + in the initial tool schema. +- Return only bounded Cursor tool-call summaries to avoid leaking cloud + environment data into the Antares conversation. + +## Testing + +### Unit tests + +- Bearer authorization is sent and never appears in errors. +- `/v1/me` credential validation. +- Model catalogue decoding, including parameter definitions. +- Agent/run request encoding for no-repo, repo, PR, and follow-up cases. +- Terminal run decoding and git metadata. +- SSE parsing for status, assistant, reasoning, tool-call, result, and error + events. +- `Last-Event-ID` reconnect and context cancellation. +- Typed handling for 400, 401/403, 404, 409, 429, and 5xx. + +### Tool tests + +- Missing/disabled credential errors. +- Action-specific validation. +- Mutating and read-only tools have the correct static approval classification. +- Progress emission is bounded and secret-free. +- Start/follow-up/cancel and status/wait call the correct client operations. +- A wait timeout returns recoverable agent/run IDs. + +### Server/UI tests + +- Cursor appears as an agent-capability provider. +- Connecting Cursor does not change the active Antares model/provider. +- Cursor never appears in the primary model picker. +- Credential responses remain redacted. +- Failed auth and model discovery produce actionable UI messages. + +### Live test + +An opt-in test runs only when `CURSOR_API_KEY` is already present in the test +process environment. It calls `/v1/me` and `/v1/models`; it does not create an +agent by default, avoiding unexpected billing. A separate explicitly enabled +smoke test may create a no-repo agent with a minimal prompt. + +## Rollout and Compatibility + +Existing configs require no migration. The provider catalogue supplies Cursor +defaults when it is first connected. Unknown/custom providers retain the +current LLM capability by default, preserving compatibility. + +The initial release is cloud-only. A future local implementation can add a +`cursor-local-agent` capability through the official SDK Bridge without +changing the REST client or pretending either runtime is a chat-completion +provider. + +## Acceptance Criteria + +- An admin can connect a valid Cursor key from Providers. +- Invalid keys fail without appearing in logs or responses. +- The provider modal lists models returned for that account. +- Connecting Cursor leaves the active Antares chat model untouched. +- Antares can create, follow up, and cancel a Cursor Cloud Agent through + `cursor_agent`, and inspect/stream it through `cursor_agent_status`. +- Repo-backed runs return branch/PR metadata. +- No-repo runs work without repository fields. +- Unit and UI tests pass without external network access. +- The live auth/model test passes when an environment-provided key is valid. diff --git a/docs/superpowers/specs/2026-08-12-dns64-provider-validation-design.md b/docs/superpowers/specs/2026-08-12-dns64-provider-validation-design.md new file mode 100644 index 0000000..ffb5ead --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-dns64-provider-validation-design.md @@ -0,0 +1,136 @@ +# DNS64-Aware Provider URL Validation Design + +## Summary + +Antares currently rejects a provider URL when any resolved address is private, +link-local, loopback, multicast, or otherwise non-public. That is the correct +default for SSRF prevention, but it produces a false positive on DNS64/NAT64 +networks: a public provider can resolve to public IPv4 addresses plus synthetic +IPv6 addresses under a network-specific ULA prefix. + +On the affected network, `api.cursor.com` resolves to public IPv4 addresses and +to addresses under `fd00:aa:bb:2090::/96`. The low 32 bits of each IPv6 address +encode one of the public IPv4 results. Connecting to that synthetic IPv6 +address reaches Cursor successfully, but `net.IP.IsPrivate` causes +`validateProviderBaseURL` to reject it before the credential check. + +Antares will recognize standards-compliant DNS64 synthesis while retaining the +existing fail-closed behavior for ordinary private destinations. + +## Goals + +- Allow public provider URLs to work on RFC 7050/RFC 6052 DNS64 networks. +- Support all RFC 6052 prefix lengths: `/32`, `/40`, `/48`, `/56`, `/64`, and + `/96`. +- Keep blocking literal private IPs, arbitrary ULA records, loopback, + link-local, metadata, multicast, documentation, and reserved ranges. +- Prevent an attacker-controlled hostname from passing validation by returning + one public A record and one unrelated private AAAA record. +- Apply the behavior consistently to all provider connection paths. +- Keep tests hermetic and independent of the machine's DNS configuration. + +## Non-goals + +- Disabling provider URL SSRF validation. +- Trusting all ULA addresses on a host that also has a public A record. +- Adding provider-specific URL bypasses. +- Implementing a DNSSEC validator inside Antares. +- Persisting a NAT64 prefix across process restarts or network changes. + +## Design + +### Resolver boundary + +Production validation continues to use `net.DefaultResolver`, but the DNS +lookup dependency is represented by a small internal interface matching +`LookupIP`. The public helper keeps its current signature and delegates to an +internal resolver-aware helper. Tests inject deterministic records rather than +depending on external DNS. + +IP literals never enter DNS64 handling. They continue through the existing +direct address check, so a literal ULA or private IPv4 address remains blocked. + +### Normal provider resolution + +For a hostname, the validator resolves all addresses under the existing +two-second lookup context: + +1. Public IPv4 and native public IPv6 addresses pass the existing checks. +2. Any directly resolved blocked IPv4 address fails validation. +3. A blocked IPv6 address is retained as a DNS64 candidate; it is not accepted + merely because the hostname also has a public IPv4 address. +4. If no blocked IPv6 candidate exists, behavior is unchanged. + +The validator records the hostname's public IPv4 addresses. A DNS64 candidate +can only be accepted if its embedded IPv4 address exactly matches one of those +public A results. + +### NAT64 prefix discovery + +When a blocked IPv6 candidate is present, Antares resolves AAAA records for +`ipv4only.arpa` using the same resolver and context. RFC 7050 defines that name +and the well-known IPv4 addresses `192.0.0.170` and `192.0.0.171`. + +For each returned IPv6 address, Antares tries the six prefix lengths allowed by +RFC 6052. It validates the reserved `u` octet, extracts the embedded IPv4 bits, +and accepts a prefix candidate only when the result is one of the two +well-known IPv4 addresses. Duplicate prefix candidates are discarded. + +Discovery is performed only when needed and is scoped to one validation call. +This avoids stale global state when a laptop changes networks. + +### Synthetic-address validation + +A blocked IPv6 provider address is treated as DNS64 synthesis only when all of +the following hold: + +- it matches one of the prefixes discovered from `ipv4only.arpa`; +- its RFC 6052 reserved `u` octet is zero where applicable; +- an IPv4 address can be extracted using that prefix length; +- the extracted IPv4 address is public under the existing block rules; and +- the extracted IPv4 address exactly matches a public IPv4 result for the + provider hostname. + +Every blocked IPv6 result must satisfy these rules. One unrelated private AAAA +record still rejects the URL. If prefix discovery fails or produces no valid +prefix, validation returns the original non-public-address error. + +## Error Handling + +- Syntax, scheme, userinfo, literal-IP, and DNS lookup errors retain their + existing messages. +- DNS64 discovery is a conditional validation step, not a fallback that turns + resolution failures into success. +- Discovery errors fail closed and do not expose internal resolver details to + the provider connection response. +- No API key is involved in DNS discovery or error output. + +## Testing + +Unit tests will use a fake resolver to cover: + +- the observed network-specific `/96` ULA prefix; +- at least one prefix that crosses the RFC 6052 `u` octet; +- all six supported prefix lengths through table-driven extraction tests; +- a synthetic IPv6 address whose embedded public IPv4 matches the hostname; +- an arbitrary ULA address alongside a public A record; +- a valid NAT64 prefix with a mismatched hostname A record; +- a synthesized private, loopback, or reserved IPv4 address; +- malformed `ipv4only.arpa` responses and missing DNS64 discovery; +- preservation of the existing literal/private/local-provider behavior. + +A server-level regression test will connect the Cursor provider using its +normal `https://api.cursor.com` base URL and an injected DNS64 resolver/client, +proving that validation reaches credential verification without weakening the +provider capability boundary. + +Verification will run focused server security tests, the full Go suite, race +tests for touched packages, `go vet`, the web tests/typecheck, a production +build, and a daemon restart plus health check. + +## Rollout + +The fix is backward-compatible and requires no configuration migration. It +will be added to the existing Cursor integration pull request, installed +locally, and verified on the affected DNS64 network before the user retries +with a newly rotated Cursor key. diff --git a/docs/tools.md b/docs/tools.md index 2663876..43dc04c 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -142,6 +142,22 @@ tools: Tools declare whether they mutate. `read_file` does not; `write_file`, `terminal`, and `browser` do. +## Cursor Cloud Agents + +`cursor_agent` delegates coding work to a configured Cursor Cloud Agent. It can +start a run, follow up on an existing agent, or request cancellation. Starting, +following up, and cancelling are always approval-gated because they create or +change remote work. It defaults to `wait: true`. With `wait: false`, it returns +the agent ID, run ID, and Cursor URL immediately; use `cursor_agent_status` +later instead of busy-polling. + +`cursor_agent_status` is read-only and needs no approval. It defaults to +`wait: false` and returns one snapshot; `wait: true` streams until terminal +status. Cancelling local waiting does not cancel the remote Cursor run. Use the +status tool to inspect an agent/run returned by `cursor_agent`, rather than +repeatedly starting new work. Both tools are available when the Cursor agent +integration has a `CURSOR_API_KEY`; see [Configuration](configuration.md). + ## Limits ```yaml diff --git a/docs/verification.md b/docs/verification.md index 27a8259..ce69ed9 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -37,6 +37,19 @@ OPENAI_API_KEY=sk-… go test ./internal/llm -run TestLiveCodex -v OPENAI_API_KEY=sk-… go test ./internal/llm -run TestLiveSpeakRoundTrip -v ``` +## Cursor Cloud Agents + +This metadata-only smoke test calls Cursor's `/v1/me` and `/v1/models` +endpoints. It does not create an agent or run. Enter the key interactively so +it is not stored in shell history: + +```bash +read -rsp 'Cursor API key: ' CURSOR_API_KEY +export CURSOR_API_KEY +go test ./internal/cursor -run TestLiveCursorMetadata -count=1 -v +unset CURSOR_API_KEY +``` + ## Chat gateways Gateways need a running bot and, for the webhook ones, a reachable URL. Verify diff --git a/internal/agent/cursor_timeout_test.go b/internal/agent/cursor_timeout_test.go new file mode 100644 index 0000000..c1ae419 --- /dev/null +++ b/internal/agent/cursor_timeout_test.go @@ -0,0 +1,32 @@ +package agent + +import ( + "testing" + "time" + + "github.com/enowdev/antares/internal/config" +) + +func TestCursorToolTimeoutsAllowLongCloudRuns(t *testing.T) { + a := agentWithConfig(config.Default()) + for _, name := range []string{"cursor_agent", "cursor_agent_status"} { + if got := a.toolTimeout(name); got < 16*time.Minute { + t.Fatalf("%s timeout = %s, want at least 16m", name, got) + } + } +} + +func TestCursorToolTimeoutDefaultsRemainOperatorOverrideable(t *testing.T) { + cfg := config.Default() + for _, name := range []string{"cursor_agent", "cursor_agent_status"} { + if got := cfg.Tools.Timeouts[name]; got != 960 { + t.Fatalf("%s default timeout = %d, want 960", name, got) + } + } + + cfg.Tools.Timeouts["cursor_agent"] = 123 + a := agentWithConfig(cfg) + if got := a.toolTimeout("cursor_agent"); got != 123*time.Second { + t.Fatalf("cursor_agent configured timeout = %s, want 123s", got) + } +} diff --git a/internal/commands/handlers.go b/internal/commands/handlers.go index e8b1fc6..5aa2da9 100644 --- a/internal/commands/handlers.go +++ b/internal/commands/handlers.go @@ -10,6 +10,7 @@ import ( "time" "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/providers" "github.com/enowdev/antares/internal/store" ) @@ -172,6 +173,12 @@ func cmdProvider(_ context.Context, d Deps, in Input) (Result, error) { if _, ok := next.Providers[in.Args]; !ok { return Result{}, fmt.Errorf("no provider named %q is configured", in.Args) } + if providers.CapabilityOf(next, in.Args) == providers.CapabilityAgent { + return Result{Output: fmt.Sprintf( + "`%s` is an agent integration, not a chat model provider; use the cursor_agent tool.", + in.Args, + )}, nil + } next.Model.Provider = in.Args if err := config.Save(next); err != nil { return Result{}, err diff --git a/internal/commands/provider_test.go b/internal/commands/provider_test.go new file mode 100644 index 0000000..8db574b --- /dev/null +++ b/internal/commands/provider_test.go @@ -0,0 +1,44 @@ +package commands + +import ( + "context" + "strings" + "testing" + + "github.com/enowdev/antares/internal/config" +) + +func TestProviderCommandRejectsAgentIntegration(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + beforeProvider, beforeModel := cfg.Model.Provider, cfg.Model.Default + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + + reloaded := false + result, err := Run(context.Background(), Deps{ + Config: func() *config.Config { return cfg }, + Reload: func() error { + reloaded = true + return nil + }, + }, Input{Name: "provider", Args: "cursor"}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Output, "cursor_agent") { + t.Fatalf("result = %+v", result) + } + if result.Action.Kind != "" || reloaded { + t.Fatalf("agent provider changed configuration: result=%+v reloaded=%v", result, reloaded) + } + + after, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if after.Model.Provider != beforeProvider || after.Model.Default != beforeModel { + t.Fatalf("model changed to %s/%s", after.Model.Provider, after.Model.Default) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 97b58f9..537c82a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -106,9 +106,9 @@ type Model struct { Panel []string `yaml:"panel" json:"panel"` } -// Provider is one configured LLM endpoint. +// Provider is one configured external AI service. type Provider struct { - Kind string `yaml:"kind" json:"kind"` // openai-compatible|anthropic|openai|gemini|custom + Kind string `yaml:"kind" json:"kind"` // openai-compatible|anthropic|openai|gemini|custom|cursor-agent BaseURL string `yaml:"base_url" json:"base_url"` APIKey string `yaml:"api_key" json:"api_key"` APIKeyEnv string `yaml:"api_key_env" json:"api_key_env"` diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 5279cba..a4c6f34 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -44,6 +44,10 @@ func Default() *Config { "custom": { Kind: "custom", Label: "Custom endpoint", Enabled: false, TimeoutSecs: 300, }, + "cursor": { + Kind: "cursor-agent", Label: "Cursor Cloud Agents", Enabled: true, + BaseURL: "https://api.cursor.com", APIKeyEnv: "CURSOR_API_KEY", TimeoutSecs: 900, + }, }, Database: Database{ Driver: "sqlite", DSN: filepath.Join(Home(), "antares.db"), @@ -68,6 +72,8 @@ func Default() *Config { "terminal": 300, "web_fetch": 60, "web_search": 30, // VPS tools allow up to 900s per call; keep the agent envelope above that. "vps_run": 960, "vps_upload": 960, "vps_download": 960, + // Cursor's provider wait defaults to 900s; leave envelope/serialization margin. + "cursor_agent": 960, "cursor_agent_status": 960, }, WebSearch: WebSearch{Provider: "browser", MaxResults: 8}, Browser: Browser{ @@ -129,7 +135,7 @@ func Default() *Config { Display: Display{ ToolProgress: true, ShowReasoning: true, MaxLiveReasoningChars: 48_000, - Theme: "system", Skin: "antares", Language: "auto", InterimAssistant: true, + Theme: "system", Skin: "antares", Language: "auto", InterimAssistant: true, }, Logging: Logging{Level: "info", File: filepath.Join(Home(), "logs", "antares.log")}, MCP: MCP{Enabled: true, Servers: map[string]MCPServer{}}, diff --git a/internal/cursor/client.go b/internal/cursor/client.go new file mode 100644 index 0000000..2b3a8f4 --- /dev/null +++ b/internal/cursor/client.go @@ -0,0 +1,259 @@ +package cursor + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +type Options struct { + BaseURL string + APIKey string + HTTPClient *http.Client +} + +type Client struct { + baseURL string + apiKey string + http *http.Client +} + +type APIError struct { + Status int + Code string + Message string + RetryAfter time.Duration +} + +func (e *APIError) Error() string { + if e.Message != "" { + return e.Message + } + return fmt.Sprintf("cursor api error: %d", e.Status) +} + +func New(o Options) (*Client, error) { + base := strings.TrimRight(strings.TrimSpace(o.BaseURL), "/") + if base == "" { + base = "https://api.cursor.com" + } + u, err := url.Parse(base) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { + return nil, fmt.Errorf("invalid Cursor base URL") + } + hc := o.HTTPClient + if hc == nil { + hc = &http.Client{Timeout: 30 * time.Second} + } + return &Client{baseURL: base, apiKey: strings.TrimSpace(o.APIKey), http: hc}, nil +} + +func (c *Client) doJSON(ctx context.Context, method, path string, in, out any) error { + var body io.Reader + if in != nil { + raw, err := json.Marshal(in) + if err != nil { + return err + } + body = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + if in != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return c.decodeAPIError(resp) + } + if out == nil { + _, err = io.Copy(io.Discard, resp.Body) + return err + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (c *Client) Me(ctx context.Context) (*Me, error) { + var out Me + if err := c.doJSON(ctx, http.MethodGet, "/v1/me", nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) Models(ctx context.Context) (*ModelCatalog, error) { + var out ModelCatalog + if err := c.doJSON(ctx, http.MethodGet, "/v1/models", nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) CreateAgent(ctx context.Context, in CreateAgentRequest) (*CreateAgentResponse, error) { + if strings.TrimSpace(in.Prompt.Text) == "" { + return nil, fmt.Errorf("cursor: prompt text is required") + } + var out CreateAgentResponse + if err := c.doJSON(ctx, http.MethodPost, "/v1/agents", in, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) CreateRun(ctx context.Context, agentID string, in CreateRunRequest) (*Run, error) { + if strings.TrimSpace(agentID) == "" { + return nil, fmt.Errorf("cursor: agentID is required") + } + if strings.TrimSpace(in.Prompt.Text) == "" { + return nil, fmt.Errorf("cursor: prompt text is required") + } + var out CreateRunResponse + path := "/v1/agents/" + url.PathEscape(agentID) + "/runs" + if err := c.doJSON(ctx, http.MethodPost, path, in, &out); err != nil { + return nil, err + } + return &out.Run, nil +} + +func (c *Client) GetAgent(ctx context.Context, agentID string) (*Agent, error) { + if strings.TrimSpace(agentID) == "" { + return nil, fmt.Errorf("cursor: agentID is required") + } + var out Agent + path := "/v1/agents/" + url.PathEscape(agentID) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) GetRun(ctx context.Context, agentID, runID string) (*Run, error) { + if strings.TrimSpace(agentID) == "" { + return nil, fmt.Errorf("cursor: agentID is required") + } + if strings.TrimSpace(runID) == "" { + return nil, fmt.Errorf("cursor: runID is required") + } + var out Run + path := "/v1/agents/" + url.PathEscape(agentID) + "/runs/" + url.PathEscape(runID) + if err := c.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) CancelRun(ctx context.Context, agentID, runID string) error { + if strings.TrimSpace(agentID) == "" { + return fmt.Errorf("cursor: agentID is required") + } + if strings.TrimSpace(runID) == "" { + return fmt.Errorf("cursor: runID is required") + } + path := "/v1/agents/" + url.PathEscape(agentID) + "/runs/" + url.PathEscape(runID) + "/cancel" + return c.doJSON(ctx, http.MethodPost, path, nil, nil) +} + +func (c *Client) decodeAPIError(resp *http.Response) error { + const maxBody = 64 << 10 + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return err + } + + var payload struct { + Code string `json:"code"` + Message string `json:"message"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + apiErr := &APIError{Status: resp.StatusCode} + if json.Unmarshal(raw, &payload) == nil { + if payload.Error.Message != "" || payload.Error.Code != "" { + apiErr.Code = payload.Error.Code + apiErr.Message = payload.Error.Message + } else { + apiErr.Code = payload.Code + apiErr.Message = payload.Message + } + } + if apiErr.Message == "" { + apiErr.Message = "request failed" + } + + c.sanitizeAPIError(apiErr) + + if ra := resp.Header.Get("Retry-After"); ra != "" { + if secs, err := strconv.Atoi(ra); err == nil { + apiErr.RetryAfter = time.Duration(secs) * time.Second + } else if t, err := http.ParseTime(ra); err == nil { + d := time.Until(t) + if d > 0 { + apiErr.RetryAfter = d + } + } + } + + return apiErr +} + +// sanitizeAPIError is the single policy for every error this client hands +// back, whether it came from a REST response or from an in-band SSE error +// event: the configured key never appears, the text is valid UTF-8, and both +// fields are bounded. Codes are short identifiers, so their cap only exists +// to stop an oversized payload from becoming an oversized error. +func (c *Client) sanitizeAPIError(apiErr *APIError) { + if c.apiKey != "" { + apiErr.Code = strings.ReplaceAll(apiErr.Code, c.apiKey, "[REDACTED]") + apiErr.Message = strings.ReplaceAll(apiErr.Message, c.apiKey, "[REDACTED]") + } + apiErr.Code = truncateRunes(strings.ToValidUTF8(apiErr.Code, "\uFFFD"), 120) + apiErr.Message = truncateRunes(strings.ToValidUTF8(apiErr.Message, "\uFFFD"), 240) +} + +func truncateRunes(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + count := 0 + for i := range s { + if count == maxRunes { + return s[:i] + } + count++ + } + return s +} + +func IsAuthError(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && (apiErr.Status == http.StatusUnauthorized || apiErr.Status == http.StatusForbidden) +} + +func IsRateLimit(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.Status == http.StatusTooManyRequests +} + +func IsStatus(err error, status int) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && apiErr.Status == status +} diff --git a/internal/cursor/client_test.go b/internal/cursor/client_test.go new file mode 100644 index 0000000..3013c12 --- /dev/null +++ b/internal/cursor/client_test.go @@ -0,0 +1,328 @@ +package cursor + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + "time" + "unicode/utf8" +) + +func TestMeAndModelsUseBearerAndDecodeCatalog(t *testing.T) { + const key = "synthetic-cursor-key" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer "+key { + t.Fatalf("Authorization = %q", got) + } + switch r.URL.Path { + case "/v1/me": + _ = json.NewEncoder(w).Encode(map[string]any{ + "apiKeyName": "test key", "createdAt": "2026-08-12T00:00:00Z", + }) + case "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{ + map[string]any{ + "id": "composer-2", "displayName": "Composer 2", + "parameters": []any{map[string]any{ + "id": "fast", "values": []any{map[string]any{"value": "true"}}, + }}, + }, + }}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client, err := New(Options{BaseURL: srv.URL, APIKey: key, HTTPClient: srv.Client()}) + if err != nil { + t.Fatal(err) + } + me, err := client.Me(context.Background()) + if err != nil || me.APIKeyName != "test key" { + t.Fatalf("Me = %+v, %v", me, err) + } + models, err := client.Models(context.Background()) + if err != nil || len(models.Items) != 1 || models.Items[0].ID != "composer-2" { + t.Fatalf("Models = %+v, %v", models, err) + } +} + +func TestAPIErrorNeverLeaksAPIKey(t *testing.T) { + const key = "synthetic-secret" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":{"message":"rejected synthetic-secret"}}`) + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: key, HTTPClient: srv.Client()}) + _, err := client.Me(context.Background()) + if err == nil || !IsAuthError(err) { + t.Fatalf("expected auth error, got %v", err) + } + if strings.Contains(err.Error(), key) { + t.Fatalf("error leaked key: %v", err) + } +} + +func TestAPIErrorClassificationAndRetryAfter(t *testing.T) { + for _, status := range []int{400, 404, 409, 429, 500} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "7") + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"code":"synthetic","message":"request failed"}`) + })) + defer srv.Close() + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + _, err := client.Me(context.Background()) + if !IsStatus(err, status) { + t.Fatalf("status %d classified as %v", status, err) + } + if status == 429 { + if !IsRateLimit(err) { + t.Fatalf("429 not classified as rate limit: %v", err) + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.RetryAfter != 7*time.Second { + t.Fatalf("RetryAfter = %v, want 7s", apiErr) + } + } + }) + } +} + +func TestCreateAgentRepoAndFollowUpPayloads(t *testing.T) { + var seen []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + seen = append(seen, body) + switch r.URL.Path { + case "/v1/agents": + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "bc-agent", "status": "ACTIVE", + "url": "https://cursor.com/agents/bc-agent", + "latestRunId": "run-one", + }, + "run": map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "CREATING", + }, + }) + case "/v1/agents/bc-agent/runs": + _ = json.NewEncoder(w).Encode(map[string]any{ + "run": map[string]any{ + "id": "run-two", "agentId": "bc-agent", "status": "CREATING", + }, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + created, err := client.CreateAgent(context.Background(), CreateAgentRequest{ + Prompt: Prompt{Text: "fix it"}, + Model: &ModelSelection{ID: "composer-2"}, + Repos: []Repository{{URL: "https://github.com/acme/repo", StartingRef: "main"}}, + AutoCreatePR: true, + }) + if err != nil || created.Agent.ID != "bc-agent" || created.Run.ID != "run-one" { + t.Fatalf("CreateAgent = %+v, %v", created, err) + } + run, err := client.CreateRun(context.Background(), "bc-agent", CreateRunRequest{ + Prompt: Prompt{Text: "add tests"}, Mode: "agent", + }) + if err != nil || run.ID != "run-two" { + t.Fatalf("CreateRun = %+v, %v", run, err) + } + if seen[0]["autoCreatePR"] != true { + t.Fatalf("create payload = %#v", seen[0]) + } +} + +func TestCreateAgentOmitsOptionalFieldsWhenUnset(t *testing.T) { + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&body) + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{"id": "a1", "status": "ACTIVE", "url": "https://cursor.com/agents/a1", "latestRunId": "r1"}, + "run": map[string]any{"id": "r1", "agentId": "a1", "status": "CREATING"}, + }) + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + if _, err := client.CreateAgent(context.Background(), CreateAgentRequest{Prompt: Prompt{Text: "fix it"}}); err != nil { + t.Fatalf("CreateAgent error = %v", err) + } + for _, field := range []string{"repos", "model", "name", "workOnCurrentBranch", "autoCreatePR", "skipReviewerRequest", "mode"} { + if _, ok := body[field]; ok { + t.Fatalf("expected %q omitted, got %#v", field, body[field]) + } + } +} + +func TestGetAgentDecodesFullEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/agents/bc-agent" { + t.Fatalf("method/path = %s %s", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-agent", "name": "fix bug", "status": "FINISHED", + "url": "https://cursor.com/agents/bc-agent", "latestRunId": "run-one", + "git": map[string]any{"branches": []any{ + map[string]any{"repoUrl": "https://github.com/acme/repo", "branch": "cursor/fix-bug"}, + }}, + }) + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + agent, err := client.GetAgent(context.Background(), "bc-agent") + if err != nil || agent.ID != "bc-agent" || agent.Status != "FINISHED" || + agent.Git == nil || len(agent.Git.Branches) != 1 || agent.Git.Branches[0].Branch != "cursor/fix-bug" { + t.Fatalf("GetAgent = %+v, %v", agent, err) + } +} + +func TestGetRunDecodesFullEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/agents/bc-agent/runs/run-one" { + t.Fatalf("method/path = %s %s", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "FINISHED", + "createdAt": "2026-08-12T00:00:00Z", "updatedAt": "2026-08-12T00:05:00Z", + "durationMs": 5000, "result": "done", + }) + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.GetRun(context.Background(), "bc-agent", "run-one") + if err != nil || run.ID != "run-one" || run.Status != "FINISHED" || + run.DurationMS != 5000 || run.Result != "done" { + t.Fatalf("GetRun = %+v, %v", run, err) + } +} + +func TestCancelRunPostsToCancelPath(t *testing.T) { + var gotMethod, gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + if err := client.CancelRun(context.Background(), "bc-agent", "run-one"); err != nil { + t.Fatalf("CancelRun error = %v", err) + } + if gotMethod != http.MethodPost || gotPath != "/v1/agents/bc-agent/runs/run-one/cancel" { + t.Fatalf("method/path = %s %s", gotMethod, gotPath) + } +} + +func TestLifecycleEscapesIDsInRequestPath(t *testing.T) { + var gotURI string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotURI = r.RequestURI + _ = json.NewEncoder(w).Encode(map[string]any{"id": "r", "agentId": "a", "status": "CREATING"}) + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + if _, err := client.GetRun(context.Background(), "a/b", "r/1"); err != nil { + t.Fatalf("GetRun error = %v", err) + } + if want := "/v1/agents/a%2Fb/runs/r%2F1"; gotURI != want { + t.Fatalf("request URI = %q, want %q", gotURI, want) + } +} + +func TestCreateAgentDoesNotRetryOnTransientError(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, `{"code":"unavailable","message":"try again"}`) + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + if _, err := client.CreateAgent(context.Background(), CreateAgentRequest{Prompt: Prompt{Text: "fix it"}}); err == nil { + t.Fatal("expected error") + } + if calls.Load() != 1 { + t.Fatalf("calls = %d, want exactly 1 (CreateAgent must not auto-retry)", calls.Load()) + } +} + +func TestLifecycleValidatesInputsBeforeNetworkIO(t *testing.T) { + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + })) + defer srv.Close() + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + + if _, err := client.CreateAgent(context.Background(), CreateAgentRequest{}); err == nil { + t.Fatal("expected error for empty CreateAgent prompt") + } + if _, err := client.CreateRun(context.Background(), "bc-agent", CreateRunRequest{}); err == nil { + t.Fatal("expected error for empty CreateRun prompt") + } + if _, err := client.CreateRun(context.Background(), "", CreateRunRequest{Prompt: Prompt{Text: "x"}}); err == nil { + t.Fatal("expected error for empty CreateRun agentID") + } + if _, err := client.GetAgent(context.Background(), ""); err == nil { + t.Fatal("expected error for empty GetAgent agentID") + } + if _, err := client.GetRun(context.Background(), "bc-agent", ""); err == nil { + t.Fatal("expected error for empty GetRun runID") + } + if err := client.CancelRun(context.Background(), "bc-agent", ""); err == nil { + t.Fatal("expected error for empty CancelRun runID") + } + if called { + t.Fatal("expected no network calls for invalid input") + } +} + +func TestAPIErrorMessageTruncatedOnRuneBoundary(t *testing.T) { + longMsg := strings.Repeat("a", 239) + "é" + strings.Repeat("é", 10) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + body, _ := json.Marshal(map[string]string{"message": longMsg}) + _, _ = w.Write(body) + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + _, err := client.Me(context.Background()) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !utf8.ValidString(msg) { + t.Fatalf("invalid UTF-8: %q", msg) + } + if got := utf8.RuneCountInString(msg); got != 240 { + t.Fatalf("rune count = %d, want 240", got) + } + want := strings.Repeat("a", 239) + "é" + if msg != want { + t.Fatalf("message = %q, want %q", msg, want) + } +} diff --git a/internal/cursor/live_test.go b/internal/cursor/live_test.go new file mode 100644 index 0000000..8f9b9fd --- /dev/null +++ b/internal/cursor/live_test.go @@ -0,0 +1,36 @@ +package cursor + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestLiveCursorMetadata(t *testing.T) { + key := strings.TrimSpace(os.Getenv("CURSOR_API_KEY")) + if key == "" { + t.Skip("set CURSOR_API_KEY to run Cursor metadata smoke test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + client, err := New(Options{APIKey: key}) + if err != nil { + t.Fatal(err) + } + me, err := client.Me(ctx) + if err != nil { + t.Fatalf("Cursor /v1/me: %v", err) + } + models, err := client.Models(ctx) + if err != nil { + t.Fatalf("Cursor /v1/models: %v", err) + } + if me.APIKeyName == "" || len(models.Items) == 0 { + t.Fatalf("incomplete metadata: me=%+v models=%d", me, len(models.Items)) + } + t.Logf("Cursor key %q exposes %d models", me.APIKeyName, len(models.Items)) +} diff --git a/internal/cursor/stream.go b/internal/cursor/stream.go new file mode 100644 index 0000000..7fdccae --- /dev/null +++ b/internal/cursor/stream.go @@ -0,0 +1,361 @@ +package cursor + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// errStreamDone signals that the SSE stream ended cleanly with a "done" +// event. It is an internal sentinel, never returned to callers of StreamRun. +var errStreamDone = errors.New("Cursor stream done") + +const maxSSELineBytes = 1 << 20 // 1 MiB + +// emitError wraps an error returned by the caller-supplied emit callback so +// StreamRun can recognize it and return immediately without reconnecting. +type emitError struct{ err error } + +func (e *emitError) Error() string { return e.err.Error() } +func (e *emitError) Unwrap() error { return e.err } + +// transportError marks a connection-level failure — a dropped, reset, or +// truncated stream — which StreamRun recovers from by reconnecting with +// Last-Event-ID. Protocol failures (oversized lines, undecodable payloads, +// API errors, emit failures) are never wrapped in it and stay immediate. +type transportError struct{ err error } + +func (e *transportError) Error() string { return "cursor: stream transport: " + e.err.Error() } +func (e *transportError) Unwrap() error { return e.err } + +// sanitizeStreamError holds an in-band SSE error to the same contract as a +// REST failure. Emit failures belong to the caller and pass through +// untouched, so the caller's own error identity survives. +func (c *Client) sanitizeStreamError(err error) error { + var emErr *emitError + if errors.As(err, &emErr) { + return err + } + var apiErr *APIError + if errors.As(err, &apiErr) { + safe := *apiErr + c.sanitizeAPIError(&safe) + return &safe + } + return err +} + +func isRetryableStreamError(err error) bool { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + var transport *transportError + return errors.As(err, &transport) +} + +// parseSSE reads Server-Sent Events from r until EOF, a "done" event, or an +// error. It returns the most recent non-empty event id seen (for use as a +// Last-Event-ID header on reconnect) and, if a "result" event was decoded, +// the terminal Run it describes. +func parseSSE(r io.Reader, emit func(StreamEvent) error) (lastID string, terminal *Run, err error) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), maxSSELineBytes) + + var ( + recID string + recType string + recData []string + ) + + flush := func() error { + if recID == "" && recType == "" && len(recData) == 0 { + return nil + } + if recID != "" { + lastID = recID + } + eventName := recType + if eventName == "" { + eventName = "message" + } + raw := json.RawMessage(strings.Join(recData, "\n")) + out := StreamEvent{ID: recID, Type: eventName, Raw: raw} + + var decodeErr error + switch eventName { + case "assistant", "thinking": + var payload struct { + Text string `json:"text"` + } + decodeErr = json.Unmarshal(raw, &payload) + out.Text = payload.Text + case "status": + var payload struct { + RunID string `json:"runId"` + Status string `json:"status"` + } + decodeErr = json.Unmarshal(raw, &payload) + out.Status = payload.Status + case "tool_call": + var payload struct { + Name string `json:"name"` + Status string `json:"status"` + } + decodeErr = json.Unmarshal(raw, &payload) + out.ToolName, out.Status = payload.Name, payload.Status + case "result": + var payload struct { + RunID string `json:"runId"` + Status string `json:"status"` + Text string `json:"text"` + DurationMS int64 `json:"durationMs"` + Git *GitState `json:"git,omitempty"` + } + decodeErr = json.Unmarshal(raw, &payload) + if decodeErr == nil { + terminal = &Run{ + ID: payload.RunID, + Status: payload.Status, + Result: payload.Text, + DurationMS: payload.DurationMS, + Git: payload.Git, + } + out.Status = payload.Status + out.Text = payload.Text + } + case "error": + var payload struct { + Code string `json:"code"` + Message string `json:"message"` + } + decodeErr = json.Unmarshal(raw, &payload) + if decodeErr == nil { + return &APIError{Code: payload.Code, Message: payload.Message} + } + case "done": + return errStreamDone + case "heartbeat", "interaction_update": + return nil + } + if decodeErr != nil { + return fmt.Errorf("cursor: decode %s event: %w", eventName, decodeErr) + } + if emitErr := emit(out); emitErr != nil { + return &emitError{err: emitErr} + } + return nil + } + + for scanner.Scan() { + line := scanner.Text() + switch { + case line == "": + if ferr := flush(); ferr != nil { + return lastID, terminal, ferr + } + recID, recType, recData = "", "", nil + case strings.HasPrefix(line, ":"): + // SSE comment line, typically used as a keep-alive ping. + case strings.HasPrefix(line, "id:"): + recID = strings.TrimPrefix(strings.TrimPrefix(line, "id:"), " ") + case strings.HasPrefix(line, "event:"): + recType = strings.TrimPrefix(strings.TrimPrefix(line, "event:"), " ") + case strings.HasPrefix(line, "data:"): + recData = append(recData, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + } + } + if serr := scanner.Err(); serr != nil { + // An oversized line is a protocol violation that would recur on every + // reconnect; anything else here is the connection failing under us. + if errors.Is(serr, bufio.ErrTooLong) { + return lastID, terminal, fmt.Errorf("cursor: sse scan: %w", serr) + } + return lastID, terminal, &transportError{err: serr} + } + if recID != "" || recType != "" || len(recData) != 0 { + if ferr := flush(); ferr != nil { + return lastID, terminal, ferr + } + } + return lastID, terminal, nil +} + +// streamOnce opens a single SSE connection for a run and parses it until the +// connection closes, a terminal result arrives, or an error occurs. +// +// done reports whether the connection ended in a way that should stop +// reconnect attempts (a "done" event was observed). terminal is non-nil only +// when a "result" event was decoded. err is nil for a clean disconnect that +// callers should retry (no terminal, not done). +func (c *Client) streamOnce( + ctx context.Context, + agentID, runID, lastID string, + emit func(StreamEvent) error, +) (nextID string, terminal *Run, done bool, err error) { + // Documented endpoint: GET /v1/agents/{id}/runs/{runId}/stream — Cursor + // Cloud Agents API, "Stream A Run" + // (https://cursor.com/docs/cloud-agent/api/endpoints#stream-a-run). + path := "/v1/agents/" + url.PathEscape(agentID) + "/runs/" + url.PathEscape(runID) + "/stream" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return lastID, nil, false, err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "text/event-stream") + if lastID != "" { + req.Header.Set("Last-Event-ID", lastID) + } + + // Cursor streams rely on periodic heartbeats and can legitimately run + // far longer than the client's ordinary metadata/lifecycle timeout; + // lifetime control belongs to ctx instead. + streamHTTP := *c.http + streamHTTP.Timeout = 0 + + resp, err := streamHTTP.Do(req) + if err != nil { + return lastID, nil, false, &transportError{err: err} + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return lastID, nil, false, c.decodeAPIError(resp) + } + + id, terminal, err := parseSSE(resp.Body, emit) + if id != "" { + lastID = id + } + if errors.Is(err, errStreamDone) { + return lastID, terminal, true, nil + } + if err != nil { + // A result decoded on this connection is the run's real outcome; a + // transport failure arriving after it must not discard it. + if terminal != nil && isRetryableStreamError(err) { + return lastID, terminal, false, nil + } + return lastID, terminal, false, c.sanitizeStreamError(err) + } + return lastID, terminal, false, nil +} + +// StreamRun streams a run's events, transparently reconnecting on ordinary +// disconnects while preserving Last-Event-ID. The retry budget applies only +// to consecutive disconnects that make no event-ID progress. +// It returns the run's terminal state once a "result" event is decoded, or +// once the stream ends and GetRun confirms completion. +func (c *Client) StreamRun( + ctx context.Context, + agentID, runID string, + emit func(StreamEvent) error, +) (*Run, error) { + if strings.TrimSpace(agentID) == "" { + return nil, fmt.Errorf("cursor: agentID is required") + } + if strings.TrimSpace(runID) == "" { + return nil, fmt.Errorf("cursor: runID is required") + } + + backoffs := [3]time.Duration{250 * time.Millisecond, 500 * time.Millisecond, 1 * time.Second} + const maxNoProgress = 4 + + var lastID string + var resetUsed bool + noProgress := 0 + var retryDelay time.Duration + + for { + if err := ctx.Err(); err != nil { + return nil, err + } + if retryDelay > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(retryDelay): + } + } + retryDelay = 0 + + previousID := lastID + nextID, terminal, done, err := c.streamOnce(ctx, agentID, runID, lastID, emit) + if nextID != "" { + lastID = nextID + } + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr + } + if IsStatus(err, http.StatusGone) { + return c.GetRun(ctx, agentID, runID) + } + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.Status == http.StatusBadRequest && + apiErr.Code == "invalid_last_event_id" && !resetUsed { + resetUsed = true + lastID = "" + continue + } + var emErr *emitError + if errors.As(err, &emErr) { + return nil, emErr.err + } + if !isRetryableStreamError(err) { + return nil, err + } + // A dropped connection is an ordinary disconnect: reuse the + // bounded reconnect accounting below instead of failing the run. + } + if terminal != nil { + return terminal, nil + } + if done { + // Stream ended with "done" but no "result" event; confirm the + // final state via the metadata API instead of guessing. + return c.GetRun(ctx, agentID, runID) + } + + if nextID != "" && nextID != previousID { + noProgress = 0 + retryDelay = backoffs[0] + continue + } + + noProgress++ + if noProgress >= maxNoProgress { + current, err := c.GetRun(ctx, agentID, runID) + if err != nil { + return nil, err + } + if current != nil && isTerminalRunStatus(current.Status) { + return current, nil + } + // A live run is not a successful stream result. Keep reconnecting + // with capped backoff until the stream advances or ctx expires. + noProgress = 0 + retryDelay = backoffs[len(backoffs)-1] + continue + } + retryDelay = backoffs[min(noProgress-1, len(backoffs)-1)] + } +} + +// isTerminalRunStatus reports whether a run has stopped for good. Cursor +// computes a run's duration once it reaches any of these states — Cloud +// Agents API (https://cursor.com/docs/cloud-agent/api/endpoints) — so an +// EXPIRED run will never produce further stream events. +func isTerminalRunStatus(status string) bool { + switch strings.ToUpper(strings.TrimSpace(status)) { + case "FINISHED", "ERROR", "CANCELLED", "EXPIRED": + return true + default: + return false + } +} diff --git a/internal/cursor/stream_test.go b/internal/cursor/stream_test.go new file mode 100644 index 0000000..8dbfbb7 --- /dev/null +++ b/internal/cursor/stream_test.go @@ -0,0 +1,625 @@ +package cursor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + "unicode/utf8" +) + +func TestStreamRunReconnectsFromLastEventID(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + switch calls.Add(1) { + case 1: + _, _ = io.WriteString(w, "id: evt-1\nevent: assistant\ndata: {\"text\":\"hello\"}\n\n") + default: + if got := r.Header.Get("Last-Event-ID"); got != "evt-1" { + t.Fatalf("Last-Event-ID = %q", got) + } + _, _ = io.WriteString(w, + "id: evt-2\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-3\nevent: done\ndata: {}\n\n") + } + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + var events []StreamEvent + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { + events = append(events, e) + return nil + }) + if err != nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } + if len(events) != 2 { + t.Fatalf("events = %#v", events) + } +} + +func TestStreamRunReconnectsBeyondAttemptBudgetWhenEachDisconnectAdvancesEventID(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + attempt := calls.Add(1) + if attempt > 1 { + want := fmt.Sprintf("evt-%d", attempt-1) + if got := r.Header.Get("Last-Event-ID"); got != want { + t.Errorf("attempt %d Last-Event-ID = %q, want %q", attempt, got, want) + } + } + if attempt <= 5 { + _, _ = fmt.Fprintf(w, + "id: evt-%d\nevent: assistant\ndata: {\"text\":\"progress\"}\n\n", + attempt, + ) + return + } + _, _ = io.WriteString(w, + "id: evt-6\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-7\nevent: done\ndata: {}\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + var events []StreamEvent + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(event StreamEvent) error { + events = append(events, event) + return nil + }) + if err != nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } + if calls.Load() != 6 { + t.Fatalf("stream calls = %d, want 6", calls.Load()) + } + if len(events) != 6 { + t.Fatalf("events = %d, want five progress events and one result", len(events)) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// truncatedBody serves fixed SSE bytes and then fails, standing in for a +// connection dropped mid-stream. +type truncatedBody struct { + data []byte + err error + off int +} + +func (b *truncatedBody) Read(p []byte) (int, error) { + if b.off < len(b.data) { + n := copy(p, b.data[b.off:]) + b.off += n + return n, nil + } + return 0, b.err +} + +func (b *truncatedBody) Close() error { return nil } + +func truncatedStreamClient(t *testing.T, respond func(attempt int32, r *http.Request) (string, error)) (*Client, *atomic.Int32) { + t.Helper() + var calls atomic.Int32 + client, err := New(Options{ + BaseURL: "https://api.cursor.invalid", + APIKey: "synthetic-key", + HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + body, readErr := respond(calls.Add(1), r) + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &truncatedBody{data: []byte(body), err: readErr}, + }, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + return client, &calls +} + +func TestStreamRunReconnectsAfterConnectionResetWithLastEventID(t *testing.T) { + reset := &net.OpError{Op: "read", Net: "tcp", Err: errors.New("connection reset by peer")} + client, calls := truncatedStreamClient(t, func(attempt int32, r *http.Request) (string, error) { + if attempt == 1 { + return "id: evt-1\nevent: assistant\ndata: {\"text\":\"hello\"}\n\n", reset + } + if got := r.Header.Get("Last-Event-ID"); got != "evt-1" { + t.Errorf("reconnect Last-Event-ID = %q, want evt-1", got) + } + return "id: evt-2\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n" + + "id: evt-3\nevent: done\ndata: {}\n\n", io.EOF + }) + + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(StreamEvent) error { return nil }) + if err != nil || run == nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRun = %+v, %v; want reconnect after a connection reset", run, err) + } + if calls.Load() != 2 { + t.Fatalf("stream calls = %d, want 2", calls.Load()) + } +} + +func TestStreamRunTerminalResultWinsOverLaterReadError(t *testing.T) { + client, calls := truncatedStreamClient(t, func(int32, *http.Request) (string, error) { + return "id: evt-1\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n", + io.ErrUnexpectedEOF + }) + + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(StreamEvent) error { return nil }) + if err != nil || run == nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRun = %+v, %v; want the decoded result to outrank the read error", run, err) + } + if calls.Load() != 1 { + t.Fatalf("stream calls = %d, want 1", calls.Load()) + } +} + +// The same recovery must hold over a real connection, not just an injected +// read error: an aborted response truncates the chunked body mid-stream. +func TestStreamRunReconnectsAfterTruncatedResponse(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + if calls.Add(1) == 1 { + _, _ = io.WriteString(w, "id: evt-1\nevent: assistant\ndata: {\"text\":\"hello\"}\n\n") + w.(http.Flusher).Flush() + panic(http.ErrAbortHandler) + } + if got := r.Header.Get("Last-Event-ID"); got != "evt-1" { + t.Errorf("reconnect Last-Event-ID = %q, want evt-1", got) + } + _, _ = io.WriteString(w, + "id: evt-2\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-3\nevent: done\ndata: {}\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(StreamEvent) error { return nil }) + if err != nil || run == nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRun = %+v, %v; want reconnect after a truncated response", run, err) + } + if calls.Load() != 2 { + t.Fatalf("stream calls = %d, want 2", calls.Load()) + } +} + +func TestStreamRunDoesNotRetryInvalidPayload(t *testing.T) { + client, calls := truncatedStreamClient(t, func(int32, *http.Request) (string, error) { + return "id: evt-1\nevent: result\ndata: {\"runId\":\n\n", io.EOF + }) + + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(StreamEvent) error { return nil }) + if err == nil { + t.Fatalf("StreamRun = %+v, nil; want an immediate decode error", run) + } + if calls.Load() != 1 { + t.Fatalf("stream calls = %d, want 1 (invalid payloads are not retried)", calls.Load()) + } +} + +// Reconnects after read failures reuse the bounded no-progress budget rather +// than looping until the context expires. +func TestStreamRunTruncatedReadsRespectNoProgressBudget(t *testing.T) { + var streamCalls, statusCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/stream"): + streamCalls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + w.(http.Flusher).Flush() + panic(http.ErrAbortHandler) + case r.URL.Path == "/v1/agents/bc-agent/runs/run-one": + statusCalls.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "FINISHED", "result": "done via status", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(StreamEvent) error { return nil }) + if err != nil || run == nil || run.Result != "done via status" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } + if streamCalls.Load() != 4 || statusCalls.Load() != 1 { + t.Fatalf("stream calls = %d, status calls = %d; want 4 and 1", streamCalls.Load(), statusCalls.Load()) + } +} + +func TestStreamRunNoProgressCapFallsBackToTerminalRun(t *testing.T) { + var streamCalls, statusCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/stream"): + streamCalls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + case r.URL.Path == "/v1/agents/bc-agent/runs/run-one": + statusCalls.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "FINISHED", "result": "done via status", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(StreamEvent) error { + return nil + }) + if err != nil || run.Status != "FINISHED" || run.Result != "done via status" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } + if streamCalls.Load() != 4 || statusCalls.Load() != 1 { + t.Fatalf("stream calls = %d, status calls = %d; want 4 and 1", streamCalls.Load(), statusCalls.Load()) + } +} + +func TestStreamRunNoProgressFallbackDoesNotReturnActiveRun(t *testing.T) { + var streamCalls, statusCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/stream"): + streamCalls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + case r.URL.Path == "/v1/agents/bc-agent/runs/run-one": + statusCalls.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "RUNNING", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRun(ctx, "bc-agent", "run-one", func(StreamEvent) error { + return nil + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("StreamRun = %+v, %v; want context deadline after active fallback", run, err) + } + if statusCalls.Load() == 0 { + t.Fatal("no-progress cap never checked run status") + } + if got := streamCalls.Load(); got < 4 || got > 6 { + t.Fatalf("stream calls = %d, want bounded reconnects after fallback", got) + } + if got := statusCalls.Load(); got != 1 { + t.Fatalf("status calls = %d, want one bounded fallback check", got) + } +} + +// Cursor computes durationMs "once the run reaches FINISHED, ERROR, +// CANCELLED, or EXPIRED" — Cloud Agents API, "Get A Run" +// (https://cursor.com/docs/cloud-agent/api/endpoints). +func TestIsTerminalRunStatusCoversEveryCursorTerminalState(t *testing.T) { + for _, tc := range []struct { + status string + want bool + }{ + {status: "FINISHED", want: true}, + {status: "ERROR", want: true}, + {status: "CANCELLED", want: true}, + {status: "EXPIRED", want: true}, + {status: " expired ", want: true}, + {status: "RUNNING"}, + {status: "CREATING"}, + {status: "PENDING"}, + {status: ""}, + } { + if got := isTerminalRunStatus(tc.status); got != tc.want { + t.Errorf("isTerminalRunStatus(%q) = %v, want %v", tc.status, got, tc.want) + } + } +} + +func TestStreamRunNoProgressFallbackReturnsExpiredRun(t *testing.T) { + var statusCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/stream"): + w.Header().Set("Content-Type", "text/event-stream") + case r.URL.Path == "/v1/agents/bc-agent/runs/run-one": + statusCalls.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "EXPIRED", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRun(ctx, "bc-agent", "run-one", func(StreamEvent) error { return nil }) + if err != nil || run == nil || run.Status != "EXPIRED" { + t.Fatalf("StreamRun = %+v, %v; want the EXPIRED run returned instead of reconnecting", run, err) + } + if statusCalls.Load() != 1 { + t.Fatalf("status calls = %d, want one bounded fallback check", statusCalls.Load()) + } +} + +func TestStreamRunUsesDocumentedStreamEndpoint(t *testing.T) { + var gotMethod, gotURI string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotURI = r.Method, r.RequestURI + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, + "id: evt-1\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-2\nevent: done\ndata: {}\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + if _, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { return nil }); err != nil { + t.Fatalf("StreamRun error = %v", err) + } + // Cursor Cloud Agents API, "Stream A Run": + // https://cursor.com/docs/cloud-agent/api/endpoints#stream-a-run + if gotMethod != http.MethodGet { + t.Fatalf("method = %q, want GET", gotMethod) + } + if want := "/v1/agents/bc-agent/runs/run-one/stream"; gotURI != want { + t.Fatalf("request URI = %q, want %q", gotURI, want) + } +} + +func TestStreamRunParsesMultilineDataToolCallAndIgnoresHeartbeat(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, + ": ping\n\n"+ + "event: heartbeat\ndata: {}\n\n"+ + "id: evt-1\nevent: tool_call\ndata: {\"name\":\"grep\",\"status\":\"running\"}\n\n"+ + "id: evt-2\nevent: assistant\ndata: {\"text\":\ndata: \"hello world\"}\n\n"+ + "id: evt-3\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"ok\"}\n\n"+ + "id: evt-4\nevent: done\ndata: {}\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + var events []StreamEvent + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { + events = append(events, e) + return nil + }) + if err != nil || run.Status != "FINISHED" || run.Result != "ok" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } + if len(events) != 3 { + t.Fatalf("events = %#v, want 3 (heartbeat and done must be ignored)", events) + } + if events[0].Type != "tool_call" || events[0].ToolName != "grep" || events[0].Status != "running" { + t.Fatalf("tool_call event = %+v", events[0]) + } + if events[1].Type != "assistant" || events[1].Text != "hello world" { + t.Fatalf("assistant event (multiline data) = %+v", events[1]) + } +} + +// The tool layer redacts again, but the client contract must on its own keep +// the configured key out of stream errors and keep them bounded. +func TestStreamRunSanitizesInBandSSEError(t *testing.T) { + const key = "synthetic-key" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "id: evt-1\nevent: error\ndata: {\"code\":\"rejected "+key+ + "\",\"message\":\"upstream refused "+key+" \xff\xfe "+strings.Repeat("padding ", 400)+"\"}\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: key, HTTPClient: srv.Client()}) + _, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(StreamEvent) error { return nil }) + if err == nil { + t.Fatal("StreamRun accepted an SSE error event") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("err = %v (%T), want *APIError", err, err) + } + if strings.Contains(apiErr.Message, key) || strings.Contains(apiErr.Code, key) || + strings.Contains(err.Error(), key) { + t.Fatalf("stream error leaked the API key: code=%q message=%q", apiErr.Code, apiErr.Message) + } + if got := utf8.RuneCountInString(apiErr.Message); got > 240 { + t.Fatalf("stream error message = %d runes, want the bounded API-error policy", got) + } + if !utf8.ValidString(apiErr.Message) || !utf8.ValidString(apiErr.Code) { + t.Fatalf("stream error was not normalized to valid UTF-8: code=%q message=%q", apiErr.Code, apiErr.Message) + } +} + +func TestStreamRunContextCancellationReturnsImmediately(t *testing.T) { + block := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.(http.Flusher).Flush() + <-block + })) + defer func() { + close(block) + srv.Close() + }() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + start := time.Now() + _, err := client.StreamRun(ctx, "bc-agent", "run-one", func(e StreamEvent) error { return nil }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("StreamRun took too long to honor cancellation: %v", elapsed) + } +} + +func TestStreamRunOversizedLineReturnsExplicitError(t *testing.T) { + huge := strings.Repeat("a", 2<<20) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "event: assistant\ndata: "+huge+"\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + _, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { return nil }) + if err == nil { + t.Fatal("expected explicit error for oversized SSE line, got nil (possible silent partial success)") + } +} + +func TestStreamRun410FallsBackToGetRun(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/stream"): + w.WriteHeader(http.StatusGone) + _ = json.NewEncoder(w).Encode(map[string]any{"code": "stream_expired", "message": "stream expired"}) + case r.URL.Path == "/v1/agents/bc-agent/runs/run-one": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "FINISHED", "result": "done", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { return nil }) + if err != nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } +} + +func TestStreamRunEndsWithDoneButNoResultFallsBackToGetRun(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/stream"): + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "id: evt-1\nevent: done\ndata: {}\n\n") + case r.URL.Path == "/v1/agents/bc-agent/runs/run-one": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "FINISHED", "result": "done via fallback", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { return nil }) + if err != nil || run.Result != "done via fallback" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } +} + +func TestStreamRunResetsOnceAfterInvalidLastEventID(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + switch calls.Add(1) { + case 1: + _, _ = io.WriteString(w, "id: evt-1\nevent: assistant\ndata: {\"text\":\"hi\"}\n\n") + case 2: + if got := r.Header.Get("Last-Event-ID"); got != "evt-1" { + t.Fatalf("Last-Event-ID = %q, want evt-1", got) + } + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"code": "invalid_last_event_id", "message": "unknown event id"}) + case 3: + if got := r.Header.Get("Last-Event-ID"); got != "" { + t.Fatalf("Last-Event-ID = %q, want reset to empty", got) + } + _, _ = io.WriteString(w, + "id: evt-2\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-3\nevent: done\ndata: {}\n\n") + default: + t.Fatalf("unexpected extra call %d", calls.Load()) + } + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { return nil }) + if err != nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } + if calls.Load() != 3 { + t.Fatalf("calls = %d, want 3", calls.Load()) + } +} + +func TestStreamRunReturnsEmitErrorImmediately(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "id: evt-1\nevent: assistant\ndata: {\"text\":\"hi\"}\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + wantErr := errors.New("synthetic emit failure") + _, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { + return wantErr + }) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } + if calls.Load() != 1 { + t.Fatalf("calls = %d, want 1 (no retry after emit error)", calls.Load()) + } +} + +func TestStreamRunIgnoresClientTimeoutDuringStream(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "id: evt-1\nevent: assistant\ndata: {\"text\":\"hi\"}\n\n") + w.(http.Flusher).Flush() + time.Sleep(120 * time.Millisecond) + _, _ = io.WriteString(w, + "id: evt-2\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-3\nevent: done\ndata: {}\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: &http.Client{Timeout: 50 * time.Millisecond}}) + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { return nil }) + if err != nil || run.Status != "FINISHED" { + t.Fatalf("StreamRun = %+v, %v (client timeout should not apply mid-stream)", run, err) + } +} diff --git a/internal/cursor/types.go b/internal/cursor/types.go new file mode 100644 index 0000000..cf2c688 --- /dev/null +++ b/internal/cursor/types.go @@ -0,0 +1,128 @@ +package cursor + +import "encoding/json" + +type Me struct { + APIKeyName string `json:"apiKeyName"` + CreatedAt string `json:"createdAt"` + UserID int64 `json:"userId,omitempty"` + UserEmail string `json:"userEmail,omitempty"` + UserFirstName string `json:"userFirstName,omitempty"` + UserLastName string `json:"userLastName,omitempty"` +} + +type ModelParameterValue struct { + Value string `json:"value"` + DisplayName string `json:"displayName,omitempty"` +} + +type ModelParameter struct { + ID string `json:"id"` + DisplayName string `json:"displayName,omitempty"` + Values []ModelParameterValue `json:"values"` +} + +type ModelParameterSelection struct { + ID string `json:"id"` + Value string `json:"value"` +} + +type ModelVariant struct { + Params []ModelParameterSelection `json:"params"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + IsDefault bool `json:"isDefault,omitempty"` +} + +type Model struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Description string `json:"description,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Parameters []ModelParameter `json:"parameters,omitempty"` + Variants []ModelVariant `json:"variants,omitempty"` +} + +type ModelCatalog struct { + Items []Model `json:"items"` +} + +type StreamEvent struct { + ID string + Type string + Status string + Text string + ToolName string + Raw json.RawMessage +} + +type Prompt struct { + Text string `json:"text"` +} + +type ModelSelection struct { + ID string `json:"id"` + Params []ModelParameterSelection `json:"params,omitempty"` +} + +type Repository struct { + URL string `json:"url"` + StartingRef string `json:"startingRef,omitempty"` + PRURL string `json:"prUrl,omitempty"` +} + +type CreateAgentRequest struct { + Prompt Prompt `json:"prompt"` + Model *ModelSelection `json:"model,omitempty"` + Name string `json:"name,omitempty"` + Repos []Repository `json:"repos,omitempty"` + WorkOnCurrentBranch bool `json:"workOnCurrentBranch,omitempty"` + AutoCreatePR bool `json:"autoCreatePR,omitempty"` + SkipReviewerRequest bool `json:"skipReviewerRequest,omitempty"` + Mode string `json:"mode,omitempty"` +} + +type CreateRunRequest struct { + Prompt Prompt `json:"prompt"` + Mode string `json:"mode,omitempty"` +} + +type GitBranch struct { + RepoURL string `json:"repoUrl"` + Branch string `json:"branch,omitempty"` + PRURL string `json:"prUrl,omitempty"` +} + +type GitState struct { + Branches []GitBranch `json:"branches"` +} + +type Agent struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + URL string `json:"url"` + LatestRunID string `json:"latestRunId"` + Git *GitState `json:"git,omitempty"` + Repos []Repository `json:"repos,omitempty"` +} + +type Run struct { + ID string `json:"id"` + AgentID string `json:"agentId"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + DurationMS int64 `json:"durationMs,omitempty"` + Result string `json:"result,omitempty"` + Git *GitState `json:"git,omitempty"` +} + +type CreateAgentResponse struct { + Agent Agent `json:"agent"` + Run Run `json:"run"` +} + +type CreateRunResponse struct { + Run Run `json:"run"` +} diff --git a/internal/llm/client.go b/internal/llm/client.go index d750fba..e83ecc7 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -61,6 +61,9 @@ func New(o Options) (Client, error) { o.HTTPClient = &http.Client{Timeout: o.Timeout} } o.BaseURL = strings.TrimRight(strings.TrimSpace(o.BaseURL), "/") + if strings.EqualFold(strings.TrimSpace(o.Kind), "cursor-agent") { + return nil, errors.New("cursor-agent is an agent integration; use the cursor_agent tool") + } base, err := newBase(o) if err != nil { diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index 457f3a7..df9f06b 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -6,6 +6,7 @@ import ( "net" "net/http" "net/http/httptest" + "strings" "syscall" "testing" "time" @@ -147,3 +148,16 @@ func TestStreamingTimeoutTracksInactivityInsteadOfTotalDuration(t *testing.T) { t.Fatalf("text = %q, want %q", text, "onetwothreefour") } } + +func TestNewRejectsCursorAgentKind(t *testing.T) { + _, err := New(Options{Kind: "cursor-agent", BaseURL: "https://api.cursor.com"}) + if err == nil || !strings.Contains(err.Error(), "cursor_agent") { + t.Fatalf("cursor-agent error = %v", err) + } +} + +func TestNewAllowsUnknownProviderKinds(t *testing.T) { + if _, err := New(Options{Kind: "future-compatible", BaseURL: "https://example.invalid/v1"}); err != nil { + t.Fatalf("unknown provider kind rejected: %v", err) + } +} diff --git a/internal/providers/catalog.go b/internal/providers/catalog.go index b2ff6bf..aa6053b 100644 --- a/internal/providers/catalog.go +++ b/internal/providers/catalog.go @@ -5,6 +5,7 @@ package providers import ( "os" + "strings" "github.com/enowdev/antares/internal/config" ) @@ -20,6 +21,34 @@ type Info struct { Models []string } +type Capability string + +const ( + CapabilityLLM Capability = "llm" + CapabilityAgent Capability = "agent" +) + +func CapabilityForKind(kind string) Capability { + if strings.EqualFold(strings.TrimSpace(kind), "cursor-agent") { + return CapabilityAgent + } + return CapabilityLLM +} + +func (i Info) Capability() Capability { return CapabilityForKind(i.Kind) } + +func CapabilityOf(cfg *config.Config, id string) Capability { + if info, ok := For(id); ok { + return info.Capability() + } + if cfg != nil { + if p, ok := cfg.Providers[id]; ok { + return CapabilityForKind(p.Kind) + } + } + return CapabilityLLM +} + // contextWindows records the true context window (in tokens) for models whose // provider API does not report one, keyed by model id. The agent consults this // when a config has no explicit model_meta, so the context gauge and compaction @@ -58,6 +87,8 @@ var catalog = []Info{ []string{"glm-5.2", "kimi-k3", "deepseek-v4-pro", "minimax-m3", "qwen3.8-max"}}, {"ollama", "Ollama (local)", "openai-compatible", "", "http://localhost:11434/v1", false, []string{"llama3.1", "qwen2.5"}}, + {"cursor", "Cursor Cloud Agents", "cursor-agent", "CURSOR_API_KEY", + "https://api.cursor.com", true, nil}, } // Catalog returns the well-known providers, in display order. @@ -96,15 +127,15 @@ func Connected(cfg *config.Config, id string) bool { return false } -// Activate records credentials (when a key is given), makes the provider the -// active one, and points the default model at it when the current one doesn't -// belong to it. It mutates cfg but does not persist — the caller saves. -func Activate(cfg *config.Config, id, key string) { +// Connect records credentials (when a key is given) and configures a provider. +// It mutates cfg but does not persist — the caller saves. +func Connect(cfg *config.Config, id, key string) (Info, bool) { if cfg.Providers == nil { cfg.Providers = map[string]config.Provider{} } + info, known := For(id) p := cfg.Providers[id] - if info, ok := For(id); ok { + if known { if p.Kind == "" { p.Kind = info.Kind } @@ -139,11 +170,23 @@ func Activate(cfg *config.Config, id, key string) { } p.Enabled = true cfg.Providers[id] = p + return info, known +} +// Activate records credentials (when a key is given), makes an LLM provider the +// active one, and points the default model at it when the current one doesn't +// belong to it. It mutates cfg but does not persist — the caller saves. +func Activate(cfg *config.Config, id, key string) bool { + _, _ = Connect(cfg, id, key) + if CapabilityOf(cfg, id) == CapabilityAgent { + return false + } cfg.Model.Provider = id + p := cfg.Providers[id] if !contains(p.Models, cfg.Model.Default) && len(p.Models) > 0 { cfg.Model.Default = p.Models[0] } + return true } func contains(list []string, s string) bool { diff --git a/internal/providers/catalog_test.go b/internal/providers/catalog_test.go new file mode 100644 index 0000000..a19ccf2 --- /dev/null +++ b/internal/providers/catalog_test.go @@ -0,0 +1,39 @@ +package providers + +import ( + "testing" + + "github.com/enowdev/antares/internal/config" +) + +func TestCursorConnectDoesNotChangeActiveModel(t *testing.T) { + cfg := config.Default() + beforeProvider, beforeModel := cfg.Model.Provider, cfg.Model.Default + + info, known := Connect(cfg, "cursor", "synthetic-key") + if !known || info.Capability() != CapabilityAgent { + t.Fatalf("cursor info = %+v, known=%v", info, known) + } + if activated := Activate(cfg, "cursor", ""); activated { + t.Fatal("agent provider was activated as an LLM") + } + if cfg.Model.Provider != beforeProvider || cfg.Model.Default != beforeModel { + t.Fatalf("model changed to %s/%s", cfg.Model.Provider, cfg.Model.Default) + } + if p := cfg.Providers["cursor"]; !p.Enabled || p.APIKey != "synthetic-key" { + t.Fatalf("cursor provider not connected: %+v", p) + } +} + +func TestDefaultCursorProviderUsesEnvironmentKey(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + t.Setenv("CURSOR_API_KEY", "synthetic-env-key") + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + _, p := cfg.ResolveProvider("cursor") + if !p.Enabled || p.APIKey != "synthetic-env-key" || p.Kind != "cursor-agent" { + t.Fatalf("cursor provider = %+v", p) + } +} diff --git a/internal/server/cursor_provider_test.go b/internal/server/cursor_provider_test.go new file mode 100644 index 0000000..d18b9ac --- /dev/null +++ b/internal/server/cursor_provider_test.go @@ -0,0 +1,706 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" +) + +// trackingIPResolver counts LookupIP calls so tests can prove a handler +// actually routed hostname resolution through the injected server resolver, +// rather than bypassing it (e.g. via net.DefaultResolver or an IP literal +// that skips resolution entirely). +type trackingIPResolver struct { + providerIPResolver + calls int +} + +func (r *trackingIPResolver) LookupIP( + ctx context.Context, network, host string, +) ([]net.IP, error) { + r.calls++ + return r.providerIPResolver.LookupIP(ctx, network, host) +} + +// hermeticCursorBaseURL is a syntactically valid, public, non-loopback HTTPS +// URL whose host is an IP literal. validateProviderBaseURL parses an IP +// literal directly (net.ParseIP) instead of resolving it via DNS, so tests +// that pass this as base_url never touch the network or depend on the +// resolver — the injected cursorFactory below handles all "connection" +// behavior. Real requests never leave the process either way: production +// code only calls Me/Models through the (fake, in tests) metadata client, and +// never opens a socket to this address. +const hermeticCursorBaseURL = "https://8.8.8.8" + +// fakeCursorMetadata is the injectable metadata-client double used across +// these tests. Both calls return the same err, mirroring the brief's shape. +type fakeCursorMetadata struct { + me cursor.Me + models cursor.ModelCatalog + err error +} + +func (f *fakeCursorMetadata) Me(context.Context) (*cursor.Me, error) { + if f.err != nil { + return nil, f.err + } + return &f.me, nil +} + +func (f *fakeCursorMetadata) Models(context.Context) (*cursor.ModelCatalog, error) { + if f.err != nil { + return nil, f.err + } + return &f.models, nil +} + +// fakeCursorMetadataSplit lets Me and Models fail independently, so a test can +// pin down the "only save after both calls succeed" guarantee. +type fakeCursorMetadataSplit struct { + me cursor.Me + meErr error + models cursor.ModelCatalog + modelsErr error + meCalls int + modelCalls int +} + +func (f *fakeCursorMetadataSplit) Me(context.Context) (*cursor.Me, error) { + f.meCalls++ + if f.meErr != nil { + return nil, f.meErr + } + return &f.me, nil +} + +func (f *fakeCursorMetadataSplit) Models(context.Context) (*cursor.ModelCatalog, error) { + f.modelCalls++ + if f.modelsErr != nil { + return nil, f.modelsErr + } + return &f.models, nil +} + +// newCursorTestServer seeds an isolated ANTARES_HOME, saves and reloads cfg +// (so env-derived provider credentials are merged the way production does), +// and returns a Server wired for handler-level tests. +func newCursorTestServer(t *testing.T, seed func(*config.Config)) *Server { + t.Helper() + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + cfg.Server.AuthToken = "test-token" + cfg.Server.DashboardPasswordHash = "test-hash" + if seed != nil { + seed(cfg) + } + if err := config.SaveAt(config.ConfigFile(), cfg); err != nil { + t.Fatalf("seed config: %v", err) + } + reloaded, err := config.Reload() + if err != nil { + t.Fatalf("reload config: %v", err) + } + s := &Server{cfg: reloaded, agent: &agent.Agent{}} + s.agent.SetConfig(reloaded) + s.reloadFn = func() error { return nil } + return s +} + +// TestConnectCursorPreservesActiveModel guards the primary model boundary: +// connecting Cursor must never touch cfg.Model, even on success. +func TestConnectCursorPreservesActiveModel(t *testing.T) { + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + cfg.Server.AuthToken = "test-token" + cfg.Server.DashboardPasswordHash = "test-hash" + cfg.Model.Provider = "openrouter" + cfg.Model.Default = "openai/gpt-5" + if err := config.SaveAt(config.ConfigFile(), cfg); err != nil { + t.Fatal(err) + } + + s := &Server{cfg: cfg, agent: &agent.Agent{}} + s.agent.SetConfig(cfg) + s.cursorFactory = func(cursor.Options) (cursorMetadataClient, error) { + return &fakeCursorMetadata{ + me: cursor.Me{APIKeyName: "test"}, + models: cursor.ModelCatalog{Items: []cursor.Model{{ID: "composer-2"}}}, + }, nil + } + s.reloadFn = func() error { return nil } + resolver := &trackingIPResolver{ + providerIPResolver: dns64Resolver( + t, "api.cursor.com", net.ParseIP("54.158.233.194")), + } + s.providerResolver = resolver + + req := httptest.NewRequest(http.MethodPost, "/api/providers/cursor/key", + strings.NewReader(`{"api_key":"synthetic-key"}`)) + req.SetPathValue("id", "cursor") + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.handleSetProviderKey(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if resolver.calls == 0 { + t.Fatal("Cursor connection bypassed the server provider resolver") + } + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if saved.Model.Provider != "openrouter" || saved.Model.Default != "openai/gpt-5" { + t.Fatalf("active model changed: %+v", saved.Model) + } + if saved.Providers["cursor"].APIKey != "synthetic-key" { + t.Fatalf("cursor credential was not saved: %+v", saved.Providers["cursor"]) + } +} + +// TestSetupStatusOmitsCursorCapability keeps first-run onboarding limited to +// chat-model providers: Cursor must never appear in the setup picker. +func TestSetupStatusOmitsCursorCapability(t *testing.T) { + s := newCursorTestServer(t, nil) + r := httptest.NewRequest(http.MethodGet, "/api/setup/status", nil) + rec := httptest.NewRecorder() + s.handleSetupStatus(rec, r) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + var body struct { + Providers []setupProvider `json:"providers"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + for _, p := range body.Providers { + if p.ID == "cursor" || p.Capability == "agent" { + t.Fatalf("setup status exposed an agent-capability provider: %+v", p) + } + } +} + +func TestSetupProviderCatalogueDoesNotResolveAbsentProviderThroughLegacyModel(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + t.Setenv("ANTARES_API_KEY", "synthetic-legacy-key") + t.Setenv("ANTARES_BASE_URL", "https://legacy.example/v1") + t.Setenv("CURSOR_API_KEY", "synthetic-cursor-key") + + cfg, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if _, ok := cfg.Providers["copilot"]; ok { + t.Fatal("test requires copilot to be absent from cfg.Providers") + } + + var copilot, cursorProvider *setupProvider + catalogue := setupProviderCatalogue(cfg) + for i := range catalogue { + switch catalogue[i].ID { + case "copilot": + copilot = &catalogue[i] + case "cursor": + cursorProvider = &catalogue[i] + } + } + if copilot == nil || cursorProvider == nil { + t.Fatalf("catalogue missing providers: copilot=%v cursor=%v", copilot != nil, cursorProvider != nil) + } + if copilot.HasKey { + t.Fatal("absent copilot provider inherited the legacy ANTARES_API_KEY") + } + if copilot.BaseURL != "" { + t.Fatalf("absent copilot base URL = %q, want catalogue default", copilot.BaseURL) + } + if !cursorProvider.HasKey { + t.Fatal("configured default cursor provider did not resolve CURSOR_API_KEY") + } + if cursorProvider.BaseURL != "https://api.cursor.com" { + t.Fatalf("cursor base URL = %q, want configured default", cursorProvider.BaseURL) + } +} + +// TestSetupCompleteRejectsCursorProvider guards onboarding: the initial setup +// flow must never be able to activate an agent-capability provider. +func TestSetupCompleteRejectsCursorProvider(t *testing.T) { + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + if err := config.SaveAt(config.ConfigFile(), cfg); err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg, agent: &agent.Agent{}} + s.agent.SetConfig(cfg) + s.reloadFn = func() error { return nil } + + body := `{"provider":"cursor","model":"composer-2","api_key":"synthetic-key"}` + r := httptest.NewRequest(http.MethodPost, "/api/setup/complete", strings.NewReader(body)) + r.RemoteAddr = "127.0.0.1:1234" + rec := httptest.NewRecorder() + s.handleSetupComplete(rec, r) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } + + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if saved.Model.Provider == "cursor" { + t.Fatal("setup complete activated the cursor provider") + } + if saved.Providers["cursor"].APIKey == "synthetic-key" { + t.Fatal("setup complete stored a cursor credential") + } +} + +// TestModelOptionsReportsCursorAgentCapabilityAndEnvKey covers resolved +// environment credentials and the capability field surfaced to the dashboard. +func TestModelOptionsReportsCursorAgentCapabilityAndEnvKey(t *testing.T) { + t.Setenv("CURSOR_API_KEY", "env-cursor-key") + s := newCursorTestServer(t, nil) + + r := httptest.NewRequest(http.MethodGet, "/api/model/options", nil) + rec := httptest.NewRecorder() + s.handleModelOptions(rec, r) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + var body struct { + Providers []struct { + ID string `json:"id"` + Capability string `json:"capability"` + HasKey bool `json:"has_key"` + } `json:"providers"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + found := false + for _, p := range body.Providers { + if p.ID != "cursor" { + continue + } + found = true + if p.Capability != "agent" { + t.Fatalf("cursor capability = %q, want agent", p.Capability) + } + if !p.HasKey { + t.Fatal("cursor has_key = false despite CURSOR_API_KEY being set") + } + } + if !found { + t.Fatal("cursor provider missing from /api/model/options") + } +} + +// TestProviderModelsReturnsCursorCatalog covers the provider-specific model +// endpoint's response shape (ids + display names). +func TestProviderModelsReturnsCursorCatalog(t *testing.T) { + s := newCursorTestServer(t, func(cfg *config.Config) { + p := cfg.Providers["cursor"] + p.APIKey = "synthetic-key" + cfg.Providers["cursor"] = p + }) + s.cursorFactory = func(cursor.Options) (cursorMetadataClient, error) { + return &fakeCursorMetadata{ + models: cursor.ModelCatalog{Items: []cursor.Model{ + {ID: "composer-2", DisplayName: "Composer 2"}, + }}, + }, nil + } + + r := httptest.NewRequest(http.MethodGet, "/api/providers/cursor/models", nil) + r.Header.Set("Authorization", "Bearer test-token") + r.SetPathValue("id", "cursor") + rec := httptest.NewRecorder() + s.handleProviderModels(rec, r) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + var body struct { + Models []struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Parameters []string `json:"parameters"` + } `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Models) != 1 || body.Models[0].ID != "composer-2" || body.Models[0].Name != "Composer 2" { + t.Fatalf("unexpected models: %+v", body.Models) + } +} + +// TestProviderModelsNeedsKeyWithoutNetworkCall covers the "no resolved key -> +// no network access" guarantee. +func TestCursorProviderModelsNeedsKeyWithoutNetworkCall(t *testing.T) { + s := newCursorTestServer(t, func(cfg *config.Config) { + p := cfg.Providers["cursor"] + p.APIKey = "" + p.APIKeyEnv = "" + cfg.Providers["cursor"] = p + }) + called := false + s.cursorFactory = func(cursor.Options) (cursorMetadataClient, error) { + called = true + return &fakeCursorMetadata{}, nil + } + + r := httptest.NewRequest(http.MethodGet, "/api/providers/cursor/models", nil) + r.Header.Set("Authorization", "Bearer test-token") + r.SetPathValue("id", "cursor") + rec := httptest.NewRecorder() + s.handleProviderModels(rec, r) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if called { + t.Fatal("handleProviderModels reached the network without a resolved key") + } + + var body struct { + NeedsKey bool `json:"needs_key"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !body.NeedsKey { + t.Fatal("needs_key = false with no resolved credential") + } +} + +// TestModelListAllExcludesCursor guards model isolation: list-all must never +// call or include Cursor, even when it has a usable (env) credential. +func TestModelListAllExcludesCursor(t *testing.T) { + t.Setenv("CURSOR_API_KEY", "env-cursor-key") + s := newCursorTestServer(t, nil) + + r := httptest.NewRequest(http.MethodGet, "/api/model/list-all", nil) + r.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.handleModelListAll(rec, r) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), `"provider":"cursor"`) { + t.Fatalf("list-all touched the cursor provider: %s", rec.Body.String()) + } +} + +// TestSetProviderKeyCursorAuthErrorDoesNotLeakKey guards secret-safety: an +// auth rejection must never echo the supplied key back to the caller, and the +// key must not be persisted. +func TestSetProviderKeyCursorAuthErrorDoesNotLeakKey(t *testing.T) { + s := newCursorTestServer(t, nil) + secret := "super-secret-key-value" + s.cursorFactory = func(o cursor.Options) (cursorMetadataClient, error) { + if o.APIKey != secret { + t.Fatalf("factory received unexpected api key: %q", o.APIKey) + } + return &fakeCursorMetadata{err: &cursor.APIError{ + Status: http.StatusUnauthorized, Message: "unauthorized", + }}, nil + } + + body := `{"api_key":"` + secret + `","base_url":"` + hermeticCursorBaseURL + `"}` + r := httptest.NewRequest(http.MethodPost, "/api/providers/cursor/key", strings.NewReader(body)) + r.SetPathValue("id", "cursor") + r.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.handleSetProviderKey(rec, r) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + var respBody struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &respBody); err != nil { + t.Fatal(err) + } + if respBody.OK { + t.Fatal("expected ok=false for an auth error") + } + if strings.Contains(rec.Body.String(), secret) { + t.Fatalf("response leaked the supplied api key: %s", rec.Body.String()) + } + + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if saved.Providers["cursor"].APIKey == secret { + t.Fatal("rejected credential was saved") + } +} + +// TestSetProviderKeyCursorTransportErrorMapsTo502 covers the transport / +// invalid-response mapping distinct from the auth-rejection 200/ok:false path. +func TestSetProviderKeyCursorTransportErrorMapsTo502(t *testing.T) { + s := newCursorTestServer(t, nil) + s.cursorFactory = func(cursor.Options) (cursorMetadataClient, error) { + return &fakeCursorMetadata{err: errors.New("connection reset")}, nil + } + + r := httptest.NewRequest(http.MethodPost, "/api/providers/cursor/key", + strings.NewReader(`{"api_key":"synthetic-key","base_url":"`+hermeticCursorBaseURL+`"}`)) + r.SetPathValue("id", "cursor") + r.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.handleSetProviderKey(rec, r) + if rec.Code != http.StatusBadGateway { + t.Fatalf("status=%d body=%s, want 502", rec.Code, rec.Body.String()) + } +} + +// TestSetProviderKeyCursorSavesOnlyAfterBothCallsSucceed pins the +// verifyCursorProvider contract: a catalog fetch failure after a successful +// identity check must not persist the credential. +func TestSetProviderKeyCursorSavesOnlyAfterBothCallsSucceed(t *testing.T) { + s := newCursorTestServer(t, nil) + fake := &fakeCursorMetadataSplit{modelsErr: errors.New("catalog unavailable")} + s.cursorFactory = func(cursor.Options) (cursorMetadataClient, error) { + return fake, nil + } + + r := httptest.NewRequest(http.MethodPost, "/api/providers/cursor/key", + strings.NewReader(`{"api_key":"synthetic-key","base_url":"`+hermeticCursorBaseURL+`"}`)) + r.SetPathValue("id", "cursor") + r.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.handleSetProviderKey(rec, r) + if rec.Code == http.StatusOK { + var respBody struct { + OK bool `json:"ok"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &respBody); err != nil { + t.Fatal(err) + } + if respBody.OK { + t.Fatal("provider reported ok=true despite a failed model-catalog fetch") + } + } + if fake.meCalls == 0 { + t.Fatal("Me was never called") + } + if fake.modelCalls == 0 { + t.Fatal("Models was never called") + } + + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if saved.Providers["cursor"].APIKey == "synthetic-key" { + t.Fatal("credential was saved despite the models call failing") + } +} + +// ---- Fix round 1: additional isolation guards ------------------------------ + +// TestModelSetRejectsCursorProvider guards /api/model/set: an agent +// integration (Cursor) can never become the active chat model, in memory or +// on disk, regardless of which config value (model or provider) triggers it. +func TestModelSetRejectsCursorProvider(t *testing.T) { + s := newCursorTestServer(t, func(cfg *config.Config) { + cfg.Model.Provider = "openrouter" + cfg.Model.Default = "openai/gpt-5" + }) + + r := httptest.NewRequest(http.MethodPost, "/api/model/set", + strings.NewReader(`{"model":"composer-2","provider":"cursor"}`)) + r.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.handleModelSet(rec, r) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } + + // Both the in-memory pointer and the on-disk file must be untouched. + memCfg := s.config() + if memCfg.Model.Provider != "openrouter" || memCfg.Model.Default != "openai/gpt-5" { + t.Fatalf("in-memory config mutated: %+v", memCfg.Model) + } + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if saved.Model.Provider != "openrouter" || saved.Model.Default != "openai/gpt-5" { + t.Fatalf("on-disk config mutated: %+v", saved.Model) + } +} + +// TestSetupTestRejectsCursorProvider guards POST /api/setup/test: it must +// fail before the generic llm.New call, with an actionable message pointing +// at the dedicated Cursor connection flow rather than llm.New's own +// "cursor-agent is an agent integration" guard text. +func TestSetupTestRejectsCursorProvider(t *testing.T) { + home := t.TempDir() + t.Setenv("ANTARES_HOME", home) + cfg := config.Default() + if err := config.SaveAt(config.ConfigFile(), cfg); err != nil { + t.Fatal(err) + } + s := &Server{cfg: cfg} + + body := `{"provider":"cursor","api_key":"synthetic-key"}` + r := httptest.NewRequest(http.MethodPost, "/api/setup/test", strings.NewReader(body)) + r.RemoteAddr = "127.0.0.1:1234" + rec := httptest.NewRecorder() + s.handleSetupTest(rec, r) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "/api/providers/cursor/key") { + t.Fatalf("response missing actionable pointer to the Cursor connection flow: %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "cursor_agent tool") { + t.Fatal("handleSetupTest reached the generic llm.New guard instead of failing earlier") + } +} + +// TestModelListRejectsCursorProvider guards GET /api/model/list: it must fail +// before the generic agent.Models -> llm.New path, even when Cursor has a +// resolved key (which would otherwise pass the existing needs_key check and +// reach the generic path). +func TestModelListRejectsCursorProvider(t *testing.T) { + s := newCursorTestServer(t, func(cfg *config.Config) { + p := cfg.Providers["cursor"] + p.APIKey = "synthetic-key" + cfg.Providers["cursor"] = p + }) + + r := httptest.NewRequest(http.MethodGet, "/api/model/list?provider=cursor", nil) + rec := httptest.NewRecorder() + s.handleModelList(rec, r) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + var body struct { + Models []any `json:"models"` + Capability string `json:"capability"` + Error string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Capability != "agent" { + t.Fatalf("capability = %q, want agent (body=%s)", body.Capability, rec.Body.String()) + } + if len(body.Models) != 0 { + t.Fatalf("models = %+v, want empty", body.Models) + } + if !strings.Contains(body.Error, "/api/providers/cursor/models") { + t.Fatalf("error = %q, missing actionable pointer", body.Error) + } + if strings.Contains(rec.Body.String(), "cursor_agent tool") { + t.Fatal("handleModelList reached the generic agent.Models -> llm.New path") + } +} + +// TestProviderModelInfoSkipsCursorProvider guards GET +// /api/providers/{id}/model-info: it must fail before agent.Models. A curated +// Models whitelist proves this deterministically — if the generic path ran, +// agent.Models would return the whitelist entry (no live call needed) and +// this handler would report found:true; the capability guard must prevent +// that regardless of the whitelist's contents. +func TestProviderModelInfoSkipsCursorProvider(t *testing.T) { + s := newCursorTestServer(t, func(cfg *config.Config) { + p := cfg.Providers["cursor"] + p.APIKey = "synthetic-key" + p.Models = []string{"composer-2"} + cfg.Providers["cursor"] = p + }) + + r := httptest.NewRequest(http.MethodGet, "/api/providers/cursor/model-info?id=composer-2", nil) + r.SetPathValue("id", "cursor") + rec := httptest.NewRecorder() + s.handleProviderModelInfo(rec, r) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + var body struct { + Found bool `json:"found"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Found { + t.Fatal("handleProviderModelInfo reached the generic agent.Models path (curated whitelist matched)") + } +} + +// TestAddProviderModelRejectsCursorProvider guards POST +// /api/providers/{id}/model: Cursor has no manual model whitelist to append +// to — its catalogue is discovered live via /api/providers/{id}/models. +func TestAddProviderModelRejectsCursorProvider(t *testing.T) { + s := newCursorTestServer(t, nil) + + r := httptest.NewRequest(http.MethodPost, "/api/providers/cursor/model", + strings.NewReader(`{"model":"composer-2"}`)) + r.SetPathValue("id", "cursor") + r.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.handleAddProviderModel(rec, r) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } + + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if len(saved.Providers["cursor"].Models) != 0 { + t.Fatalf("cursor gained a manual model entry: %+v", saved.Providers["cursor"].Models) + } +} + +// TestDeleteProviderModelRejectsCursorProvider mirrors +// TestAddProviderModelRejectsCursorProvider for the delete path. +func TestDeleteProviderModelRejectsCursorProvider(t *testing.T) { + s := newCursorTestServer(t, func(cfg *config.Config) { + p := cfg.Providers["cursor"] + p.Models = []string{"composer-2"} + cfg.Providers["cursor"] = p + }) + + r := httptest.NewRequest(http.MethodDelete, "/api/providers/cursor/model/composer-2", nil) + r.SetPathValue("id", "cursor") + r.SetPathValue("model", "composer-2") + r.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.handleDeleteProviderModel(rec, r) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } + + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if len(saved.Providers["cursor"].Models) != 1 { + t.Fatalf("cursor's manual model entry was mutated despite the guard: %+v", saved.Providers["cursor"].Models) + } +} diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index 072231e..70935bf 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -2,6 +2,7 @@ package server import ( "errors" + "fmt" "log/slog" "net/http" "os" @@ -11,6 +12,7 @@ import ( "github.com/enowdev/antares/internal/config" "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/providers" "github.com/enowdev/antares/internal/tools" ) @@ -165,21 +167,25 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { NeedsAPIVersion bool `json:"needs_api_version,omitempty"` NeedsBaseURL bool `json:"needs_base_url,omitempty"` TimeoutSecs int `json:"timeout_seconds,omitempty"` + // Capability distinguishes chat-model providers ("llm") from agent + // integrations ("agent", e.g. Cursor) so the dashboard can route them + // to their own connection flow instead of the active-model picker. + Capability string `json:"capability"` } // Every provider from the catalogue (configured or not), so the new kinds // can be set up from here — then any custom providers only in the config. seen := map[string]bool{} - providers := make([]providerInfo, 0) + providerList := make([]providerInfo, 0) for _, sp := range setupProviderCatalogue(cfg) { p := cfg.Providers[sp.ID] - providers = append(providers, providerInfo{ + providerList = append(providerList, providerInfo{ ID: sp.ID, Label: sp.Label, Kind: sp.Kind, Enabled: p.Enabled, HasKey: p.APIKey != "", Local: sp.Local, BaseURL: firstNonEmpty(p.BaseURL, sp.BaseURL), Active: sp.ID == cfg.Model.Provider, Hint: sp.Hint, KeyHint: sp.KeyHint, KeyURL: sp.KeyURL, KeyLabel: sp.KeyLabel, Note: sp.Note, NeedsRegion: sp.NeedsRegion, NeedsAPIVersion: sp.NeedsAPIVersion, - NeedsBaseURL: sp.NeedsBaseURL, TimeoutSecs: p.TimeoutSecs, + NeedsBaseURL: sp.NeedsBaseURL, TimeoutSecs: p.TimeoutSecs, Capability: sp.Capability, }) seen[sp.ID] = true } @@ -192,25 +198,38 @@ func (s *Server) handleModelOptions(w http.ResponseWriter, r *http.Request) { sort.Strings(names) for _, name := range names { p := cfg.Providers[name] - providers = append(providers, providerInfo{ + providerList = append(providerList, providerInfo{ ID: name, Label: firstNonEmpty(p.Label, name), Kind: p.Kind, Enabled: p.Enabled, HasKey: p.APIKey != "", Local: isLocalEndpoint(p.BaseURL), BaseURL: p.BaseURL, Active: name == cfg.Model.Provider, TimeoutSecs: p.TimeoutSecs, + Capability: string(providers.CapabilityForKind(p.Kind)), }) } writeJSON(w, http.StatusOK, map[string]any{ "active": map[string]string{"model": cfg.Model.Default, "provider": cfg.Model.Provider}, - "providers": providers, + "providers": providerList, }) } func (s *Server) handleModelList(w http.ResponseWriter, r *http.Request) { provider := r.URL.Query().Get("provider") + cfg := s.config() // Calling a provider we know has no credential just turns a known state // into an opaque 401. Report the missing key instead. - id, p := s.config().ResolveProvider(provider) + id, p := cfg.ResolveProvider(provider) + // Agent integrations (Cursor) are not chat-model providers: fail before + // the generic agent.Models -> llm.New path, and point the caller at the + // dedicated discovery endpoint instead of a 401/500 from the guard below. + if providers.CapabilityOf(cfg, id) == providers.CapabilityAgent { + writeJSON(w, http.StatusOK, map[string]any{ + "models": []any{}, "provider": id, "capability": "agent", + "error": fmt.Sprintf( + "%s is an agent integration; browse its models via GET /api/providers/%s/models.", id, id), + }) + return + } if p.APIKey == "" && !isLocalEndpoint(p.BaseURL) { writeJSON(w, http.StatusOK, map[string]any{ "models": []any{}, "needs_key": true, "provider": id, @@ -252,10 +271,17 @@ func (s *Server) handleModelListAll(w http.ResponseWriter, r *http.Request) { } var targets []target seen := map[string]bool{} - add := func(id, label string) { + add := func(id, label, kind string) { if seen[id] { return } + // Agent integrations (Cursor) are never aggregated here, even when + // keyed via the environment: this endpoint feeds the active-model + // picker, and an agent capability cannot be the active chat model. + if providers.CapabilityForKind(kind) == providers.CapabilityAgent { + seen[id] = true + return + } p := cfg.Providers[id] keyed := p.APIKey != "" || (p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "") if keyed || isLocalEndpoint(p.BaseURL) { @@ -264,10 +290,10 @@ func (s *Server) handleModelListAll(w http.ResponseWriter, r *http.Request) { } } for _, sp := range setupProviderCatalogue(cfg) { - add(sp.ID, sp.Label) + add(sp.ID, sp.Label, sp.Kind) } for name := range cfg.Providers { - add(name, cfg.Providers[name].Label) + add(name, cfg.Providers[name].Label, cfg.Providers[name].Kind) } type row struct { @@ -343,6 +369,17 @@ func (s *Server) handleModelSet(w http.ResponseWriter, r *http.Request) { return } prevProvider := cfg.Model.Provider + resultProvider := prevProvider + if body.Provider != "" { + resultProvider = body.Provider + } + // An agent integration (Cursor) can never become the active chat model — + // checked before any mutation, memory swap, or disk write below. + if providers.CapabilityOf(cfg, resultProvider) == providers.CapabilityAgent { + writeError(w, http.StatusBadRequest, + fmt.Errorf("%q is an agent integration and cannot be the active model", resultProvider)) + return + } cfg.Model.Default = body.Model if body.Provider != "" { cfg.Model.Provider = body.Provider diff --git a/internal/server/handlers_providers.go b/internal/server/handlers_providers.go index 8e9c55c..a289680 100644 --- a/internal/server/handlers_providers.go +++ b/internal/server/handlers_providers.go @@ -1,11 +1,15 @@ package server import ( + "context" "errors" + "fmt" "net/http" "strings" + "time" "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" "github.com/enowdev/antares/internal/providers" ) @@ -20,6 +24,13 @@ func (s *Server) handleProviderModelInfo(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusBadRequest, errors.New("a model id is required")) return } + // Agent integrations (Cursor) are not chat-model providers: fail before + // the generic agent.Models -> llm.New path. This handler's contract is a + // silent fallback (found:false), so no network call is needed either way. + if providers.CapabilityOf(s.config(), id) == providers.CapabilityAgent { + writeJSON(w, http.StatusOK, map[string]any{"found": false}) + return + } models, err := s.agent.Models(r.Context(), id) if err != nil { // Fetch failed — not fatal, the UI falls back to manual entry. @@ -39,6 +50,67 @@ func (s *Server) handleProviderModelInfo(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]any{"found": false}) } +// handleProviderModels returns the live model catalogue for an +// agent-capability provider (Cursor). It never touches cfg.Model and never +// aggregates into /api/model/list-all — that isolation is what lets Cursor +// carry its own model picker without disturbing the active chat model. +func (s *Server) handleProviderModels(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + id := r.PathValue("id") + cfg := s.config() + if providers.CapabilityOf(cfg, id) != providers.CapabilityAgent { + writeError(w, http.StatusBadRequest, + errors.New("this provider does not expose a dedicated model endpoint")) + return + } + + _, p := cfg.ResolveProvider(id) + key := strings.TrimSpace(p.APIKey) + if key == "" { + // No resolved credential: report the need without making a network call. + writeJSON(w, http.StatusOK, map[string]any{"models": []any{}, "needs_key": true}) + return + } + + client, err := s.newCursorMetadataClient(cursor.Options{BaseURL: p.BaseURL, APIKey: key}) + if err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + catalog, err := client.Models(ctx) + if err != nil { + if cursor.IsAuthError(err) { + writeJSON(w, http.StatusOK, map[string]any{"models": []any{}, "error": err.Error()}) + return + } + writeError(w, http.StatusBadGateway, err) + return + } + + type modelOut struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Parameters []cursor.ModelParameter `json:"parameters"` + } + out := make([]modelOut, 0, len(catalog.Items)) + for _, m := range catalog.Items { + params := m.Parameters + if params == nil { + params = []cursor.ModelParameter{} + } + out = append(out, modelOut{ + ID: m.ID, Name: m.DisplayName, Description: m.Description, Parameters: params, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"models": out}) +} + // handleContextWindow reports the active model's token budget, so the composer's // context gauge can show "0 / " before the first turn (usage events // carry the window once a turn runs, but not before one has). Mirrors the @@ -92,6 +164,15 @@ func (s *Server) handleAddProviderModel(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusInternalServerError, err) return } + // Agent integrations (Cursor) do not curate a manual model whitelist — + // their catalogue is discovered live via GET /api/providers/{id}/models. + // Reject before any config mutation, matching the same boundary as + // /api/model/set and /api/model/list. + if providers.CapabilityOf(cfg, id) == providers.CapabilityAgent { + writeError(w, http.StatusBadRequest, fmt.Errorf( + "%s is an agent integration; its models are discovered via GET /api/providers/%s/models", id, id)) + return + } if cfg.Providers == nil { cfg.Providers = map[string]config.Provider{} } @@ -139,6 +220,13 @@ func (s *Server) handleDeleteProviderModel(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusInternalServerError, err) return } + // Same boundary as handleAddProviderModel: Cursor has no manual model + // whitelist to delete from. + if providers.CapabilityOf(cfg, id) == providers.CapabilityAgent { + writeError(w, http.StatusBadRequest, fmt.Errorf( + "%s is an agent integration; its models are discovered via GET /api/providers/%s/models", id, id)) + return + } p := cfg.Providers[id] out := p.Models[:0] for _, m := range p.Models { @@ -194,7 +282,7 @@ func (s *Server) handleProviderSettings(w http.ResponseWriter, r *http.Request) } } if baseURL != "" { - if err := validateProviderBaseURL(r.Context(), baseURL, allowLocal); err != nil { + if err := s.validateProviderBaseURL(r.Context(), baseURL, allowLocal); err != nil { writeError(w, http.StatusBadRequest, err) return } diff --git a/internal/server/handlers_setup.go b/internal/server/handlers_setup.go index cdd29fd..cf03002 100644 --- a/internal/server/handlers_setup.go +++ b/internal/server/handlers_setup.go @@ -11,6 +11,7 @@ import ( "time" "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" "github.com/enowdev/antares/internal/llm" "github.com/enowdev/antares/internal/store" ) @@ -29,6 +30,11 @@ type setupProvider struct { Local bool `json:"local"` Models []string `json:"models,omitempty"` HasKey bool `json:"has_key"` + // Capability distinguishes chat-model providers ("llm") from agent + // integrations ("agent", e.g. Cursor). Only "llm" providers are eligible + // for initial onboarding and the active model — see setupProviderCatalogue, + // handleSetupStatus, and handleSetupComplete. + Capability string `json:"capability"` // KeyLabel overrides the "API key" label (e.g. "Service account JSON"). KeyLabel string `json:"key_label,omitempty"` // Note is an extra line shown under the form (e.g. how creds are supplied). @@ -137,12 +143,28 @@ func setupProviderCatalogue(cfg *config.Config) []setupProvider { ID: "custom", Label: "Something else", Kind: "openai-compatible", Hint: "Any OpenAI-compatible endpoint.", }, + { + ID: "cursor", Label: "Cursor Cloud Agents", Kind: "cursor-agent", + Capability: "agent", + Hint: "Delegate coding tasks to durable Cursor Cloud Agents.", + KeyHint: "crsr_…", KeyURL: "https://cursor.com/dashboard/api", + BaseURL: "https://api.cursor.com", + Note: "This deployment key and Cursor quota are shared by users allowed to invoke Cursor tools.", + }, } for i := range out { - if p, ok := cfg.Providers[out[i].ID]; ok { - out[i].HasKey = p.APIKey != "" - if p.BaseURL != "" { - out[i].BaseURL = p.BaseURL + if out[i].Capability == "" { + out[i].Capability = "llm" + } + // Resolve only configured providers. ResolveProvider intentionally + // treats an unknown name as the legacy inline model provider, which + // would otherwise make absent catalogue entries inherit ANTARES_API_KEY + // and ANTARES_BASE_URL. + if _, configured := cfg.Providers[out[i].ID]; configured { + _, resolved := cfg.ResolveProvider(out[i].ID) + out[i].HasKey = strings.TrimSpace(resolved.APIKey) != "" + if resolved.BaseURL != "" { + out[i].BaseURL = resolved.BaseURL } } } @@ -161,6 +183,17 @@ func NeedsSetup(cfg *config.Config) bool { func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { cfg := s.config() home, _ := os.UserHomeDir() + // Onboarding only ever picks a chat-model provider: agent integrations + // (Cursor) are connected later, from Settings, and must never appear in + // the first-run picker. + catalogue := setupProviderCatalogue(cfg) + visible := make([]setupProvider, 0, len(catalogue)) + for _, p := range catalogue { + if p.Capability == "agent" { + continue + } + visible = append(visible, p) + } writeJSON(w, http.StatusOK, map[string]any{ "needs_setup": NeedsSetup(cfg), "model": cfg.Model.Default, @@ -168,7 +201,7 @@ func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { "workspace": cfg.Agent.Workspace, "home": home, "config_path": configPath(), - "providers": setupProviderCatalogue(cfg), + "providers": visible, "database": cfg.Database.Driver, }) } @@ -202,10 +235,19 @@ func (s *Server) handleSetupTest(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, errors.New("unknown provider")) return } + // Agent integrations (Cursor) are not chat-model providers: fail before + // the generic llm.New call below, and point at the dedicated flow rather + // than the setup wizard's "test connection" step. + if chosen.Capability == "agent" { + writeError(w, http.StatusBadRequest, fmt.Errorf( + "%s is an agent integration; connect it via POST /api/providers/%s/key and browse its models via GET /api/providers/%s/models", + chosen.ID, chosen.ID, chosen.ID)) + return + } baseURL := firstNonEmpty(body.BaseURL, chosen.BaseURL) if baseURL != "" { - if err := validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { + if err := s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -327,10 +369,18 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, errors.New("unknown provider")) return } + // Agent integrations (Cursor) are not chat-model providers: initial setup + // must pick an active model, which an agent capability cannot serve. This + // check runs before any config mutation below. + if chosen.Capability == "agent" { + writeError(w, http.StatusBadRequest, + errors.New("this provider is an agent integration and cannot be used for initial setup")) + return + } baseURL := firstNonEmpty(body.BaseURL, chosen.BaseURL) if baseURL != "" { - if err := validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { + if err := s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -439,6 +489,25 @@ func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) { }) } +// verifyCursorProvider checks a Cursor credential the same way Settings +// verifies any other provider: confirm identity, then fetch the model +// catalogue. Both must succeed before the caller persists anything. +func (s *Server) verifyCursorProvider( + ctx context.Context, + baseURL, apiKey string, +) (*cursor.ModelCatalog, error) { + client, err := s.newCursorMetadataClient(cursor.Options{ + BaseURL: baseURL, APIKey: apiKey, + }) + if err != nil { + return nil, err + } + if _, err := client.Me(ctx); err != nil { + return nil, err + } + return client.Models(ctx) +} + // handleSetProviderKey verifies a credential and stores it in one step, so a // provider can be connected from wherever the user noticed it was missing // rather than sending them to hunt through Settings. @@ -486,7 +555,7 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { } baseURL := firstNonEmpty(body.BaseURL, entry.BaseURL, chosen.BaseURL) if baseURL != "" { - if err := validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { + if err := s.validateProviderBaseURL(r.Context(), baseURL, chosen.Local); err != nil { writeError(w, http.StatusBadRequest, err) return } @@ -500,6 +569,45 @@ func (s *Server) handleSetProviderKey(w http.ResponseWriter, r *http.Request) { return } + // Cursor is an agent integration, not a chat-model provider: verify it + // through the metadata client rather than the generic llm.New path (which + // deliberately refuses "cursor-agent" — see llm.New), and never touch + // cfg.Model on success. + if chosen.Capability == "agent" { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + catalog, err := s.verifyCursorProvider(ctx, baseURL, key) + if err != nil { + if cursor.IsAuthError(err) { + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()}) + return + } + writeJSON(w, http.StatusBadGateway, map[string]any{ + "ok": false, "error": "The provider could not be reached or returned an invalid response: " + err.Error(), + }) + return + } + + entry.APIKey = key + entry.BaseURL = baseURL + entry.Enabled = true + if entry.Label == "" { + entry.Label = chosen.Label + } + cfg.Providers[id] = entry + + if err := config.Save(cfg); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if err := s.applyReload(); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": len(catalog.Items)}) + return + } + // Reject a bad key here rather than saving it and failing on the next turn. client, err := llm.New(llm.Options{ Kind: entry.Kind, BaseURL: baseURL, APIKey: key, diff --git a/internal/server/routes.go b/internal/server/routes.go index ca156e4..9b728dc 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -91,6 +91,7 @@ func (s *Server) routes() { m.HandleFunc("GET /api/model/list-all", s.handleModelListAll) m.HandleFunc("POST /api/model/set", s.handleModelSet) m.HandleFunc("POST /api/providers/{id}/key", s.handleSetProviderKey) + m.HandleFunc("GET /api/providers/{id}/models", s.handleProviderModels) m.HandleFunc("GET /api/providers/{id}/model-info", s.handleProviderModelInfo) m.HandleFunc("POST /api/providers/{id}/model", s.handleAddProviderModel) m.HandleFunc("DELETE /api/providers/{id}/model/{model}", s.handleDeleteProviderModel) diff --git a/internal/server/security.go b/internal/server/security.go index c9a804a..eb0de8d 100644 --- a/internal/server/security.go +++ b/internal/server/security.go @@ -101,6 +101,58 @@ func (s *Server) requireSetupAccess(w http.ResponseWriter, r *http.Request) bool // loopback endpoint; custom/provider URLs are not allowed to resolve into // private, link-local, metadata, multicast, or otherwise non-public ranges. func validateProviderBaseURL(ctx context.Context, raw string, allowLocal bool) error { + return validateProviderBaseURLWithResolver(ctx, raw, allowLocal, net.DefaultResolver) +} + +// validateProviderBaseURL validates a provider's base_url using the server's +// injected resolver when tests set one, or net.DefaultResolver otherwise. +// Production request handlers must call this method (not the package-level +// function) so DNS64 discovery and hostname resolution stay hermetically +// testable end to end. +func (s *Server) validateProviderBaseURL(ctx context.Context, raw string, allowLocal bool) error { + resolver := s.providerResolver + if resolver == nil { + resolver = net.DefaultResolver + } + return validateProviderBaseURLWithResolver(ctx, raw, allowLocal, resolver) +} + +func providerIPError(ip net.IP) error { + return fmt.Errorf("provider base_url resolves to a non-public address (%s)", ip.String()) +} + +// dns64AddressMatches reports whether ip is a synthesized NAT64 address (per +// one of the discovered prefixes) whose embedded IPv4 is itself public and +// was also observed as one of the host's plain A records. This is the only +// way a blocked (non-public per providerIPBlocked) IPv6 literal is accepted: +// it must decode, under a locally discovered RFC 6052 prefix, to an IPv4 +// address that is both public and independently confirmed by the same +// lookup — never trusting the embedded IPv4 alone. +func dns64AddressMatches(ip net.IP, prefixes []nat64Prefix, publicV4 map[string]struct{}) bool { + for _, prefix := range prefixes { + if !prefixMatches(ip, prefix.network, prefix.bits) { + continue + } + embedded, ok := extractRFC6052IPv4(ip, prefix.bits) + if !ok || providerIPBlocked(embedded) { + continue + } + if _, ok := publicV4[embedded.String()]; ok { + return true + } + } + return false +} + +// validateProviderBaseURLWithResolver is validateProviderBaseURL with an +// injectable resolver, so tests can exercise DNS64/NAT64 behavior +// hermetically. Blocked IPv4 addresses fail immediately. A blocked IPv6 +// address is accepted only when it decodes under a prefix discovered via +// ipv4only.arpa to a public IPv4 that was also returned as a plain A record +// for the same host; discovery failure is fail-closed. +func validateProviderBaseURLWithResolver( + ctx context.Context, raw string, allowLocal bool, resolver providerIPResolver, +) error { raw = strings.TrimSpace(raw) if raw == "" { return errors.New("provider base_url is required") @@ -117,28 +169,49 @@ func validateProviderBaseURL(ctx context.Context, raw string, allowLocal bool) e } host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".") - check := func(ip net.IP) error { + if ip := net.ParseIP(host); ip != nil { if providerIPBlocked(ip) && !(allowLocal && ip.IsLoopback()) { - return fmt.Errorf("provider base_url resolves to a non-public address (%s)", ip.String()) + return providerIPError(ip) } return nil } - if ip := net.ParseIP(host); ip != nil { - return check(ip) - } lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() - ips, err := net.DefaultResolver.LookupIP(lookupCtx, "ip", host) + ips, err := resolver.LookupIP(lookupCtx, "ip", host) if err != nil { return fmt.Errorf("provider host cannot be resolved: %w", err) } if len(ips) == 0 { return errors.New("provider host has no address") } + + publicV4 := map[string]struct{}{} + var blockedV6 []net.IP for _, ip := range ips { - if err := check(ip); err != nil { - return err + blocked := providerIPBlocked(ip) && !(allowLocal && ip.IsLoopback()) + if !blocked { + if v4 := ip.To4(); v4 != nil && !providerIPBlocked(v4) { + publicV4[v4.String()] = struct{}{} + } + continue + } + if ip.To4() != nil { + return providerIPError(ip) + } + blockedV6 = append(blockedV6, append(net.IP(nil), ip...)) + } + if len(blockedV6) == 0 { + return nil + } + + prefixes, err := discoverNAT64Prefixes(lookupCtx, resolver) + if err != nil { + return providerIPError(blockedV6[0]) + } + for _, ip := range blockedV6 { + if !dns64AddressMatches(ip, prefixes, publicV4) { + return providerIPError(ip) } } return nil @@ -161,3 +234,125 @@ func providerIPBlocked(ip net.IP) bool { } return false } + +var rfc6052PrefixLengths = [...]int{32, 40, 48, 56, 64, 96} + +func validRFC6052PrefixLength(bits int) bool { + for _, candidate := range rfc6052PrefixLengths { + if bits == candidate { + return true + } + } + return false +} + +func extractRFC6052IPv4(ip net.IP, prefixBits int) (net.IP, bool) { + v6 := ip.To16() + if v6 == nil || ip.To4() != nil || !validRFC6052PrefixLength(prefixBits) { + return nil, false + } + // RFC 6052 reserves bits 64-71 as the zero-valued "u" octet. + if v6[8] != 0 { + return nil, false + } + if prefixBits == 96 { + return net.IPv4(v6[12], v6[13], v6[14], v6[15]), true + } + compact := make([]byte, 15) + copy(compact[:8], v6[:8]) + copy(compact[8:], v6[9:]) + offset := prefixBits / 8 + return net.IPv4( + compact[offset], + compact[offset+1], + compact[offset+2], + compact[offset+3], + ), true +} + +type providerIPResolver interface { + LookupIP(context.Context, string, string) ([]net.IP, error) +} + +type nat64Prefix struct { + network net.IP + bits int +} + +func prefixMatches(ip, network net.IP, bits int) bool { + left, right := ip.To16(), network.To16() + if left == nil || right == nil { + return false + } + mask := net.CIDRMask(bits, 128) + return left.Mask(mask).Equal(right.Mask(mask)) +} + +func isIPv4OnlyWKA(ip net.IP) bool { + return ip.Equal(net.IPv4(192, 0, 0, 170)) || ip.Equal(net.IPv4(192, 0, 0, 171)) +} + +// nat64Candidate records the evidence an ipv4only.arpa answer gives for one +// prefix: which well-known IPv4 addresses were embedded there, and whether +// some answer placed a well-known address there and nowhere else. +type nat64Candidate struct { + prefix nat64Prefix + wka map[string]bool + sole bool +} + +// discoverNAT64Prefixes learns the NAT64 prefixes in use from ipv4only.arpa, +// keeping them in the order the resolver returned (RFC 7050, Section 3). +// +// A well-known IPv4 address can sit at more than one RFC 6052 placement of +// the same answer when the prefix itself repeats those octets. RFC 7050 +// requires the value to be present only once and, when it is not, to repeat +// the search with the other well-known address: only a placement both +// 192.0.0.170 and 192.0.0.171 agree on survives. Candidates that neither +// test resolves are dropped, because a spurious shorter prefix would widen +// the network that validation is willing to accept. +func discoverNAT64Prefixes(ctx context.Context, resolver providerIPResolver) ([]nat64Prefix, error) { + ips, err := resolver.LookupIP(ctx, "ip6", "ipv4only.arpa") + if err != nil { + return nil, err + } + candidates := map[string]*nat64Candidate{} + var order []string + for _, ip := range ips { + var placements []*nat64Candidate + for _, bits := range rfc6052PrefixLengths { + embedded, ok := extractRFC6052IPv4(ip, bits) + if !ok || !isIPv4OnlyWKA(embedded) { + continue + } + mask := net.CIDRMask(bits, 128) + network := append(net.IP(nil), ip.To16().Mask(mask)...) + key := fmt.Sprintf("%d:%x", bits, []byte(network)) + candidate := candidates[key] + if candidate == nil { + candidate = &nat64Candidate{ + prefix: nat64Prefix{network: network, bits: bits}, + wka: map[string]bool{}, + } + candidates[key] = candidate + order = append(order, key) + } + candidate.wka[embedded.String()] = true + placements = append(placements, candidate) + } + if len(placements) == 1 { + placements[0].sole = true + } + } + + var out []nat64Prefix + for _, key := range order { + if candidate := candidates[key]; candidate.sole || len(candidate.wka) > 1 { + out = append(out, candidate.prefix) + } + } + if len(out) == 0 { + return nil, errors.New("DNS64 prefix discovery returned no unambiguous RFC 6052 prefix") + } + return out, nil +} diff --git a/internal/server/security_test.go b/internal/server/security_test.go index 4d648e8..af78e0e 100644 --- a/internal/server/security_test.go +++ b/internal/server/security_test.go @@ -2,6 +2,8 @@ package server import ( "context" + "fmt" + "net" "net/http" "net/http/httptest" "testing" @@ -9,6 +11,41 @@ import ( "github.com/enowdev/antares/internal/config" ) +type staticIPResolver map[string][]net.IP + +func (r staticIPResolver) LookupIP(_ context.Context, network, host string) ([]net.IP, error) { + ips, ok := r[network+" "+host] + if !ok { + return nil, &net.DNSError{Err: "not found", Name: host, IsNotFound: true} + } + out := make([]net.IP, len(ips)) + copy(out, ips) + return out, nil +} + +func synthesizeRFC6052(t *testing.T, prefix net.IP, bits int, v4 net.IP) net.IP { + t.Helper() + p := prefix.To16() + v := v4.To4() + if p == nil || v == nil { + t.Fatalf("invalid synthesis input: prefix=%v v4=%v", prefix, v4) + } + out := make(net.IP, net.IPv6len) + if bits == 96 { + copy(out[:12], p[:12]) + copy(out[12:], v) + return out + } + compact := make([]byte, 15) + prefixBytes := bits / 8 + copy(compact[:prefixBytes], p[:prefixBytes]) + copy(compact[prefixBytes:prefixBytes+net.IPv4len], v) + copy(out[:8], compact[:8]) + out[8] = 0 + copy(out[9:], compact[8:]) + return out +} + func TestQueryTokenAllowlist(t *testing.T) { for _, tc := range []struct { path string @@ -71,6 +108,129 @@ func TestRequestIsLoopback(t *testing.T) { } } +func TestExtractRFC6052IPv4SupportsEveryPrefixLength(t *testing.T) { + prefix := net.ParseIP("fd00:aa:bb:2090::") + want := net.ParseIP("54.158.233.194") + for _, bits := range []int{32, 40, 48, 56, 64, 96} { + t.Run(fmt.Sprintf("/%d", bits), func(t *testing.T) { + synth := synthesizeRFC6052(t, prefix, bits, want) + got, ok := extractRFC6052IPv4(synth, bits) + if !ok || !got.Equal(want) { + t.Fatalf("extractRFC6052IPv4(%s, %d) = %v, %v; want %s, true", + synth, bits, got, ok, want) + } + }) + } +} + +func TestExtractRFC6052IPv4RejectsInvalidFormat(t *testing.T) { + ip := synthesizeRFC6052(t, net.ParseIP("fd00:aa:bb:2090::"), 64, net.ParseIP("54.158.233.194")) + ip[8] = 1 + for _, tc := range []struct { + ip net.IP + bits int + }{ + {ip: ip, bits: 64}, + {ip: net.ParseIP("54.158.233.194"), bits: 96}, + {ip: net.ParseIP("2001:db8::1"), bits: 72}, + } { + if _, ok := extractRFC6052IPv4(tc.ip, tc.bits); ok { + t.Fatalf("extractRFC6052IPv4(%s, %d) accepted invalid format", tc.ip, tc.bits) + } + } +} + +func TestDiscoverNAT64PrefixesUsesIPv4OnlyARPA(t *testing.T) { + prefix := net.ParseIP("fd00:aa:bb:2090::") + resolver := staticIPResolver{ + "ip6 ipv4only.arpa": { + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.170")), + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.171")), + }, + } + prefixes, err := discoverNAT64Prefixes(context.Background(), resolver) + if err != nil { + t.Fatalf("discoverNAT64Prefixes: %v", err) + } + if len(prefixes) != 1 || prefixes[0].bits != 96 { + t.Fatalf("prefixes = %+v, want one /96 prefix", prefixes) + } + if !prefixMatches(prefix, prefixes[0].network, 96) { + t.Fatalf("prefix network = %s, want %s/96", prefixes[0].network, prefix) + } +} + +// ambiguousNAT64Prefix embeds the 192.0.0.170 byte pattern in its own bytes, +// so an ipv4only.arpa answer synthesized under it carries a well-known +// address at both the /32 and the /96 RFC 6052 placement. +func ambiguousNAT64Prefix() net.IP { return net.ParseIP("fd00:aa:c000:aa::") } + +func TestDiscoverNAT64PrefixesRejectsAmbiguousWellKnownPlacement(t *testing.T) { + prefix := ambiguousNAT64Prefix() + resolver := staticIPResolver{ + "ip6 ipv4only.arpa": { + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.170")), + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.171")), + }, + } + prefixes, err := discoverNAT64Prefixes(context.Background(), resolver) + if err != nil { + t.Fatalf("discoverNAT64Prefixes: %v", err) + } + if len(prefixes) != 1 || prefixes[0].bits != 96 { + t.Fatalf("prefixes = %+v, want only the /96 corroborated by both well-known addresses", prefixes) + } + if !prefixMatches(prefix, prefixes[0].network, 96) { + t.Fatalf("prefix network = %s/%d, want %s/96", prefixes[0].network, prefixes[0].bits, prefix) + } +} + +func TestDiscoverNAT64PrefixesKeepsMultipleLegitimatePrefixes(t *testing.T) { + short, long := net.ParseIP("fd00:cc:dd:ee::"), net.ParseIP("fd00:aa:bb:2090::") + resolver := staticIPResolver{ + "ip6 ipv4only.arpa": { + synthesizeRFC6052(t, short, 64, net.ParseIP("192.0.0.170")), + synthesizeRFC6052(t, short, 64, net.ParseIP("192.0.0.171")), + synthesizeRFC6052(t, long, 96, net.ParseIP("192.0.0.170")), + synthesizeRFC6052(t, long, 96, net.ParseIP("192.0.0.171")), + }, + } + prefixes, err := discoverNAT64Prefixes(context.Background(), resolver) + if err != nil { + t.Fatalf("discoverNAT64Prefixes: %v", err) + } + if len(prefixes) != 2 { + t.Fatalf("prefixes = %+v, want both discovered NAT64 prefixes", prefixes) + } + if prefixes[0].bits != 64 || !prefixMatches(short, prefixes[0].network, 64) { + t.Fatalf("first prefix = %s/%d, want %s/64", prefixes[0].network, prefixes[0].bits, short) + } + if prefixes[1].bits != 96 || !prefixMatches(long, prefixes[1].network, 96) { + t.Fatalf("second prefix = %s/%d, want %s/96", prefixes[1].network, prefixes[1].bits, long) + } +} + +// A broader prefix inferred from an ambiguous placement would accept any ULA +// sharing those leading bits, so an unrelated internal AAAA must stay blocked +// even though its bytes decode to the host's real public IPv4. +func TestValidateProviderBaseURLRejectsULAUnderAmbiguousDNS64Discovery(t *testing.T) { + prefix := ambiguousNAT64Prefix() + resolver := staticIPResolver{ + "ip provider.example": { + net.ParseIP("54.158.233.194"), + net.ParseIP("fd00:aa:369e:e9c2::1"), + }, + "ip6 ipv4only.arpa": { + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.170")), + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.171")), + }, + } + if err := validateProviderBaseURLWithResolver( + context.Background(), "https://provider.example", false, resolver); err == nil { + t.Fatal("internal ULA was accepted under a prefix inferred from an ambiguous placement") + } +} + func TestValidateProviderBaseURLBlocksPrivateDestinations(t *testing.T) { ctx := context.Background() for _, raw := range []string{ @@ -100,6 +260,85 @@ func TestValidateProviderBaseURLBlocksPrivateDestinations(t *testing.T) { } } +func dns64Resolver(t *testing.T, targetHost string, targetV4 net.IP) staticIPResolver { + t.Helper() + prefix := net.ParseIP("fd00:aa:bb:2090::") + return staticIPResolver{ + "ip " + targetHost: { + targetV4, + synthesizeRFC6052(t, prefix, 96, targetV4), + }, + "ip6 ipv4only.arpa": { + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.170")), + synthesizeRFC6052(t, prefix, 96, net.ParseIP("192.0.0.171")), + }, + } +} + +func TestValidateProviderBaseURLAllowsDiscoveredDNS64(t *testing.T) { + resolver := dns64Resolver(t, "api.cursor.com", net.ParseIP("54.158.233.194")) + err := validateProviderBaseURLWithResolver( + context.Background(), "https://api.cursor.com", false, resolver) + if err != nil { + t.Fatalf("DNS64 provider rejected: %v", err) + } +} + +func TestValidateProviderBaseURLRejectsUnrelatedULAOnMixedDNS(t *testing.T) { + resolver := dns64Resolver(t, "provider.example", net.ParseIP("54.158.233.194")) + resolver["ip provider.example"] = append( + resolver["ip provider.example"], net.ParseIP("fd00:dead:beef::1")) + if err := validateProviderBaseURLWithResolver( + context.Background(), "https://provider.example", false, resolver); err == nil { + t.Fatal("mixed public DNS with an unrelated ULA was accepted") + } +} + +func TestValidateProviderBaseURLRejectsDNS64AddressForDifferentARecord(t *testing.T) { + prefix := net.ParseIP("fd00:aa:bb:2090::") + resolver := dns64Resolver(t, "provider.example", net.ParseIP("54.158.233.194")) + resolver["ip provider.example"] = []net.IP{ + net.ParseIP("54.158.233.194"), + synthesizeRFC6052(t, prefix, 96, net.ParseIP("54.225.153.71")), + } + if err := validateProviderBaseURLWithResolver( + context.Background(), "https://provider.example", false, resolver); err == nil { + t.Fatal("DNS64 address whose embedded IPv4 mismatched the A record was accepted") + } +} + +func TestValidateProviderBaseURLRejectsMissingOrMalformedDNS64Discovery(t *testing.T) { + for _, tc := range []struct { + name string + discovery []net.IP + }{ + {name: "missing"}, + {name: "malformed", discovery: []net.IP{net.ParseIP("2001:4860::1")}}, + } { + t.Run(tc.name, func(t *testing.T) { + resolver := dns64Resolver(t, "provider.example", net.ParseIP("54.158.233.194")) + if tc.discovery == nil { + delete(resolver, "ip6 ipv4only.arpa") + } else { + resolver["ip6 ipv4only.arpa"] = tc.discovery + } + if err := validateProviderBaseURLWithResolver( + context.Background(), "https://provider.example", false, resolver); err == nil { + t.Fatal("provider passed without a valid discovered DNS64 prefix") + } + }) + } +} + +func TestDNS64MatchRejectsEmbeddedPrivateIPv4(t *testing.T) { + prefix := nat64Prefix{network: net.ParseIP("fd00:aa:bb:2090::"), bits: 96} + ip := synthesizeRFC6052(t, prefix.network, prefix.bits, net.ParseIP("10.0.0.8")) + publicV4 := map[string]struct{}{"10.0.0.8": {}} + if dns64AddressMatches(ip, []nat64Prefix{prefix}, publicV4) { + t.Fatal("DNS64 address embedding a private IPv4 was accepted") + } +} + func TestRequireSetupAccessIsLoopbackOnlyWithoutBearer(t *testing.T) { cfg := config.Default() s := &Server{cfg: cfg} diff --git a/internal/server/server.go b/internal/server/server.go index a8a6566..2d10f68 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,6 +22,7 @@ import ( "github.com/enowdev/antares/internal/agent" "github.com/enowdev/antares/internal/config" "github.com/enowdev/antares/internal/cron" + "github.com/enowdev/antares/internal/cursor" "github.com/enowdev/antares/internal/gateway" "github.com/enowdev/antares/internal/mcp" "github.com/enowdev/antares/internal/skills" @@ -30,6 +31,17 @@ import ( "github.com/enowdev/antares/internal/version" ) +// cursorMetadataClient is the narrow surface Server needs from a Cursor +// client: identity/quota verification and the model catalogue. Tests inject a +// fake through cursorFactory; production always goes through cursor.New. +type cursorMetadataClient interface { + Me(context.Context) (*cursor.Me, error) + Models(context.Context) (*cursor.ModelCatalog, error) +} + +// cursorClientFactory builds a cursorMetadataClient from connection options. +type cursorClientFactory func(cursor.Options) (cursorMetadataClient, error) + // Server wires the API handlers to the agent and store. type Server struct { cfg *config.Config @@ -48,6 +60,15 @@ type Server struct { // distFS holds the embedded dashboard build, when present. distFS fs.FS + // cursorFactory overrides how a Cursor metadata client is constructed. + // Only tests set this; production callers get cursor.New via + // newCursorMetadataClient. + cursorFactory cursorClientFactory + + // providerResolver overrides provider hostname resolution in handler tests. + // Production uses net.DefaultResolver. + providerResolver providerIPResolver + mu sync.RWMutex reloadFn func() error @@ -140,6 +161,15 @@ func (s *Server) config() *config.Config { return s.cfg } +// newCursorMetadataClient builds a Cursor metadata client, honouring an +// injected test factory when one is set. No production caller injects one. +func (s *Server) newCursorMetadataClient(o cursor.Options) (cursorMetadataClient, error) { + if s.cursorFactory != nil { + return s.cursorFactory(o) + } + return cursor.New(o) +} + //go:embed all:dist var embeddedDist embed.FS diff --git a/internal/tools/cursor_agent.go b/internal/tools/cursor_agent.go new file mode 100644 index 0000000..d14320d --- /dev/null +++ b/internal/tools/cursor_agent.go @@ -0,0 +1,716 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" +) + +type cursorAgentTool struct{} + +func (cursorAgentTool) Name() string { return "cursor_agent" } + +func (cursorAgentTool) Description() string { + return "Start, continue, or cancel a Cursor Cloud Agent run. " + + "Use cursor_agent_status for read-only snapshots or to wait on an existing run." +} + +func (cursorAgentTool) Schema() map[string]any { + return schema(map[string]any{ + "action": propEnum("Operation to perform.", "start", "follow_up", "cancel"), + "prompt": prop("string", "Task for start/follow_up."), + "agent_id": prop("string", "Cursor bc- agent id for follow_up/cancel."), + "run_id": prop("string", "Cursor run- id for cancel."), + "model": prop("string", "Optional model id returned by Cursor."), + "repository_url": prop("string", "Optional HTTPS GitHub repository URL."), + "starting_ref": prop("string", "Optional branch or commit SHA."), + "pull_request_url": prop("string", "Optional GitHub pull request URL."), + "mode": propEnum("Cursor conversation mode.", "agent", "plan"), + "auto_create_pr": propDefault("boolean", "Open a PR when the run completes.", false), + "skip_reviewer_request": propDefault("boolean", "Do not request the key owner as reviewer.", true), + "wait": propDefault("boolean", "Stream until terminal status.", true), + }, "action") +} + +func (cursorAgentTool) RequiresApproval() bool { return true } + +type cursorAgentArgs struct { + Action string `json:"action"` + Prompt string `json:"prompt"` + AgentID string `json:"agent_id"` + RunID string `json:"run_id"` + Model string `json:"model"` + RepositoryURL string `json:"repository_url"` + StartingRef string `json:"starting_ref"` + PullRequestURL string `json:"pull_request_url"` + Mode string `json:"mode"` + AutoCreatePR bool `json:"auto_create_pr"` + SkipReviewerRequest *bool `json:"skip_reviewer_request"` + Wait *bool `json:"wait"` +} + +func (a *cursorAgentArgs) trim() { + a.Action = strings.TrimSpace(a.Action) + a.Prompt = strings.TrimSpace(a.Prompt) + a.AgentID = strings.TrimSpace(a.AgentID) + a.RunID = strings.TrimSpace(a.RunID) + a.Model = strings.TrimSpace(a.Model) + a.RepositoryURL = strings.TrimSpace(a.RepositoryURL) + a.StartingRef = strings.TrimSpace(a.StartingRef) + a.PullRequestURL = strings.TrimSpace(a.PullRequestURL) + a.Mode = strings.TrimSpace(a.Mode) +} + +func (cursorAgentTool) Execute(ctx context.Context, in Input) Result { + var args cursorAgentArgs + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + args.trim() + if err := validateCursorAgentArgs(args); err != nil { + return Errorf("%v", err) + } + + client, provider, err := cursorClientFromInput(in) + if err != nil { + return safeCursorResultError(err, args.AgentID, args.RunID, provider.APIKey) + } + + switch args.Action { + case "start": + return startCursorAgent(ctx, in, client, provider, args) + case "follow_up": + return followUpCursorAgent(ctx, in, client, provider, args) + case "cancel": + err := client.CancelRun(ctx, args.AgentID, args.RunID) + if err != nil { + return cursorOperationError(err, "run", args.AgentID, args.RunID, provider.APIKey) + } + agentID := redactCursorString(args.AgentID, provider.APIKey) + runID := redactCursorString(args.RunID, provider.APIKey) + return Result{ + Content: fmt.Sprintf("Cursor cancellation requested.\nagent_id: %s\nrun_id: %s", agentID, runID), + Meta: map[string]any{ + "agent_id": agentID, + "run_id": runID, + "status": "cancel_requested", + }, + } + default: + return Errorf("action must be start, follow_up, or cancel") + } +} + +func startCursorAgent( + ctx context.Context, + in Input, + client *cursor.Client, + provider config.Provider, + args cursorAgentArgs, +) Result { + wait := true + if args.Wait != nil { + wait = *args.Wait + } + skipReviewer := true + if args.SkipReviewerRequest != nil { + skipReviewer = *args.SkipReviewerRequest + } + + repos := []cursor.Repository{} + if args.RepositoryURL != "" { + repos = append(repos, cursor.Repository{ + URL: args.RepositoryURL, + StartingRef: args.StartingRef, + PRURL: args.PullRequestURL, + }) + } + var model *cursor.ModelSelection + if args.Model != "" { + model = &cursor.ModelSelection{ID: args.Model} + } + + created, err := client.CreateAgent(ctx, cursor.CreateAgentRequest{ + Prompt: cursor.Prompt{Text: args.Prompt}, + Model: model, + Repos: repos, + AutoCreatePR: args.AutoCreatePR, + SkipReviewerRequest: skipReviewer, + Mode: args.Mode, + }) + if err != nil { + return cursorOperationError(err, "agent", "", "", provider.APIKey) + } + if created == nil { + return cursorResultError(errors.New("Cursor returned an empty create response"), "", "") + } + if !wait { + return cursorRunResult(created.Agent, created.Run, provider.APIKey, true) + } + + waitCtx, cancel := cursorWaitContext(ctx, provider) + defer cancel() + return waitCursorRun(waitCtx, in, client, created.Agent, created.Run) +} + +func followUpCursorAgent( + ctx context.Context, + in Input, + client *cursor.Client, + provider config.Provider, + args cursorAgentArgs, +) Result { + agent, err := client.GetAgent(ctx, args.AgentID) + if err != nil { + return cursorOperationError(err, "agent", args.AgentID, "", provider.APIKey) + } + run, err := client.CreateRun(ctx, args.AgentID, cursor.CreateRunRequest{ + Prompt: cursor.Prompt{Text: args.Prompt}, + Mode: args.Mode, + }) + if err != nil { + return cursorOperationError(err, "agent", args.AgentID, "", provider.APIKey) + } + if agent == nil || run == nil { + return safeCursorResultError( + errors.New("Cursor returned an empty follow-up response"), + args.AgentID, + "", + provider.APIKey, + ) + } + + wait := true + if args.Wait != nil { + wait = *args.Wait + } + if !wait { + return cursorRunResult(*agent, *run, provider.APIKey, true) + } + + waitCtx, cancel := cursorWaitContext(ctx, provider) + defer cancel() + return waitCursorRun(waitCtx, in, client, *agent, *run) +} + +func validateCursorAgentArgs(args cursorAgentArgs) error { + switch args.Action { + case "": + return errors.New("action is required") + case "start": + if args.Prompt == "" { + return errors.New("prompt is required for start") + } + if args.AgentID != "" { + return errors.New("agent_id is not allowed for start") + } + if args.RunID != "" { + return errors.New("run_id is not allowed for start") + } + if err := validateCursorMode(args.Mode); err != nil { + return err + } + if err := validateCursorRepository(args.RepositoryURL); err != nil { + return err + } + if args.StartingRef != "" && args.RepositoryURL == "" { + return errors.New("repository_url is required when starting_ref is set") + } + if args.PullRequestURL != "" { + if args.RepositoryURL == "" { + return errors.New("repository_url is required when pull_request_url is set") + } + if err := validateCursorPullRequest(args.PullRequestURL); err != nil { + return err + } + } + if args.AutoCreatePR && args.RepositoryURL == "" { + return errors.New("repository_url is required when auto_create_pr is true") + } + return nil + case "follow_up": + if args.AgentID == "" { + return errors.New("agent_id is required for follow_up") + } + if err := validateCursorID(args.AgentID, "bc-", "agent_id"); err != nil { + return err + } + if args.Prompt == "" { + return errors.New("prompt is required for follow_up") + } + if args.RunID != "" { + return errors.New("run_id is not allowed for follow_up") + } + if err := rejectCursorStartFields(args, "follow_up"); err != nil { + return err + } + return validateCursorMode(args.Mode) + case "cancel": + if args.AgentID == "" { + return errors.New("agent_id is required for cancel") + } + if args.RunID == "" { + return errors.New("run_id is required for cancel") + } + if err := validateCursorID(args.AgentID, "bc-", "agent_id"); err != nil { + return err + } + if err := validateCursorID(args.RunID, "run-", "run_id"); err != nil { + return err + } + if args.Prompt != "" { + return errors.New("prompt is not allowed for cancel") + } + if args.Mode != "" { + return errors.New("mode is not allowed for cancel") + } + if args.Wait != nil { + return errors.New("wait is not allowed for cancel") + } + if err := rejectCursorStartFields(args, "cancel"); err != nil { + return err + } + return nil + default: + return errors.New("action must be start, follow_up, or cancel") + } +} + +func rejectCursorStartFields(args cursorAgentArgs, action string) error { + switch { + case args.Model != "": + return fmt.Errorf("model is not allowed for %s", action) + case args.RepositoryURL != "": + return fmt.Errorf("repository_url is not allowed for %s", action) + case args.StartingRef != "": + return fmt.Errorf("starting_ref is not allowed for %s", action) + case args.PullRequestURL != "": + return fmt.Errorf("pull_request_url is not allowed for %s", action) + case args.AutoCreatePR: + return fmt.Errorf("auto_create_pr is not allowed for %s", action) + case args.SkipReviewerRequest != nil: + return fmt.Errorf("skip_reviewer_request is not allowed for %s", action) + default: + return nil + } +} + +func validateCursorMode(mode string) error { + if mode == "" || mode == "agent" || mode == "plan" { + return nil + } + return errors.New("mode must be agent or plan") +} + +func validateCursorID(value, prefix, field string) error { + if !strings.HasPrefix(value, prefix) || len(value) == len(prefix) { + return fmt.Errorf("%s must be a Cursor %s id", field, prefix) + } + return nil +} + +func cursorClientFromInput(in Input) (*cursor.Client, config.Provider, error) { + if in.Deps == nil || in.Deps.Config == nil { + return nil, config.Provider{}, errors.New("Cursor is unavailable in this runtime") + } + _, provider := in.Deps.Config.ResolveProvider("cursor") + provider.APIKey = strings.TrimSpace(provider.APIKey) + if !provider.Enabled || provider.APIKey == "" { + return nil, provider, errors.New("connect Cursor in Providers or set CURSOR_API_KEY") + } + client, err := cursor.New(cursor.Options{ + BaseURL: provider.BaseURL, + APIKey: provider.APIKey, + }) + return client, provider, err +} + +func validateCursorRepository(raw string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + if _, err := parseCursorGitHubURL(raw); err != nil { + return errors.New("repository_url must be an HTTPS GitHub URL") + } + return nil +} + +func validateCursorPullRequest(raw string) error { + parsed, err := parseCursorGitHubURL(raw) + if err != nil { + return errors.New("pull_request_url must be an HTTPS GitHub pull request URL") + } + parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(parts) != 4 || parts[2] != "pull" { + return errors.New("pull_request_url must be an HTTPS GitHub pull request URL with a /pull/ path") + } + number, err := strconv.ParseUint(parts[3], 10, 64) + if err != nil || number == 0 { + return errors.New("pull_request_url must be an HTTPS GitHub pull request URL with a /pull/ path") + } + return nil +} + +func parseCursorGitHubURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || + parsed.Scheme != "https" || + !strings.EqualFold(parsed.Host, "github.com") || + parsed.User != nil { + return nil, errors.New("not an HTTPS GitHub URL") + } + return parsed, nil +} + +func cursorWaitContext(ctx context.Context, provider config.Provider) (context.Context, context.CancelFunc) { + timeout := time.Duration(provider.TimeoutSecs) * time.Second + if timeout <= 0 { + timeout = 15 * time.Minute + } + return context.WithTimeout(ctx, timeout) +} + +func waitCursorRun( + ctx context.Context, + in Input, + client *cursor.Client, + agent cursor.Agent, + run cursor.Run, +) Result { + agentID := agent.ID + if agentID == "" { + agentID = run.AgentID + } + runID := run.ID + terminal, err := client.StreamRun(ctx, agentID, runID, func(event cursor.StreamEvent) error { + emitCursorEvent(in, event) + return nil + }) + secret := cursorSecretFromInput(in) + if err != nil { + return cursorOperationError(err, "run", agentID, runID, secret) + } + if terminal == nil { + return safeCursorResultError(errors.New("Cursor stream returned no run"), agentID, runID, secret) + } + if terminal.ID == "" { + terminal.ID = runID + } + if terminal.AgentID == "" { + terminal.AgentID = agentID + } + return cursorRunResult(agent, *terminal, secret, false) +} + +func emitCursorEvent(in Input, event cursor.StreamEvent) { + secret := cursorSecretFromInput(in) + message := "Cursor " + redactCursorString(event.Type, secret) + chunk := redactCursorString(event.Text, secret) + if event.ToolName != "" { + message = "Cursor tool " + + redactCursorString(event.ToolName, secret) + " " + + redactCursorString(event.Status, secret) + } + message = boundCursorProgress(message) + chunk = boundCursorProgress(chunk) + if in.Emit != nil { + in.Emit(Progress{Tool: "cursor_agent", Message: message, Chunk: chunk}) + } +} + +func boundCursorProgress(value string) string { + const maxRunes = 2000 + value = strings.ToValidUTF8(value, "\uFFFD") + runes := []rune(value) + if len(runes) > maxRunes { + return string(runes[:maxRunes]) + "…" + } + return value +} + +func cursorAgentStatusResult( + ctx context.Context, + in Input, + client *cursor.Client, + provider config.Provider, + agent cursor.Agent, + runID string, + wait bool, +) Result { + if wait { + waitCtx, cancel := cursorWaitContext(ctx, provider) + defer cancel() + return waitCursorRun(waitCtx, in, client, agent, cursor.Run{ID: runID, AgentID: agent.ID}) + } + run, err := client.GetRun(ctx, agent.ID, runID) + if err != nil { + return cursorOperationError(err, "run", agent.ID, runID, provider.APIKey) + } + if run == nil { + return safeCursorResultError( + errors.New("Cursor returned an empty run response"), + agent.ID, + runID, + provider.APIKey, + ) + } + return cursorRunResult(agent, *run, provider.APIKey, true) +} + +func cursorRunResult(agent cursor.Agent, run cursor.Run, secret string, discouragePolling bool) Result { + agentID := agent.ID + if agentID == "" { + agentID = run.AgentID + } + status := run.Status + if status == "" { + status = agent.Status + } + git := run.Git + if git == nil { + git = agent.Git + } + + agentID = redactCursorString(agentID, secret) + runID := redactCursorString(run.ID, secret) + status = redactCursorString(status, secret) + cursorURL := redactCursorString(agent.URL, secret) + resultText := redactCursorString(run.Result, secret) + safeGit := sanitizeCursorGit(git, secret) + + meta := map[string]any{ + "agent_id": agentID, + "run_id": runID, + "status": status, + "cursor_url": cursorURL, + "duration_ms": run.DurationMS, + "result": resultText, + } + if safeGit != nil { + meta["git"] = safeGit + } + + var content strings.Builder + content.WriteString("Cursor run\n") + fmt.Fprintf(&content, "agent_id: %s\nrun_id: %s\nstatus: %s", agentID, runID, status) + if cursorURL != "" { + fmt.Fprintf(&content, "\ncursor_url: %s", cursorURL) + } + fmt.Fprintf(&content, "\nduration_ms: %d", run.DurationMS) + if resultText != "" { + fmt.Fprintf(&content, "\n\nResult:\n%s", resultText) + } + if safeGit != nil && len(safeGit.Branches) > 0 { + content.WriteString("\n\nGit:") + for _, branch := range safeGit.Branches { + fmt.Fprintf(&content, "\n- %s", branch.RepoURL) + if branch.Branch != "" { + fmt.Fprintf(&content, " — %s", branch.Branch) + } + if branch.PRURL != "" { + fmt.Fprintf(&content, " — PR: %s", branch.PRURL) + } + } + } + if discouragePolling { + content.WriteString("\n\nDo not busy-poll this run. Call cursor_agent_status once later when you need a fresh snapshot.") + } + return Result{Content: content.String(), Meta: meta} +} + +func sanitizeCursorGit(git *cursor.GitState, secret string) *cursor.GitState { + if git == nil { + return nil + } + safe := &cursor.GitState{Branches: make([]cursor.GitBranch, len(git.Branches))} + for i, branch := range git.Branches { + safe.Branches[i] = cursor.GitBranch{ + RepoURL: redactCursorString(branch.RepoURL, secret), + Branch: redactCursorString(branch.Branch, secret), + PRURL: redactCursorString(branch.PRURL, secret), + } + } + return safe +} + +func cursorSecretFromInput(in Input) string { + if in.Deps == nil || in.Deps.Config == nil { + return "" + } + _, provider := in.Deps.Config.ResolveProvider("cursor") + return strings.TrimSpace(provider.APIKey) +} + +func redactCursorString(value, secret string) string { + if secret == "" { + return value + } + return strings.ReplaceAll(value, secret, "[REDACTED]") +} + +func sanitizeCursorError(err error, secret string) error { + if err == nil || secret == "" { + return err + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return err + } + var apiErr *cursor.APIError + if errors.As(err, &apiErr) { + safe := *apiErr + safe.Code = redactCursorString(safe.Code, secret) + safe.Message = redactCursorString(safe.Message, secret) + return &safe + } + return errors.New(redactCursorString(err.Error(), secret)) +} + +func cursorResultError(err error, agentID, runID string) Result { + meta := map[string]any{"agent_id": agentID, "run_id": runID} + switch { + case cursor.IsAuthError(err): + return Result{Content: "Cursor API key was rejected.", Meta: meta, IsError: true} + case cursor.IsRateLimit(err): + var apiErr *cursor.APIError + _ = errors.As(err, &apiErr) + if apiErr != nil && apiErr.RetryAfter > 0 { + meta["retry_after_seconds"] = int(apiErr.RetryAfter.Seconds()) + } + return Result{Content: "Cursor rate limit reached; retry later.", Meta: meta, IsError: true} + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): + return Result{ + Content: "Stopped waiting; the remote Cursor run may still be active. Use cursor_agent_status with the returned IDs.", + Meta: meta, + IsError: true, + } + default: + return Result{Content: "Cursor request failed: " + err.Error(), Meta: meta, IsError: true} + } +} + +func safeCursorResultError(err error, agentID, runID, secret string) Result { + return cursorResultError( + sanitizeCursorError(err, secret), + redactCursorString(agentID, secret), + redactCursorString(runID, secret), + ) +} + +// cursorNotFoundLabel prefers Cursor's typed code over the caller's guess: +// cancelling a run whose agent is gone reports the agent, not the run. Only +// these fixed labels are returned, never the upstream code itself. +func cursorNotFoundLabel(err error, missingKind string) string { + var apiErr *cursor.APIError + if errors.As(err, &apiErr) { + switch strings.ToLower(strings.TrimSpace(apiErr.Code)) { + case "agent_not_found": + return "Cursor agent not found" + case "run_not_found": + return "Cursor run not found" + } + } + if missingKind == "agent" { + return "Cursor agent not found" + } + return "Cursor run not found" +} + +func cursorOperationError(err error, missingKind, agentID, runID, secret string) Result { + err = sanitizeCursorError(err, secret) + agentID = redactCursorString(agentID, secret) + runID = redactCursorString(runID, secret) + meta := map[string]any{"agent_id": agentID, "run_id": runID} + if cursor.IsStatus(err, http.StatusNotFound) { + return Result{Content: cursorNotFoundLabel(err, missingKind) + ": " + err.Error(), Meta: meta, IsError: true} + } + if cursor.IsStatus(err, http.StatusConflict) { + return Result{Content: err.Error(), Meta: meta, IsError: true} + } + return cursorResultError(err, agentID, runID) +} + +type cursorAgentStatusTool struct{} + +func (cursorAgentStatusTool) Name() string { return "cursor_agent_status" } + +func (cursorAgentStatusTool) Description() string { + return "Read a Cursor Cloud Agent run snapshot, or stream an existing run until it reaches a terminal status." +} + +func (cursorAgentStatusTool) Schema() map[string]any { + return schema(map[string]any{ + "agent_id": prop("string", "Cursor bc- agent id."), + "run_id": prop("string", "Optional run- id; latest run is used when omitted."), + "wait": propDefault("boolean", "Stream until terminal status.", false), + }, "agent_id") +} + +func (cursorAgentStatusTool) Execute(ctx context.Context, in Input) Result { + var args struct { + AgentID string `json:"agent_id"` + RunID string `json:"run_id"` + Wait *bool `json:"wait"` + } + if err := in.Bind(&args); err != nil { + return Errorf("%v", err) + } + args.AgentID = strings.TrimSpace(args.AgentID) + args.RunID = strings.TrimSpace(args.RunID) + if args.AgentID == "" { + return Errorf("agent_id is required") + } + if err := validateCursorID(args.AgentID, "bc-", "agent_id"); err != nil { + return Errorf("%v", err) + } + if args.RunID != "" { + if err := validateCursorID(args.RunID, "run-", "run_id"); err != nil { + return Errorf("%v", err) + } + } + + client, provider, err := cursorClientFromInput(in) + if err != nil { + return safeCursorResultError(err, args.AgentID, args.RunID, provider.APIKey) + } + agent, err := client.GetAgent(ctx, args.AgentID) + if err != nil { + return cursorOperationError(err, "agent", args.AgentID, args.RunID, provider.APIKey) + } + if agent == nil { + return safeCursorResultError( + errors.New("Cursor returned an empty agent response"), + args.AgentID, + args.RunID, + provider.APIKey, + ) + } + + runID := args.RunID + if runID == "" { + runID = strings.TrimSpace(agent.LatestRunID) + if runID == "" { + return safeCursorResultError( + errors.New("Cursor agent has no latest run"), + args.AgentID, + "", + provider.APIKey, + ) + } + if err := validateCursorID(runID, "run-", "run_id"); err != nil { + return safeCursorResultError(err, args.AgentID, runID, provider.APIKey) + } + } + wait := false + if args.Wait != nil { + wait = *args.Wait + } + return cursorAgentStatusResult(ctx, in, client, provider, *agent, runID, wait) +} diff --git a/internal/tools/cursor_agent_test.go b/internal/tools/cursor_agent_test.go new file mode 100644 index 0000000..e1a1ac8 --- /dev/null +++ b/internal/tools/cursor_agent_test.go @@ -0,0 +1,901 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + "unicode/utf8" + + "github.com/enowdev/antares/internal/config" +) + +const cursorToolTestKey = "synthetic-key" + +func cursorToolTestConfig(baseURL string) *config.Config { + cfg := config.Default() + p := cfg.Providers["cursor"] + p.APIKey = cursorToolTestKey + p.BaseURL = baseURL + cfg.Providers["cursor"] = p + return cfg +} + +func cursorToolTestInput(cfg *config.Config, args string, progress *[]Progress) Input { + return Input{ + Args: []byte(args), + Deps: &Deps{Config: cfg}, + Emit: func(p Progress) { + if progress != nil { + *progress = append(*progress, p) + } + }, + } +} + +func writeCursorSSE(w io.Writer, event string, payload any) { + raw, _ := json.Marshal(payload) + _, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, raw) +} + +func TestCursorToolSchemasAndApprovalClassification(t *testing.T) { + if !NeedsApproval(cursorAgentTool{}) { + t.Fatal("cursor_agent must require approval") + } + if NeedsApproval(cursorAgentStatusTool{}) { + t.Fatal("cursor_agent_status must be read-only") + } + + agentProps := cursorAgentTool{}.Schema()["properties"].(map[string]any) + action := agentProps["action"].(map[string]any) + gotActions := action["enum"].([]string) + if strings.Join(gotActions, ",") != "start,follow_up,cancel" { + t.Fatalf("cursor_agent action enum = %v", gotActions) + } + if got := agentProps["wait"].(map[string]any)["default"]; got != true { + t.Fatalf("cursor_agent wait default = %#v, want true", got) + } + if got := agentProps["skip_reviewer_request"].(map[string]any)["default"]; got != true { + t.Fatalf("skip_reviewer_request default = %#v, want true", got) + } + if got := agentProps["auto_create_pr"].(map[string]any)["default"]; got != false { + t.Fatalf("auto_create_pr default = %#v, want false", got) + } + + statusSchema := cursorAgentStatusTool{}.Schema() + statusProps := statusSchema["properties"].(map[string]any) + if got := statusProps["wait"].(map[string]any)["default"]; got != false { + t.Fatalf("cursor_agent_status wait default = %#v, want false", got) + } + required := statusSchema["required"].([]string) + if len(required) != 1 || required[0] != "agent_id" { + t.Fatalf("cursor_agent_status required = %v", required) + } +} + +func TestCursorAgentRejectsMissingConfigAndInvalidRepository(t *testing.T) { + in := cursorToolTestInput(config.Default(), `{"action":"start","prompt":"fix it"}`, nil) + result := (cursorAgentTool{}).Execute(context.Background(), in) + if !result.IsError || !strings.Contains(result.Content, "CURSOR_API_KEY") { + t.Fatalf("missing-key result = %+v", result) + } + + in.Deps = nil + result = (cursorAgentTool{}).Execute(context.Background(), in) + if !result.IsError || !strings.Contains(result.Content, "unavailable") { + t.Fatalf("missing-runtime result = %+v", result) + } + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "validation should happen before network I/O", http.StatusInternalServerError) + })) + defer srv.Close() + cfg := cursorToolTestConfig(srv.URL) + + for _, rawURL := range []string{ + "http://github.com/acme/repo", + "https://user@github.com/acme/repo", + "https://github.com:443/acme/repo", + "https://example.com/acme/repo", + } { + t.Run(rawURL, func(t *testing.T) { + args := fmt.Sprintf(`{"action":"start","prompt":"fix it","repository_url":%q}`, rawURL) + got := (cursorAgentTool{}).Execute(context.Background(), cursorToolTestInput(cfg, args, nil)) + if !got.IsError || !strings.Contains(got.Content, "HTTPS GitHub") { + t.Fatalf("invalid repository result = %+v", got) + } + }) + } + if calls.Load() != 0 { + t.Fatalf("invalid repository made %d network calls", calls.Load()) + } +} + +func TestCursorAgentRejectsStartingRefWithoutRepositoryBeforeNetwork(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "validation should happen before network I/O", http.StatusInternalServerError) + })) + defer srv.Close() + + args := `{"action":"start","prompt":"fix it","starting_ref":"main","wait":false}` + got := (cursorAgentTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), args, nil), + ) + if !got.IsError || !strings.Contains(got.Content, "repository_url is required when starting_ref is set") { + t.Fatalf("result = %+v, want actionable starting_ref validation error", got) + } + if calls.Load() != 0 { + t.Fatalf("starting_ref without repository_url made %d network request(s)", calls.Load()) + } +} + +func TestCursorAgentValidatesActionSpecificArguments(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + http.Error(w, "validation should happen before network I/O", http.StatusInternalServerError) + })) + defer srv.Close() + cfg := cursorToolTestConfig(srv.URL) + + tests := []struct { + name string + args string + want string + }{ + {"missing action", `{}`, "action is required"}, + {"unknown action", `{"action":"launch"}`, "action must be"}, + {"start missing prompt", `{"action":"start","wait":false}`, "prompt is required"}, + {"start rejects agent id", `{"action":"start","prompt":"x","agent_id":"bc-one"}`, "agent_id is not allowed for start"}, + {"start rejects run id", `{"action":"start","prompt":"x","run_id":"run-one"}`, "run_id is not allowed for start"}, + {"start rejects mode", `{"action":"start","prompt":"x","mode":"ask"}`, "mode must be"}, + {"pull request needs repository", `{"action":"start","prompt":"x","pull_request_url":"https://github.com/acme/repo/pull/7"}`, "repository_url is required"}, + {"auto PR needs repository", `{"action":"start","prompt":"x","auto_create_pr":true}`, "repository_url is required"}, + {"pull request path", `{"action":"start","prompt":"x","repository_url":"https://github.com/acme/repo","pull_request_url":"https://github.com/acme/repo/issues/7"}`, "pull_request_url must be"}, + {"follow-up missing agent", `{"action":"follow_up","prompt":"x","wait":false}`, "agent_id is required"}, + {"follow-up bad agent prefix", `{"action":"follow_up","agent_id":"agent-one","prompt":"x","wait":false}`, "bc-"}, + {"follow-up missing prompt", `{"action":"follow_up","agent_id":"bc-one","wait":false}`, "prompt is required"}, + {"follow-up rejects run id", `{"action":"follow_up","agent_id":"bc-one","run_id":"run-one","prompt":"x","wait":false}`, "run_id is not allowed for follow_up"}, + {"follow-up rejects model", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","model":"composer-2","wait":false}`, "model is not allowed for follow_up"}, + {"follow-up rejects repository", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","repository_url":"https://github.com/acme/repo","wait":false}`, "repository_url is not allowed for follow_up"}, + {"follow-up rejects ref", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","starting_ref":"main","wait":false}`, "starting_ref is not allowed for follow_up"}, + {"follow-up rejects pull request", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","pull_request_url":"https://github.com/acme/repo/pull/7","wait":false}`, "pull_request_url is not allowed for follow_up"}, + {"follow-up rejects auto PR", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","auto_create_pr":true,"wait":false}`, "auto_create_pr is not allowed for follow_up"}, + {"follow-up rejects reviewer option", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","skip_reviewer_request":false,"wait":false}`, "skip_reviewer_request is not allowed for follow_up"}, + {"follow-up rejects mode", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","mode":"ask","wait":false}`, "mode must be"}, + {"cancel missing agent", `{"action":"cancel","run_id":"run-one"}`, "agent_id is required"}, + {"cancel missing run", `{"action":"cancel","agent_id":"bc-one"}`, "run_id is required"}, + {"cancel bad agent prefix", `{"action":"cancel","agent_id":"agent-one","run_id":"run-one"}`, "bc-"}, + {"cancel bad run prefix", `{"action":"cancel","agent_id":"bc-one","run_id":"job-one"}`, "run-"}, + {"cancel rejects prompt", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","prompt":"x"}`, "prompt is not allowed for cancel"}, + {"cancel rejects mode", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","mode":"agent"}`, "mode is not allowed for cancel"}, + {"cancel rejects wait", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","wait":false}`, "wait is not allowed for cancel"}, + {"cancel rejects repository", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","repository_url":"https://github.com/acme/repo"}`, "repository_url is not allowed for cancel"}, + {"cancel rejects reviewer option", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","skip_reviewer_request":true}`, "skip_reviewer_request is not allowed for cancel"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := (cursorAgentTool{}).Execute(context.Background(), cursorToolTestInput(cfg, tt.args, nil)) + if !got.IsError || !strings.Contains(got.Content, tt.want) { + t.Fatalf("result = %+v, want error containing %q", got, tt.want) + } + }) + } + if calls.Load() != 0 { + t.Fatalf("invalid arguments made %d network calls", calls.Load()) + } +} + +func TestCursorAgentStatusValidatesIDs(t *testing.T) { + cfg := cursorToolTestConfig("http://127.0.0.1:1") + tests := []struct { + args string + want string + }{ + {`{}`, "agent_id is required"}, + {`{"agent_id":"agent-one"}`, "bc-"}, + {`{"agent_id":"bc-one","run_id":"job-one"}`, "run-"}, + } + for _, tt := range tests { + got := (cursorAgentStatusTool{}).Execute(context.Background(), cursorToolTestInput(cfg, tt.args, nil)) + if !got.IsError || !strings.Contains(got.Content, tt.want) { + t.Fatalf("args %s: result = %+v, want %q", tt.args, got, tt.want) + } + } +} + +func TestCursorAgentStartPostsExpectedPayloadAndReturnsImmediately(t *testing.T) { + var calls atomic.Int32 + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodPost || r.URL.Path != "/v1/agents" { + t.Errorf("method/path = %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer "+cursorToolTestKey { + t.Errorf("Authorization = %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "bc-one", "status": "ACTIVE", + "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-one", + }, + "run": map[string]any{ + "id": "run-one", "agentId": "bc-one", "status": "CREATING", + }, + }) + })) + defer srv.Close() + + args := `{ + "action":"start", + "prompt":"fix it", + "model":"composer-2", + "repository_url":"https://github.com/acme/repo", + "starting_ref":"main", + "pull_request_url":"https://github.com/acme/repo/pull/7", + "mode":"plan", + "auto_create_pr":true, + "wait":false + }` + got := (cursorAgentTool{}).Execute(context.Background(), cursorToolTestInput(cursorToolTestConfig(srv.URL), args, nil)) + if got.IsError { + t.Fatalf("start result = %+v", got) + } + if calls.Load() != 1 { + t.Fatalf("calls = %d, want exactly one create request", calls.Load()) + } + prompt := body["prompt"].(map[string]any) + if prompt["text"] != "fix it" || body["mode"] != "plan" { + t.Fatalf("create body = %#v", body) + } + model := body["model"].(map[string]any) + if model["id"] != "composer-2" { + t.Fatalf("model = %#v", model) + } + repos := body["repos"].([]any) + repo := repos[0].(map[string]any) + if repo["url"] != "https://github.com/acme/repo" || + repo["startingRef"] != "main" || + repo["prUrl"] != "https://github.com/acme/repo/pull/7" { + t.Fatalf("repo = %#v", repo) + } + if body["autoCreatePR"] != true || body["skipReviewerRequest"] != true { + t.Fatalf("pointer defaults not applied: %#v", body) + } + if got.Meta["agent_id"] != "bc-one" || got.Meta["run_id"] != "run-one" || + got.Meta["cursor_url"] != "https://cursor.com/agents/bc-one" { + t.Fatalf("meta = %#v", got.Meta) + } + if !strings.Contains(got.Content, "Do not busy-poll") { + t.Fatalf("immediate result lacks polling guidance: %q", got.Content) + } +} + +func TestCursorAgentStartSupportsNoRepository(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + } + if _, exists := body["repos"]; exists { + t.Errorf("no-repo create unexpectedly sent repos: %#v", body["repos"]) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "bc-norepo", "status": "ACTIVE", + "url": "https://cursor.com/agents/bc-norepo", "latestRunId": "run-norepo", + }, + "run": map[string]any{ + "id": "run-norepo", "agentId": "bc-norepo", "status": "CREATING", + }, + }) + })) + defer srv.Close() + + args := `{"action":"start","prompt":"research this","wait":false}` + got := (cursorAgentTool{}).Execute(context.Background(), cursorToolTestInput(cursorToolTestConfig(srv.URL), args, nil)) + if got.IsError || got.Meta["agent_id"] != "bc-norepo" { + t.Fatalf("no-repo result = %+v", got) + } +} + +func TestCursorAgentFollowUpFetchesAgentAndPreservesURL(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch calls.Add(1) { + case 1: + if r.Method != http.MethodGet || r.URL.Path != "/v1/agents/bc-one" { + t.Errorf("first request = %s %s", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-one", "status": "FINISHED", + "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-one", + }) + case 2: + if r.Method != http.MethodPost || r.URL.Path != "/v1/agents/bc-one/runs" { + t.Errorf("second request = %s %s", r.Method, r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + } + if body["mode"] != "agent" || body["prompt"].(map[string]any)["text"] != "add tests" { + t.Errorf("follow-up body = %#v", body) + } + for _, forbidden := range []string{"model", "repos", "autoCreatePR", "skipReviewerRequest"} { + if _, ok := body[forbidden]; ok { + t.Errorf("follow-up body included %s: %#v", forbidden, body) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "run": map[string]any{ + "id": "run-two", "agentId": "bc-one", "status": "CREATING", + }, + }) + default: + t.Errorf("unexpected request %d: %s %s", calls.Load(), r.Method, r.URL.Path) + } + })) + defer srv.Close() + + args := `{"action":"follow_up","agent_id":"bc-one","prompt":"add tests","mode":"agent","wait":false}` + got := (cursorAgentTool{}).Execute(context.Background(), cursorToolTestInput(cursorToolTestConfig(srv.URL), args, nil)) + if got.IsError { + t.Fatalf("follow-up result = %+v", got) + } + if calls.Load() != 2 { + t.Fatalf("calls = %d, want agent read plus run create", calls.Load()) + } + if got.Meta["cursor_url"] != "https://cursor.com/agents/bc-one" || + got.Meta["run_id"] != "run-two" { + t.Fatalf("follow-up meta = %#v", got.Meta) + } +} + +func TestCursorAgentCancelPostsOnce(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodPost || r.URL.Path != "/v1/agents/bc-one/runs/run-one/cancel" { + t.Errorf("cancel request = %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + args := `{"action":"cancel","agent_id":"bc-one","run_id":"run-one"}` + got := (cursorAgentTool{}).Execute(context.Background(), cursorToolTestInput(cursorToolTestConfig(srv.URL), args, nil)) + if got.IsError || calls.Load() != 1 { + t.Fatalf("cancel result = %+v, calls = %d", got, calls.Load()) + } + if got.Meta["agent_id"] != "bc-one" || got.Meta["run_id"] != "run-one" { + t.Fatalf("cancel meta = %#v", got.Meta) + } +} + +// A cancel 404 can mean either the run or the whole agent is gone, and only +// Cursor's typed code says which. +func TestCursorAgentCancelNotFoundUsesTypedCode(t *testing.T) { + for _, tc := range []struct { + code string + want string + }{ + {code: "agent_not_found", want: "Cursor agent not found"}, + {code: "run_not_found", want: "Cursor run not found"}, + {code: "", want: "Cursor run not found"}, + } { + t.Run("code="+tc.code, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": tc.code, "message": "missing " + cursorToolTestKey, + }) + })) + defer srv.Close() + + got := (cursorAgentTool{}).Execute( + context.Background(), + cursorToolTestInput( + cursorToolTestConfig(srv.URL), + `{"action":"cancel","agent_id":"bc-one","run_id":"run-one"}`, + nil, + ), + ) + if !got.IsError || !strings.HasPrefix(got.Content, tc.want) { + t.Fatalf("cancel 404 result = %+v, want %q", got, tc.want) + } + if strings.Contains(got.Content, cursorToolTestKey) { + t.Fatalf("cancel 404 leaked the API key: %s", got.Content) + } + }) + } +} + +func TestCursorAgentStatusResolvesLatestRunWithoutStreaming(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch calls.Add(1) { + case 1: + if r.URL.Path != "/v1/agents/bc-one" { + t.Errorf("agent path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-one", "status": "FINISHED", + "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-latest", + }) + case 2: + if r.URL.Path != "/v1/agents/bc-one/runs/run-latest" { + t.Errorf("run path = %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-latest", "agentId": "bc-one", "status": "FINISHED", + "durationMs": 4200, "result": "all done", + "git": map[string]any{"branches": []any{ + map[string]any{ + "repoUrl": "https://github.com/acme/repo", + "branch": "cursor/fix", + "prUrl": "https://github.com/acme/repo/pull/8", + }, + }}, + }) + default: + t.Errorf("unexpected request %d: %s", calls.Load(), r.URL.Path) + } + })) + defer srv.Close() + + got := (cursorAgentStatusTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), `{"agent_id":"bc-one"}`, nil), + ) + if got.IsError || calls.Load() != 2 { + t.Fatalf("status result = %+v, calls = %d", got, calls.Load()) + } + for _, text := range []string{ + "bc-one", "run-latest", "FINISHED", "all done", + "cursor/fix", "https://github.com/acme/repo/pull/8", "Do not busy-poll", + } { + if !strings.Contains(got.Content, text) { + t.Errorf("status content missing %q: %s", text, got.Content) + } + } + metaJSON, _ := json.Marshal(got.Meta) + for _, text := range []string{"all done", "cursor/fix", "https://github.com/acme/repo/pull/8"} { + if !strings.Contains(string(metaJSON), text) { + t.Errorf("status meta missing %q: %s", text, metaJSON) + } + } +} + +func TestCursorAgentWaitStreamsBoundedProgressAndFinalMetadata(t *testing.T) { + longChunk := strings.Repeat("界", 2001) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/agents/bc-one": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-one", "status": "RUNNING", + "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-one", + }) + case "/v1/agents/bc-one/runs/run-one/stream": + w.Header().Set("Content-Type", "text/event-stream") + writeCursorSSE(w, "status", map[string]any{"runId": "run-one", "status": "RUNNING"}) + writeCursorSSE(w, "assistant", map[string]any{"text": longChunk}) + writeCursorSSE(w, "thinking", map[string]any{"text": "checking tests"}) + writeCursorSSE(w, "tool_call", map[string]any{"name": "grep", "status": "running"}) + writeCursorSSE(w, "result", map[string]any{ + "runId": "run-one", "status": "FINISHED", "text": "fixed", + "durationMs": 9001, + "git": map[string]any{"branches": []any{ + map[string]any{ + "repoUrl": "https://github.com/acme/repo", + "branch": "cursor/fixed", + "prUrl": "https://github.com/acme/repo/pull/9", + }, + }}, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + var progress []Progress + got := (cursorAgentStatusTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), `{"agent_id":"bc-one","wait":true}`, &progress), + ) + if got.IsError { + t.Fatalf("wait result = %+v", got) + } + if len(progress) != 5 { + t.Fatalf("progress = %#v, want status/assistant/thinking/tool/result", progress) + } + if progress[0].Message != "Cursor status" { + t.Fatalf("status progress = %+v", progress[0]) + } + if progress[1].Message != "Cursor assistant" || + !utf8.ValidString(progress[1].Chunk) || + utf8.RuneCountInString(progress[1].Chunk) != 2001 || + !strings.HasSuffix(progress[1].Chunk, "…") { + t.Fatalf("bounded assistant progress = message %q, runes %d, valid=%v", + progress[1].Message, utf8.RuneCountInString(progress[1].Chunk), utf8.ValidString(progress[1].Chunk)) + } + if progress[2].Message != "Cursor thinking" || progress[2].Chunk != "checking tests" { + t.Fatalf("thinking progress = %+v", progress[2]) + } + if progress[3].Message != "Cursor tool grep running" { + t.Fatalf("tool progress = %+v", progress[3]) + } + for _, p := range progress { + if p.Tool != "cursor_agent" { + t.Errorf("progress tool = %q, want cursor_agent", p.Tool) + } + } + for _, text := range []string{"fixed", "cursor/fixed", "https://github.com/acme/repo/pull/9"} { + if !strings.Contains(got.Content, text) { + t.Errorf("final content missing %q: %s", text, got.Content) + } + } + if got.Meta["duration_ms"] != int64(9001) { + t.Fatalf("duration meta = %#v", got.Meta["duration_ms"]) + } + metaJSON, _ := json.Marshal(got.Meta) + if !strings.Contains(string(metaJSON), "cursor/fixed") || + !strings.Contains(string(metaJSON), "https://github.com/acme/repo/pull/9") { + t.Fatalf("git meta = %s", metaJSON) + } +} + +func TestCursorAgentWaitBoundsAndNormalizesEveryProgressField(t *testing.T) { + invalidAndLong := strings.Repeat("界", 10) + string([]byte{0xff}) + strings.Repeat("界", 2100) + longToolName := strings.Repeat("tool", 600) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/agents/bc-one": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-one", "status": "RUNNING", + "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-one", + }) + case "/v1/agents/bc-one/runs/run-one/stream": + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, "event: ") + _, _ = w.Write([]byte(invalidAndLong)) + _, _ = io.WriteString(w, "\ndata: {}\n\n") + _, _ = io.WriteString(w, "event: assistant\ndata: {\"text\":\"") + _, _ = w.Write([]byte(invalidAndLong)) + _, _ = io.WriteString(w, "\"}\n\n") + writeCursorSSE(w, "tool_call", map[string]any{"name": longToolName, "status": "running"}) + writeCursorSSE(w, "result", map[string]any{ + "runId": "run-one", "status": "FINISHED", "text": "fixed", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + var progress []Progress + got := (cursorAgentStatusTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), `{"agent_id":"bc-one","wait":true}`, &progress), + ) + if got.IsError { + t.Fatalf("wait result = %+v", got) + } + if len(progress) != 4 { + t.Fatalf("progress = %#v, want event/assistant/tool/result", progress) + } + + var boundedMessages, boundedChunks int + for i, update := range progress { + for field, value := range map[string]string{ + "message": update.Message, + "chunk": update.Chunk, + } { + if !utf8.ValidString(value) { + t.Errorf("progress[%d].%s is invalid UTF-8", i, field) + } + if gotRunes := utf8.RuneCountInString(value); gotRunes > 2001 { + t.Errorf("progress[%d].%s has %d runes, want at most 2001", i, field, gotRunes) + } + if strings.HasSuffix(value, "…") { + if gotRunes := utf8.RuneCountInString(value); gotRunes != 2001 { + t.Errorf("progress[%d].%s has %d bounded runes, want 2001", i, field, gotRunes) + } + if field == "message" { + boundedMessages++ + } else { + boundedChunks++ + } + } + } + } + if boundedMessages != 2 { + t.Errorf("bounded messages = %d, want oversized event and tool messages", boundedMessages) + } + if boundedChunks != 1 { + t.Errorf("bounded chunks = %d, want oversized assistant chunk", boundedChunks) + } + if !strings.Contains(progress[0].Message, "\uFFFD") || + !strings.Contains(progress[1].Chunk, "\uFFFD") { + t.Fatal("invalid UTF-8 was not normalized with replacement runes") + } +} + +func TestCursorAgentTimeoutPreservesRecoverableIDsWithoutCancel(t *testing.T) { + var cancelCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/agents": + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "bc-one", "status": "ACTIVE", + "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-one", + }, + "run": map[string]any{ + "id": "run-one", "agentId": "bc-one", "status": "CREATING", + }, + }) + case "/v1/agents/bc-one/runs/run-one/stream": + w.Header().Set("Content-Type", "text/event-stream") + w.(http.Flusher).Flush() + <-r.Context().Done() + case "/v1/agents/bc-one/runs/run-one/cancel": + cancelCalls.Add(1) + w.WriteHeader(http.StatusNoContent) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + got := (cursorAgentTool{}).Execute( + ctx, + cursorToolTestInput(cursorToolTestConfig(srv.URL), `{"action":"start","prompt":"fix it"}`, nil), + ) + if !got.IsError || !strings.Contains(got.Content, "remote Cursor run may still be active") { + t.Fatalf("timeout result = %+v", got) + } + if got.Meta["agent_id"] != "bc-one" || got.Meta["run_id"] != "run-one" { + t.Fatalf("timeout lost recovery ids: %#v", got.Meta) + } + if cancelCalls.Load() != 0 { + t.Fatalf("timeout remotely canceled the run %d time(s)", cancelCalls.Load()) + } +} + +func TestCursorAgentErrorsAreClassifiedAndSecretSafe(t *testing.T) { + t.Run("server error is redacted and create is not retried", func(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, `{"message":"rejected synthetic-key"}`) + })) + defer srv.Close() + + var progress []Progress + got := (cursorAgentTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), `{"action":"start","prompt":"fix it","wait":false}`, &progress), + ) + if !got.IsError || calls.Load() != 1 { + t.Fatalf("server error result = %+v, calls = %d", got, calls.Load()) + } + metaJSON, _ := json.Marshal(got.Meta) + combined := got.Content + got.Display + string(metaJSON) + for _, p := range progress { + combined += p.Message + p.Chunk + } + if strings.Contains(combined, cursorToolTestKey) { + t.Fatalf("result leaked synthetic key: %s", combined) + } + }) + + t.Run("rate limit includes retry after", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Retry-After", "7") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"message":"slow down"}`) + })) + defer srv.Close() + got := (cursorAgentTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), `{"action":"start","prompt":"fix it","wait":false}`, nil), + ) + if !got.IsError || !strings.Contains(got.Content, "rate limit") || + got.Meta["retry_after_seconds"] != 7 { + t.Fatalf("rate-limit result = %+v", got) + } + }) + + t.Run("agent and run 404s are distinguished", func(t *testing.T) { + agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"message":"missing"}`) + })) + defer agentServer.Close() + got := (cursorAgentStatusTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(agentServer.URL), `{"agent_id":"bc-missing"}`, nil), + ) + if !got.IsError || !strings.HasPrefix(got.Content, "Cursor agent not found") { + t.Fatalf("agent 404 result = %+v", got) + } + + runServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/agents/bc-one" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-one", "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-missing", + }) + return + } + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"message":"missing"}`) + })) + defer runServer.Close() + got = (cursorAgentStatusTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(runServer.URL), `{"agent_id":"bc-one"}`, nil), + ) + if !got.IsError || !strings.HasPrefix(got.Content, "Cursor run not found") { + t.Fatalf("run 404 result = %+v", got) + } + }) + + t.Run("conflict text is preserved without retry", func(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"message":"agent already active"}`) + })) + defer srv.Close() + got := (cursorAgentTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), `{"action":"start","prompt":"fix it","wait":false}`, nil), + ) + if !got.IsError || got.Content != "agent already active" || calls.Load() != 1 { + t.Fatalf("conflict result = %+v, calls = %d", got, calls.Load()) + } + }) +} + +func TestCursorAgentRedactsSecretFromStreamProgressAndResult(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/agents/bc-one": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-one", "status": "RUNNING", + "url": "https://cursor.com/agents/bc-one?token=" + cursorToolTestKey, + "latestRunId": "run-one", + }) + case "/v1/agents/bc-one/runs/run-one/stream": + w.Header().Set("Content-Type", "text/event-stream") + writeCursorSSE(w, "assistant", map[string]any{"text": "saw " + cursorToolTestKey}) + writeCursorSSE(w, "tool_call", map[string]any{"name": cursorToolTestKey, "status": "running"}) + writeCursorSSE(w, "result", map[string]any{ + "runId": "run-one", "status": "FINISHED", + "text": "finished with " + cursorToolTestKey, + "git": map[string]any{"branches": []any{ + map[string]any{ + "repoUrl": "https://github.com/acme/" + cursorToolTestKey, + "branch": "cursor/" + cursorToolTestKey, + "prUrl": "https://github.com/acme/repo/pull/1?token=" + cursorToolTestKey, + }, + }}, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + var progress []Progress + got := (cursorAgentStatusTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), `{"agent_id":"bc-one","wait":true}`, &progress), + ) + metaJSON, _ := json.Marshal(got.Meta) + combined := got.Content + got.Display + string(metaJSON) + for _, p := range progress { + combined += p.Message + p.Chunk + } + if strings.Contains(combined, cursorToolTestKey) { + t.Fatalf("stream output leaked synthetic key: %s", combined) + } +} + +func TestCursorAgentRedactsTrimmedConfiguredSecretFromSnapshot(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/agents/bc-one": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-one", "status": "RUNNING", + "url": "https://cursor.com/agents/bc-one?token=" + cursorToolTestKey, + "latestRunId": "run-one", + }) + case "/v1/agents/bc-one/runs/run-one": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-one", "status": "FINISHED", + "result": "finished with " + cursorToolTestKey, + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := cursorToolTestConfig(srv.URL) + provider := cfg.Providers["cursor"] + provider.APIKey = " " + cursorToolTestKey + " " + cfg.Providers["cursor"] = provider + got := (cursorAgentStatusTool{}).Execute( + context.Background(), + cursorToolTestInput(cfg, `{"agent_id":"bc-one"}`, nil), + ) + metaJSON, _ := json.Marshal(got.Meta) + if combined := got.Content + got.Display + string(metaJSON); strings.Contains(combined, cursorToolTestKey) { + t.Fatalf("snapshot output leaked trimmed synthetic key: %s", combined) + } +} + +func TestCursorAgentRedactsSecretFromInvalidLatestRunMetadata(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-one", "status": "RUNNING", + "url": "https://cursor.com/agents/bc-one", + "latestRunId": "invalid-" + cursorToolTestKey, + }) + })) + defer srv.Close() + + got := (cursorAgentStatusTool{}).Execute( + context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), `{"agent_id":"bc-one"}`, nil), + ) + metaJSON, _ := json.Marshal(got.Meta) + if combined := got.Content + got.Display + string(metaJSON); strings.Contains(combined, cursorToolTestKey) { + t.Fatalf("invalid latest-run result leaked synthetic key: %s", combined) + } +} + +func TestCursorAgentToolsRegisteredInOrdinaryAgentToolsets(t *testing.T) { + for _, name := range []string{"cursor_agent", "cursor_agent_status"} { + if _, ok := Default().Get(name); !ok { + t.Errorf("%s is not registered", name) + } + for _, set := range []string{"coding", "vibecoder", "default"} { + if !containsTool(ExpandToolset(set), name) { + t.Errorf("toolset %q does not contain %s", set, name) + } + } + for _, set := range []string{"minimal", "research", "browser", "social", "security", "osint", "reverse", "intercept"} { + if containsTool(ExpandToolset(set), name) { + t.Errorf("toolset %q unexpectedly contains %s", set, name) + } + } + } + if !containsTool(ExpandToolset("default"), "read_file") || + !containsTool(ExpandToolset("social"), "temp_mail") { + t.Fatal("adding Cursor tools replaced ordinary toolset members") + } +} diff --git a/internal/tools/register.go b/internal/tools/register.go index c282e9f..8efd2ba 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -35,6 +35,8 @@ func init() { askUserTool{}, scheduleTool{}, diagnosticsTool{}, + cursorAgentTool{}, + cursorAgentStatusTool{}, // Native OSINT reconnaissance toolset. osintDNSTool{}, osintDorksTool{}, osintWhoisTool{}, osintIPTool{}, osintUsernameTool{}, osintGithubTool{}, osintEmailTool{}, osintBreachTool{}, osintReputationTool{}, diff --git a/internal/tools/registry.go b/internal/tools/registry.go index c5e00bf..f89627f 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -240,7 +240,7 @@ var Toolsets = map[string][]string{ "minimal": {"read_file", "list_files", "grep", "todo"}, "coding": { "read_file", "read_document", "write_file", "edit_file", "list_files", "glob", "grep", - "terminal", "process", "todo", "board", "project_info", "set_soul", "skill", "delegate_task", "task", "list_roles", "diagnostics", "http_request", "ask_user", "schedule", + "terminal", "process", "todo", "board", "project_info", "set_soul", "skill", "delegate_task", "task", "list_roles", "diagnostics", "http_request", "ask_user", "schedule", "cursor_agent", "cursor_agent_status", }, "research": { "read_file", "read_document", "web_search", "web_fetch", "http_request", "browser", "grep", "todo", "memory", @@ -269,6 +269,7 @@ var Toolsets = map[string][]string{ "web_fetch", "http_request", "browser", "web_search", "terminal", "process", "read_file", "write_file", "list_files", "glob", "grep", "check_dependencies", "todo", "report_finding", "add_intel", "methodology_status", "skill", "rag_search", + "cursor_agent", "cursor_agent_status", }, "intercept": { "intercept", "browser", "http_request", "web_fetch", "solve_captcha", @@ -283,7 +284,7 @@ var Toolsets = map[string][]string{ "default": { "read_file", "read_document", "write_file", "edit_file", "list_files", "glob", "grep", "terminal", "process", "web_search", "web_fetch", "http_request", "browser", "todo", "board", "project_info", "set_soul", "memory", "list_proxies", "vps_run", "vps_upload", "vps_download", - "session_search", "rag_search", "rag_index", "skill", "delegate_task", "task", "list_roles", "image_generate", "view_image", "speak", "transcribe", "computer", "diagnostics", "ask_user", "schedule", + "session_search", "rag_search", "rag_index", "skill", "delegate_task", "task", "list_roles", "image_generate", "view_image", "speak", "transcribe", "computer", "diagnostics", "ask_user", "schedule", "cursor_agent", "cursor_agent_status", "osint_dns", "osint_dorks", "osint_whois", "osint_ip", "osint_username", "osint_github", "osint_email", "osint_email_full", "osint_breach", "osint_shodan", "osint_reputation", "osint_crypto", "osint_domain", "osint_phone", "osint_scrape", "osint_paste", "osint_footprint", "osint_pivot", "osint_google", "osint_dorks_live", "check_dependencies", "re_info", "re_strings", "re_analyze", "re_decompile", "solve_captcha", "intercept", "email_read", "temp_mail", "social_browser", "social_account", }, diff --git a/internal/tui/pickers.go b/internal/tui/pickers.go index 32a8c40..e65d8ba 100644 --- a/internal/tui/pickers.go +++ b/internal/tui/pickers.go @@ -259,11 +259,20 @@ func (m *Model) openProviderPicker() { // connect one that is not yet set up. func (m *Model) selectProvider(id string) { if providers.Connected(m.cfg, id) { + if providers.CapabilityOf(m.cfg, id) == providers.CapabilityAgent { + m.setStatus(id + " agent integration is connected") + m.pushSystem("Use the cursor_agent tool to run Cursor Cloud Agents.") + return + } m.activateProvider(id, "") return } info, ok := providers.For(id) if !ok { + if providers.CapabilityOf(m.cfg, id) == providers.CapabilityAgent { + m.pushSystem("Use the cursor_agent tool to run this agent integration.") + return + } m.activateProvider(id, "") // unknown/custom provider — just switch to it return } @@ -284,6 +293,14 @@ func (m *Model) selectProvider(id string) { // activateProvider connects/switches to a provider and persists the change. func (m *Model) activateProvider(id, key string) { + if providers.CapabilityOf(m.cfg, id) == providers.CapabilityAgent { + providers.Connect(m.cfg, id, key) + m.saveConfig() + m.setStatus("connected " + id + " agent integration") + m.pushSystem("Connected to " + id + ". Active model remains " + m.cfg.Model.Default + + " (" + m.cfg.Model.Provider + "). Use the cursor_agent tool.") + return + } providers.Activate(m.cfg, id, key) m.saveConfig() if key != "" { diff --git a/internal/tui/provider_test.go b/internal/tui/provider_test.go new file mode 100644 index 0000000..11f3964 --- /dev/null +++ b/internal/tui/provider_test.go @@ -0,0 +1,38 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/enowdev/antares/internal/config" +) + +func TestCursorProviderConnectAndSelectPreserveActiveModel(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + beforeProvider, beforeModel := cfg.Model.Provider, cfg.Model.Default + if err := config.Save(cfg); err != nil { + t.Fatal(err) + } + m := &Model{cfg: cfg} + + m.activateProvider("cursor", "synthetic-key") + if m.cfg.Model.Provider != beforeProvider || m.cfg.Model.Default != beforeModel { + t.Fatalf("model changed to %s/%s", m.cfg.Model.Provider, m.cfg.Model.Default) + } + connected, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if p := connected.Providers["cursor"]; !p.Enabled || p.APIKey != "synthetic-key" || p.Kind != "cursor-agent" { + t.Fatalf("cursor provider = %+v", p) + } + + m.selectProvider("cursor") + if m.cfg.Model.Provider != beforeProvider || m.cfg.Model.Default != beforeModel { + t.Fatalf("model changed to %s/%s", m.cfg.Model.Provider, m.cfg.Model.Default) + } + if len(m.blocks) == 0 || !strings.Contains(m.blocks[len(m.blocks)-1].text, "cursor_agent") { + t.Fatalf("system message = %+v", m.blocks) + } +} diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index 45dd586..6acaaa8 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -506,6 +506,9 @@ const en = { 'providers.backToList': 'All providers', 'providers.manage': 'Manage', 'providers.manageDesc': 'Credentials, models, and advanced settings for this provider.', + 'providers.agentIntegration': 'Agent integration', + 'providers.agentManageDesc': 'Credentials, available agent models, and advanced settings.', + 'providers.agentModelsReadOnly': 'Models available to this Cursor API key. Cursor chooses the default when no model is specified.', 'providers.secCredentials': 'Credentials', 'providers.secModels': 'Models', 'providers.secAdvanced': 'Advanced', @@ -1518,6 +1521,9 @@ const id: Dict = { 'providers.backToList': 'Semua provider', 'providers.manage': 'Kelola', 'providers.manageDesc': 'Kredensial, model, dan pengaturan lanjutan untuk provider ini.', + 'providers.agentIntegration': 'Integrasi agent', + 'providers.agentManageDesc': 'Kredensial, model agent yang tersedia, dan pengaturan lanjutan.', + 'providers.agentModelsReadOnly': 'Model yang tersedia untuk API key Cursor ini. Cursor memilih default bila model tidak ditentukan.', 'providers.secCredentials': 'Kredensial', 'providers.secModels': 'Model', 'providers.secAdvanced': 'Lanjutan', diff --git a/web/src/lib/providerCapabilities.test.mjs b/web/src/lib/providerCapabilities.test.mjs new file mode 100644 index 0000000..9c1d3a7 --- /dev/null +++ b/web/src/lib/providerCapabilities.test.mjs @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test' +import { agentModelsErrorText, isAgentProvider, providerModelsPath } from './providerCapabilities.ts' + +describe('provider capabilities', () => { + test('classifies Cursor as an agent integration', () => { + expect(isAgentProvider({ capability: 'agent' })).toBe(true) + expect(isAgentProvider({ capability: 'llm' })).toBe(false) + }) + + test('uses provider-specific models for agents only', () => { + expect(providerModelsPath({ id: 'cursor', capability: 'agent' })) + .toBe('/providers/cursor/models') + expect(providerModelsPath({ id: 'openai', capability: 'llm' })).toBeNull() + }) + + test('uses the embedded agent-model discovery error from a 200 response', () => { + expect(agentModelsErrorText({ error: 'Cursor API key expired' }, undefined)) + .toBe('Cursor API key expired') + }) + + test('uses a thrown request error when model discovery has no embedded error', () => { + expect(agentModelsErrorText({ models: [] }, new Error('Network unavailable'))) + .toBe('Network unavailable') + }) + + test('prefers the embedded response error deterministically', () => { + expect(agentModelsErrorText( + { error: 'Cursor API key expired' }, + new Error('Network unavailable'), + )).toBe('Cursor API key expired') + }) +}) diff --git a/web/src/lib/providerCapabilities.ts b/web/src/lib/providerCapabilities.ts new file mode 100644 index 0000000..e496e06 --- /dev/null +++ b/web/src/lib/providerCapabilities.ts @@ -0,0 +1,23 @@ +export type ProviderCapability = 'llm' | 'agent' + +export interface ProviderCapabilityInfo { + id: string + capability?: ProviderCapability +} + +export function isAgentProvider(provider: Pick): boolean { + return provider.capability === 'agent' +} + +export function providerModelsPath(provider: ProviderCapabilityInfo): string | null { + return isAgentProvider(provider) + ? `/providers/${encodeURIComponent(provider.id)}/models` + : null +} + +export function agentModelsErrorText( + response: { error?: string } | undefined, + requestError: Error | undefined, +): string | undefined { + return response?.error ?? requestError?.message +} diff --git a/web/src/pages/ProvidersPage.tsx b/web/src/pages/ProvidersPage.tsx index d58d2ce..aa69b8b 100644 --- a/web/src/pages/ProvidersPage.tsx +++ b/web/src/pages/ProvidersPage.tsx @@ -12,6 +12,7 @@ import { Trash, } from '@phosphor-icons/react' import { del, get, post } from '@/lib/api' +import { agentModelsErrorText, isAgentProvider, providerModelsPath, type ProviderCapability } from '@/lib/providerCapabilities' import { useApi } from '@/lib/hooks' import { useI18n } from '@/lib/i18n' import { cn } from '@/lib/utils' @@ -40,6 +41,7 @@ interface ProviderInfo { local: boolean base_url: string active: boolean + capability: ProviderCapability hint?: string key_hint?: string key_url?: string @@ -159,7 +161,8 @@ function ProvidersTab({ onOpenModels }: { onOpenModels: () => void }) { {providerName(p.label)} - {p.active ? {t('models.activeNow')} : null} + {isAgentProvider(p) ? {t('providers.agentIntegration')} : null} + {p.active && !isAgentProvider(p) ? {t('models.activeNow')} : null} {p.base_url || p.kind} @@ -243,6 +246,19 @@ interface AllModel { context_window: number } +interface AgentModel { + id: string + name: string + description?: string + parameters?: unknown[] +} + +interface AgentModelsResponse { + models: AgentModel[] + needs_key?: boolean + error?: string +} + /** * Manage one provider in a modal: credentials, its models (add/remove with an * auto-fetched context window), and advanced settings. Each section saves to @@ -273,9 +289,12 @@ function ProviderModal({ const [busy, setBusy] = useState(false) const [error, setError] = useState() - // Models added to this provider (from the combined list, filtered to it). - const modelsState = useApi<{ models: AllModel[] }>('/model/list-all') - const myModels = (modelsState.data?.models ?? []).filter((m) => m.provider === p.id) + // Both hooks must run for every provider; the irrelevant endpoint is disabled. + const agentOnly = isAgentProvider(p) + const agentModelsState = useApi(providerModelsPath(p)) + const llmModelsState = useApi<{ models: AllModel[] }>(agentOnly ? null : '/model/list-all') + const myModels = (llmModelsState.data?.models ?? []).filter((m) => m.provider === p.id) + const agentModelsError = agentModelsErrorText(agentModelsState.data, agentModelsState.error) const [newModel, setNewModel] = useState('') const [newCtx, setNewCtx] = useState('') const [ctxAuto, setCtxAuto] = useState(false) @@ -344,7 +363,7 @@ function ProviderModal({ setNewModel('') setNewCtx('') setCtxAuto(false) - modelsState.reload() + llmModelsState.reload() onChanged() } finally { setModelBusy(false) @@ -353,7 +372,7 @@ function ProviderModal({ const removeModel = async (id: string) => { await del(`/providers/${encodeURIComponent(p.id)}/model/${encodeURIComponent(id)}`) - modelsState.reload() + llmModelsState.reload() onChanged() } @@ -387,7 +406,7 @@ function ProviderModal({ {providerName(p.label)} - {t('providers.manageDesc')} + {agentOnly ? t('providers.agentManageDesc') : t('providers.manageDesc')}
@@ -452,61 +471,88 @@ function ProviderModal({ {section === 'models' ? ( <> -
- -
- { - setNewModel(e.target.value) - setCtxAuto(false) - }} - onBlur={() => autoFetchCtx(newModel)} - placeholder={t('providers.modelIdPlaceholder')} - className="sm:flex-1" - /> - setNewCtx(e.target.value)} - placeholder={t('providers.ctxPlaceholder')} - inputMode="numeric" - className="sm:w-40" - /> - -
-

- {ctxAuto ? t('providers.ctxAuto') : t('providers.ctxHint')} -

-
- - {myModels.length === 0 ? ( -

{t('models.none')}

+ {agentOnly ? ( + <> +

{t('providers.agentModelsReadOnly')}

+ {agentModelsState.data?.needs_key ? ( +

{t('models.needsKey')}

+ ) : agentModelsError ? ( +

+ {agentModelsError} +

+ ) : (agentModelsState.data?.models ?? []).length === 0 ? ( +

{t('models.none')}

+ ) : ( +
+ {(agentModelsState.data?.models ?? []).map((m) => ( +
+

{m.id}

+

{m.name}

+ {m.description ?

{m.description}

: null} +
+ ))} +
+ )} + ) : ( -
- {myModels.map((m) => ( -
-
-

{m.id}

- {m.context_window > 0 ? ( -

- {t('models.ctx', { n: Math.round(m.context_window / 1000) })} -

- ) : null} -
-
- ))} -
+

+ {ctxAuto ? t('providers.ctxAuto') : t('providers.ctxHint')} +

+
+ + {myModels.length === 0 ? ( +

{t('models.none')}

+ ) : ( +
+ {myModels.map((m) => ( +
+
+

{m.id}

+ {m.context_window > 0 ? ( +

+ {t('models.ctx', { n: Math.round(m.context_window / 1000) })} +

+ ) : null} +
+ +
+ ))} +
+ )} + )} ) : null}