cursor-opencode-provider 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -12
- package/dist/agent-url.d.ts +17 -0
- package/dist/agent-url.js +74 -0
- package/dist/context/auth-store.d.ts +21 -0
- package/dist/context/auth-store.js +64 -0
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/paths.d.ts +11 -0
- package/dist/context/paths.js +25 -1
- package/dist/index.d.ts +7 -0
- package/dist/language-model.js +35 -19
- package/dist/models.d.ts +4 -4
- package/dist/models.js +11 -11
- package/dist/plugin.d.ts +13 -0
- package/dist/plugin.js +206 -65
- package/dist/protocol/client-version.js +2 -16
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/transport/connect.d.ts +29 -3
- package/dist/transport/connect.js +87 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Use [Cursor](https://cursor.com) subscription models from [OpenCode](https://opencode.ai) by speaking Cursor's Connect-RPC agent protocol.
|
|
4
4
|
|
|
5
|
-
This project is a custom **AI SDK provider** (`LanguageModelV3`) plus an **OpenCode plugin** that handles authentication and model discovery. Instead of calling a generic chat-completions API, it encodes and decodes Cursor's protobuf agent protocol over HTTP/2 to
|
|
5
|
+
This project is a custom **AI SDK provider** (`LanguageModelV3`) plus an **OpenCode plugin** that handles authentication and model discovery. Instead of calling a generic chat-completions API, it encodes and decodes Cursor's protobuf agent protocol over HTTP/2 to Cursor's agent backend.
|
|
6
6
|
|
|
7
7
|
> **Status:** Usable end-to-end in OpenCode (auth, models, streaming, tools). See [Known limitations](#known-limitations).
|
|
8
8
|
|
|
9
|
+
## Demo
|
|
10
|
+
|
|
11
|
+
OpenCode driving a Cursor-routed Grok model through this provider:
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
9
15
|
## Features
|
|
10
16
|
|
|
11
17
|
- **OpenCode integration** — registers a `cursor` provider with auth hooks and cached model list
|
|
@@ -41,7 +47,7 @@ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun
|
|
|
41
47
|
}
|
|
42
48
|
```
|
|
43
49
|
|
|
44
|
-
Pin a version if you want: `"cursor-opencode-provider@0.1.
|
|
50
|
+
Pin a version if you want: `"cursor-opencode-provider@0.1.4"`.
|
|
45
51
|
|
|
46
52
|
### From a local clone
|
|
47
53
|
|
|
@@ -69,7 +75,7 @@ Point config at the built files with absolute `file://` URLs:
|
|
|
69
75
|
|
|
70
76
|
## OpenCode setup
|
|
71
77
|
|
|
72
|
-
If the `cursor` provider block is omitted, the classic plugin auto-registers it on startup (as **Cursor Integration**) using this package's entry. Model entries
|
|
78
|
+
If the `cursor` provider block is omitted, the classic plugin auto-registers it on startup (as **Cursor Integration**) using this package's entry. Model entries come from the local cache, which is filled after auth and again on startup when the cache is empty but credentials remain.
|
|
73
79
|
|
|
74
80
|
For OpenCode builds that use the Effect/Promise **v2** plugin API (`plugins` field), also load:
|
|
75
81
|
|
|
@@ -96,7 +102,15 @@ Choose the **cursor** provider, then one of:
|
|
|
96
102
|
| **Cursor account (browser login)** | PKCE OAuth — opens cursor.com to sign in |
|
|
97
103
|
| **API key** | Paste a key from [cursor.com/settings](https://cursor.com/settings) (`sk-...`) |
|
|
98
104
|
|
|
99
|
-
After login, the plugin fetches your available models and writes them to
|
|
105
|
+
After login, the plugin fetches your available models and writes them to `~/.cache/opencode/cursor-models.json` (or `$XDG_CACHE_HOME/opencode/` when set). On later startups, if that cache is missing or empty but Cursor auth is still present, the plugin fetches again during config load.
|
|
106
|
+
|
|
107
|
+
### Paths (XDG)
|
|
108
|
+
|
|
109
|
+
| Kind | Default | Override |
|
|
110
|
+
|------|---------|----------|
|
|
111
|
+
| Model / version **cache** | `~/.cache/opencode/` | `$XDG_CACHE_HOME/opencode/` |
|
|
112
|
+
| OpenCode **auth** (`auth.json`) | `~/.local/share/opencode/` | `$XDG_DATA_HOME/opencode/` |
|
|
113
|
+
| OpenCode **config** (AGENTS, skills, …) | `~/.config/opencode/` | (config dir helper; not the model cache) |
|
|
100
114
|
|
|
101
115
|
### Select a model
|
|
102
116
|
|
|
@@ -114,25 +128,30 @@ import { createCursor } from "cursor-opencode-provider"
|
|
|
114
128
|
const cursor = createCursor({
|
|
115
129
|
name: "cursor",
|
|
116
130
|
accessToken: process.env.CURSOR_ACCESS_TOKEN,
|
|
131
|
+
// apiBaseURL: "https://api2.cursor.sh",
|
|
132
|
+
// agentBaseURL: "https://agentn.us.api5.cursor.sh", // explicit Run host override
|
|
133
|
+
// telemetryEnabled: true, // opt in to GetServerConfig telemetry
|
|
117
134
|
})
|
|
118
135
|
|
|
119
136
|
const model = cursor.languageModel("composer-2.5")
|
|
120
137
|
// model implements AI SDK LanguageModelV3 (doStream / doGenerate)
|
|
121
138
|
```
|
|
122
139
|
|
|
123
|
-
Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `
|
|
140
|
+
Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `apiBaseURL`, `agentBaseURL`, `headers`, `telemetryEnabled`. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`.
|
|
124
141
|
|
|
125
142
|
## Environment variables
|
|
126
143
|
|
|
127
144
|
| Variable | Description |
|
|
128
145
|
|----------|-------------|
|
|
129
|
-
| `CURSOR_CONFIG_DIR` | Override directory for `cursor-models.json` cache (defaults to the OpenCode directory passed into the plugin) |
|
|
130
146
|
| `CURSOR_WEBSITE_URL` | Override OAuth login base URL (default `https://cursor.com`) |
|
|
131
|
-
| `CURSOR_API_BASE_URL` | Override API base for auth
|
|
147
|
+
| `CURSOR_API_BASE_URL` | Override API base for auth, model discovery, and `GetServerConfig` agent URL resolution (default `https://api2.cursor.sh`) |
|
|
148
|
+
| `CURSOR_GET_SERVER_CONFIG_TELEMETRY` | Set to `1` or `true` to opt the `GetServerConfig` lookup into telemetry in OpenCode/plugin usage |
|
|
132
149
|
| `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
|
|
133
150
|
| `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default `/tmp/cursor-provider-debug.log`) |
|
|
151
|
+
| `XDG_CACHE_HOME` | When set, model/version caches go under `$XDG_CACHE_HOME/opencode/` instead of `~/.cache/opencode/` |
|
|
152
|
+
| `XDG_DATA_HOME` | When set, OpenCode `auth.json` is read from `$XDG_DATA_HOME/opencode/` instead of `~/.local/share/opencode/` |
|
|
134
153
|
|
|
135
|
-
`createCursor({
|
|
154
|
+
`createCursor({ agentBaseURL })` overrides the agent Run host. When unset, the provider resolves the host from Cursor's `GetServerConfig` API (`agentUrlConfig.agentnUrl`, region-specific — e.g. `agentn.us.api5.cursor.sh`, `agent-gcpp-uswest.api5.cursor.sh`) once per process and holds it in memory (never written to disk), so a held-open Run stream is never repointed mid-session. Explicit agent overrides and GetServerConfig results are validated as HTTPS `*.cursor.sh` hosts (Cursor's agent hostnames vary and may change); non-`cursor.sh` hosts are rejected. The lookup sends `{ "telem_enabled": false }` by default; set `telemetryEnabled: true` in provider config, or `CURSOR_GET_SERVER_CONFIG_TELEMETRY=1` for OpenCode/plugin usage, to opt in. If the lookup fails or does not return a valid Cursor agent host, the model call fails clearly instead of falling back to `agentn.global.api5.cursor.sh`.
|
|
136
155
|
|
|
137
156
|
## Development
|
|
138
157
|
|
|
@@ -152,7 +171,7 @@ OpenCode
|
|
|
152
171
|
└── createCursor() → LanguageModelV3
|
|
153
172
|
├── session.ts held-open Run stream + exec bridge
|
|
154
173
|
├── protocol/ protobuf messages, framing, tools, thinking
|
|
155
|
-
└── transport/ Connect-RPC over HTTP/2 to
|
|
174
|
+
└── transport/ Connect-RPC over HTTP/2 to Cursor's agent backend
|
|
156
175
|
```
|
|
157
176
|
|
|
158
177
|
| Module | Role |
|
|
@@ -164,6 +183,7 @@ OpenCode
|
|
|
164
183
|
| `src/session.ts` | Held-open agent Run session and pending exec correlation |
|
|
165
184
|
| `src/auth.ts` | PKCE OAuth, API key exchange, JWT refresh |
|
|
166
185
|
| `src/models.ts` | `AvailableModels` fetch and `cursor-models.json` cache |
|
|
186
|
+
| `src/agent-url.ts` | `GetServerConfig` fetch + in-process memo (region-specific Run host) |
|
|
167
187
|
| `src/transport/connect.ts` | HTTP/2 bidi stream and unary RPC calls |
|
|
168
188
|
| `src/protocol/` | Protobuf encode/decode, checksum/device ids, tool-call mapping |
|
|
169
189
|
|
|
@@ -181,11 +201,12 @@ OpenCode
|
|
|
181
201
|
|
|
182
202
|
| Problem | What to try |
|
|
183
203
|
|---------|-------------|
|
|
184
|
-
| No Cursor models in the picker |
|
|
204
|
+
| No Cursor models in the picker | Confirm Cursor auth (`opencode auth login` → **cursor**). Restart OpenCode — if auth is present and the cache is empty, models are fetched on startup. Confirm `provider.cursor.npm` is the package name (or a built `file://…/dist/index.js`). |
|
|
185
205
|
| Auth / 401 errors mid-session | Re-login. OAuth and exchanged API-key JWTs refresh automatically when near expiry; a revoked refresh token needs a fresh login. |
|
|
186
206
|
| “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. |
|
|
187
|
-
| Empty or stale model list | Delete
|
|
188
|
-
| Stream hangs or HTTP/2 errors | Abort the turn and retry. The agent Run uses a bidirectional HTTP/2 stream
|
|
207
|
+
| Empty or stale model list | Delete `~/.cache/opencode/cursor-models.json` (or under `$XDG_CACHE_HOME/opencode/`) and restart OpenCode. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
|
|
208
|
+
| Stream hangs or HTTP/2 errors | Abort the turn and retry. The agent Run uses a bidirectional HTTP/2 stream; a dropped connection leaves the in-flight session unusable. |
|
|
209
|
+
| No response / silent 200 + close | The provider resolves the Run host from `GetServerConfig` (`agentUrlConfig.agentnUrl`). The URL is resolved once per process and not cached on disk. If resolution fails, the model call reports the endpoint-resolution error instead of falling back to `agentn.global.api5.cursor.sh`, which some accounts reject silently ("This region is not yet available for your team"). Set `CURSOR_PROVIDER_DEBUG=1` to confirm the resolved host in the debug log. |
|
|
189
210
|
| Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`, default `/tmp/cursor-provider-debug.log`) and reproduce the issue. |
|
|
190
211
|
|
|
191
212
|
## Known limitations
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the Run stream origin for this account via the `GetServerConfig`
|
|
3
|
+
* Connect RPC. Memoized for the process lifetime.
|
|
4
|
+
*
|
|
5
|
+
* - already resolved → return the memo (no fetch)
|
|
6
|
+
* - otherwise → fetch `agentUrlConfig.agentnUrl` (then `agentUrl`), memoize, return
|
|
7
|
+
* - fetch fails / no valid `agentUrlConfig` → throw (no global-host fallback)
|
|
8
|
+
*
|
|
9
|
+
* Concurrent callers share a single in-flight fetch.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveAgentUrl(token: string, options?: {
|
|
12
|
+
apiBaseURL?: string;
|
|
13
|
+
baseURL?: string;
|
|
14
|
+
telemetryEnabled?: boolean;
|
|
15
|
+
}): Promise<string>;
|
|
16
|
+
/** Reset the in-process memo. Tests only. */
|
|
17
|
+
export declare function resetAgentUrlCache(): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { fetchAgentUrl, trace } from "./transport/connect.js";
|
|
3
|
+
import { CURSOR_API_HOST } from "./shared.js";
|
|
4
|
+
const DEFAULT_API_BASE = `https://${CURSOR_API_HOST}`;
|
|
5
|
+
// In-process memo of the region-specific Run stream origin (agentnUrl). Resolved
|
|
6
|
+
// once per process — the auth loader warms it, and the first startSession reuses
|
|
7
|
+
// it — and held for the process lifetime. Region routing is near-static, and
|
|
8
|
+
// re-resolving mid-session would break a held-open bidi Run stream anyway.
|
|
9
|
+
//
|
|
10
|
+
// Unlike the models/version caches this is NOT persisted to disk. A wrong agent
|
|
11
|
+
// host is fatal and silent (HTTP 200 + immediate close, "This region is not
|
|
12
|
+
// available for your team"), so persisting it would risk pinning the process —
|
|
13
|
+
// or the next process — to a stale region or the legacy global host that some
|
|
14
|
+
// accounts reject. Resolving fresh per process is cheap (one unary RPC on the
|
|
15
|
+
// API host) and self-heals after a Cursor-side region migration on restart.
|
|
16
|
+
//
|
|
17
|
+
function normalizeApiBaseURL(baseURL) {
|
|
18
|
+
if (!baseURL)
|
|
19
|
+
return DEFAULT_API_BASE;
|
|
20
|
+
return new URL(baseURL).origin;
|
|
21
|
+
}
|
|
22
|
+
function resolveCacheKey(token, options) {
|
|
23
|
+
const tokenHash = createHash("sha256").update(token).digest("hex").slice(0, 16);
|
|
24
|
+
return `${tokenHash}|${normalizeApiBaseURL(options.apiBaseURL ?? options.baseURL)}|telem:${options.telemetryEnabled === true}`;
|
|
25
|
+
}
|
|
26
|
+
const _resolved = new Map();
|
|
27
|
+
// In-flight fetches share the same key as resolved URLs so concurrent callers dedup per account.
|
|
28
|
+
const _inflight = new Map();
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the Run stream origin for this account via the `GetServerConfig`
|
|
31
|
+
* Connect RPC. Memoized for the process lifetime.
|
|
32
|
+
*
|
|
33
|
+
* - already resolved → return the memo (no fetch)
|
|
34
|
+
* - otherwise → fetch `agentUrlConfig.agentnUrl` (then `agentUrl`), memoize, return
|
|
35
|
+
* - fetch fails / no valid `agentUrlConfig` → throw (no global-host fallback)
|
|
36
|
+
*
|
|
37
|
+
* Concurrent callers share a single in-flight fetch.
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveAgentUrl(token, options = {}) {
|
|
40
|
+
const cacheKey = resolveCacheKey(token, options);
|
|
41
|
+
const memo = _resolved.get(cacheKey);
|
|
42
|
+
if (memo) {
|
|
43
|
+
trace(`agent-url: reuse in-process memo → ${memo}`);
|
|
44
|
+
return memo;
|
|
45
|
+
}
|
|
46
|
+
const inflight = _inflight.get(cacheKey);
|
|
47
|
+
if (inflight) {
|
|
48
|
+
trace("agent-url: awaiting in-flight GetServerConfig");
|
|
49
|
+
return inflight;
|
|
50
|
+
}
|
|
51
|
+
const promise = (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const url = await fetchAgentUrl(token, options);
|
|
54
|
+
_resolved.set(cacheKey, url);
|
|
55
|
+
trace(`agent-url: resolved via GetServerConfig → ${url}`);
|
|
56
|
+
return url;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
60
|
+
trace(`agent-url: GetServerConfig failed (${reason}); no fallback agent host will be used`);
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
_inflight.delete(cacheKey);
|
|
65
|
+
}
|
|
66
|
+
})();
|
|
67
|
+
_inflight.set(cacheKey, promise);
|
|
68
|
+
return promise;
|
|
69
|
+
}
|
|
70
|
+
/** Reset the in-process memo. Tests only. */
|
|
71
|
+
export function resetAgentUrlCache() {
|
|
72
|
+
_resolved.clear();
|
|
73
|
+
_inflight.clear();
|
|
74
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Mirrors OpenCode / SDK OAuth + API auth used for Cursor. `wellknown` is not wired yet. */
|
|
2
|
+
export type StoredAuth = {
|
|
3
|
+
type: "oauth";
|
|
4
|
+
access: string;
|
|
5
|
+
refresh: string;
|
|
6
|
+
expires: number;
|
|
7
|
+
accountId?: string;
|
|
8
|
+
enterpriseUrl?: string;
|
|
9
|
+
} | {
|
|
10
|
+
type: "api";
|
|
11
|
+
key: string;
|
|
12
|
+
metadata?: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Read a provider's credentials from OpenCode's `auth.json` (XDG data dir).
|
|
16
|
+
*
|
|
17
|
+
* Honors `OPENCODE_AUTH_CONTENT` when set — same injection hook OpenCode core
|
|
18
|
+
* uses in tests / embedded runs (not an SDK export).
|
|
19
|
+
*/
|
|
20
|
+
export declare function readStoredAuth(providerId: string): Promise<StoredAuth | undefined>;
|
|
21
|
+
export declare function asStoredAuth(value: unknown): StoredAuth | undefined;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { opencodeGlobalDataDir } from "./paths.js";
|
|
4
|
+
function debugEnabled() {
|
|
5
|
+
return process.env.CURSOR_PROVIDER_DEBUG === "1" || process.env.CURSOR_PROVIDER_DEBUG === "true";
|
|
6
|
+
}
|
|
7
|
+
function debugAuthStore(message) {
|
|
8
|
+
if (!debugEnabled())
|
|
9
|
+
return;
|
|
10
|
+
console.debug(`[cursor-opencode-provider] auth-store: ${message}`);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Read a provider's credentials from OpenCode's `auth.json` (XDG data dir).
|
|
14
|
+
*
|
|
15
|
+
* Honors `OPENCODE_AUTH_CONTENT` when set — same injection hook OpenCode core
|
|
16
|
+
* uses in tests / embedded runs (not an SDK export).
|
|
17
|
+
*/
|
|
18
|
+
export async function readStoredAuth(providerId) {
|
|
19
|
+
if (process.env.OPENCODE_AUTH_CONTENT) {
|
|
20
|
+
try {
|
|
21
|
+
const data = JSON.parse(process.env.OPENCODE_AUTH_CONTENT);
|
|
22
|
+
return asStoredAuth(data[providerId]);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
debugAuthStore("OPENCODE_AUTH_CONTENT is not valid JSON");
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const filePath = path.join(opencodeGlobalDataDir(), "auth.json");
|
|
30
|
+
try {
|
|
31
|
+
const raw = await readFile(filePath, "utf-8");
|
|
32
|
+
try {
|
|
33
|
+
const data = JSON.parse(raw);
|
|
34
|
+
return asStoredAuth(data[providerId]);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
debugAuthStore("auth.json is not valid JSON");
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const code = err?.code;
|
|
43
|
+
if (code !== "ENOENT") {
|
|
44
|
+
debugAuthStore(`failed to read auth.json (${code ?? "unknown"})`);
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function asStoredAuth(value) {
|
|
50
|
+
if (!value || typeof value !== "object")
|
|
51
|
+
return undefined;
|
|
52
|
+
const v = value;
|
|
53
|
+
if (v.type === "oauth" &&
|
|
54
|
+
typeof v.access === "string" &&
|
|
55
|
+
typeof v.refresh === "string" &&
|
|
56
|
+
typeof v.expires === "number") {
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
if (v.type === "api" && typeof v.key === "string") {
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
// wellknown is intentionally rejected until resolveAccessToken supports it
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
package/dist/context/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { buildRequestContext, type BuildRequestContextInput } from "./build.js";
|
|
2
|
-
export { opencodeGlobalConfigDir } from "./paths.js";
|
|
2
|
+
export { opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir } from "./paths.js";
|
package/dist/context/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { buildRequestContext } from "./build.js";
|
|
2
|
-
export { opencodeGlobalConfigDir } from "./paths.js";
|
|
2
|
+
export { opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir } from "./paths.js";
|
package/dist/context/paths.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
/** OpenCode global config dir (`~/.config/opencode`). */
|
|
2
2
|
export declare function opencodeGlobalConfigDir(): string;
|
|
3
|
+
/**
|
|
4
|
+
* OpenCode global cache dir (`~/.cache/opencode`).
|
|
5
|
+
* Uses `$XDG_CACHE_HOME/opencode` when set, otherwise `$HOME/.cache/opencode`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function opencodeGlobalCacheDir(): string;
|
|
8
|
+
/**
|
|
9
|
+
* OpenCode global data dir (`~/.local/share/opencode`).
|
|
10
|
+
* Uses `$XDG_DATA_HOME/opencode` when set, otherwise `$HOME/.local/share/opencode`.
|
|
11
|
+
* Auth credentials live here in `auth.json`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function opencodeGlobalDataDir(): string;
|
|
3
14
|
export declare function resolveHomeRelative(p: string): string;
|
package/dist/context/paths.js
CHANGED
|
@@ -1,8 +1,32 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
function resolveHome() {
|
|
4
|
+
return process.env.HOME || process.env.USERPROFILE || homedir();
|
|
5
|
+
}
|
|
3
6
|
/** OpenCode global config dir (`~/.config/opencode`). */
|
|
4
7
|
export function opencodeGlobalConfigDir() {
|
|
5
|
-
return path.join(
|
|
8
|
+
return path.join(resolveHome(), ".config", "opencode");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* OpenCode global cache dir (`~/.cache/opencode`).
|
|
12
|
+
* Uses `$XDG_CACHE_HOME/opencode` when set, otherwise `$HOME/.cache/opencode`.
|
|
13
|
+
*/
|
|
14
|
+
export function opencodeGlobalCacheDir() {
|
|
15
|
+
if (process.env.XDG_CACHE_HOME) {
|
|
16
|
+
return path.join(process.env.XDG_CACHE_HOME, "opencode");
|
|
17
|
+
}
|
|
18
|
+
return path.join(resolveHome(), ".cache", "opencode");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* OpenCode global data dir (`~/.local/share/opencode`).
|
|
22
|
+
* Uses `$XDG_DATA_HOME/opencode` when set, otherwise `$HOME/.local/share/opencode`.
|
|
23
|
+
* Auth credentials live here in `auth.json`.
|
|
24
|
+
*/
|
|
25
|
+
export function opencodeGlobalDataDir() {
|
|
26
|
+
if (process.env.XDG_DATA_HOME) {
|
|
27
|
+
return path.join(process.env.XDG_DATA_HOME, "opencode");
|
|
28
|
+
}
|
|
29
|
+
return path.join(resolveHome(), ".local", "share", "opencode");
|
|
6
30
|
}
|
|
7
31
|
export function resolveHomeRelative(p) {
|
|
8
32
|
if (p.startsWith("~/"))
|
package/dist/index.d.ts
CHANGED
|
@@ -3,8 +3,15 @@ export type CreateCursorOptions = {
|
|
|
3
3
|
name: string;
|
|
4
4
|
accessToken?: string;
|
|
5
5
|
apiKey?: string;
|
|
6
|
+
/** API base for auth, model discovery, and GetServerConfig. */
|
|
7
|
+
apiBaseURL?: string;
|
|
8
|
+
/** Explicit Cursor agent Run host override. */
|
|
9
|
+
agentBaseURL?: string;
|
|
10
|
+
/** @deprecated Use agentBaseURL. Kept as the legacy agent Run host override. */
|
|
6
11
|
baseURL?: string;
|
|
7
12
|
headers?: Record<string, string>;
|
|
13
|
+
/** Opt in to telemetry on the GetServerConfig endpoint lookup. Defaults to false. */
|
|
14
|
+
telemetryEnabled?: boolean;
|
|
8
15
|
/** OpenCode project / worktree directory for request_context collectors. */
|
|
9
16
|
workspaceRoot?: string;
|
|
10
17
|
};
|
package/dist/language-model.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import fs from "node:fs";
|
|
3
2
|
import { createHash } from "node:crypto";
|
|
4
|
-
import { bidiRunStream, trace } from "./transport/connect.js";
|
|
3
|
+
import { bidiRunStream, normalizeAgentRunOrigin, trace } from "./transport/connect.js";
|
|
5
4
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
6
5
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
7
6
|
import { decodeMessage } from "./protocol/messages.js";
|
|
@@ -12,10 +11,13 @@ import { conversationBlobCount } from "./protocol/blob-store.js";
|
|
|
12
11
|
import { sessionManager } from "./session.js";
|
|
13
12
|
import { readCache, cacheFilePath, resolveVariantParameters } from "./models.js";
|
|
14
13
|
import { buildRequestContext } from "./context/build.js";
|
|
14
|
+
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
15
|
+
import { resolveAgentUrl } from "./agent-url.js";
|
|
16
|
+
import { CURSOR_API_HOST } from "./shared.js";
|
|
15
17
|
let _availableModels;
|
|
16
18
|
// mtime of the cache file the last time we loaded it. Compared on each call
|
|
17
19
|
// so discoverModels' background refresh is picked up without a process restart.
|
|
18
|
-
let _availableModelsMtimeMs =
|
|
20
|
+
let _availableModelsMtimeMs = -1;
|
|
19
21
|
export function createCursorLanguageModel(modelId, providerId, options) {
|
|
20
22
|
return {
|
|
21
23
|
specificationVersion: "v3",
|
|
@@ -49,7 +51,7 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
49
51
|
const token = await resolveBearerToken({
|
|
50
52
|
accessToken: options.accessToken,
|
|
51
53
|
apiKey: options.apiKey,
|
|
52
|
-
baseUrl: options
|
|
54
|
+
baseUrl: resolveApiBaseURL(options),
|
|
53
55
|
});
|
|
54
56
|
const prompt = callOptions.prompt;
|
|
55
57
|
// ── Continuation vs fresh turn ──
|
|
@@ -171,6 +173,14 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
171
173
|
const systemPrompt = extractSystemPrompt(prompt);
|
|
172
174
|
const tools = extractTools(callOptions);
|
|
173
175
|
await loadAvailableModels();
|
|
176
|
+
// Resolve the region-specific Run stream origin once per process (memoized
|
|
177
|
+
// in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
|
|
178
|
+
// still go through the Cursor agent-host allowlist.
|
|
179
|
+
const agentBaseUrl = resolveExplicitAgentBaseURL(options) ??
|
|
180
|
+
(await resolveAgentUrl(token, {
|
|
181
|
+
apiBaseURL: resolveApiBaseURL(options),
|
|
182
|
+
telemetryEnabled: resolveTelemetryEnabled(options),
|
|
183
|
+
}));
|
|
174
184
|
const providerOptions = callOptions.providerOptions?.cursor;
|
|
175
185
|
const reasoningEffort = providerOptions?.reasoningEffort;
|
|
176
186
|
const maxMode = !!(providerOptions?.maxMode ?? false);
|
|
@@ -180,7 +190,7 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
180
190
|
// that signal when a turn ends with tool-calls; the Cursor stream must stay
|
|
181
191
|
// open until we write the exec results on the next doStream.
|
|
182
192
|
const stream = await bidiRunStream(token, {
|
|
183
|
-
baseURL:
|
|
193
|
+
baseURL: agentBaseUrl,
|
|
184
194
|
headers: options.headers,
|
|
185
195
|
});
|
|
186
196
|
// Build the tool descriptors once — advertised in AgentRunRequest #4 mcp_tools
|
|
@@ -301,11 +311,9 @@ export function findContinuationSession(toolResults) {
|
|
|
301
311
|
return undefined;
|
|
302
312
|
}
|
|
303
313
|
async function loadAvailableModels() {
|
|
304
|
-
const
|
|
305
|
-
if (!configDir)
|
|
306
|
-
return;
|
|
314
|
+
const cacheDir = opencodeGlobalCacheDir();
|
|
307
315
|
try {
|
|
308
|
-
const filePath = cacheFilePath(
|
|
316
|
+
const filePath = cacheFilePath(cacheDir);
|
|
309
317
|
let mtime = 0;
|
|
310
318
|
try {
|
|
311
319
|
const stat = await fs.promises.stat(filePath);
|
|
@@ -316,23 +324,31 @@ async function loadAvailableModels() {
|
|
|
316
324
|
}
|
|
317
325
|
// Re-read when the file changed (discoverModels background refresh).
|
|
318
326
|
if (mtime !== _availableModelsMtimeMs) {
|
|
319
|
-
const cached = await readCache(
|
|
327
|
+
const cached = await readCache(cacheDir);
|
|
320
328
|
_availableModels = cached?.models;
|
|
321
329
|
_availableModelsMtimeMs = mtime;
|
|
322
330
|
}
|
|
323
331
|
}
|
|
324
332
|
catch { /* ignore */ }
|
|
325
333
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
334
|
+
function resolveApiBaseURL(options) {
|
|
335
|
+
return options.apiBaseURL ?? process.env.CURSOR_API_BASE_URL ?? `https://${CURSOR_API_HOST}`;
|
|
336
|
+
}
|
|
337
|
+
function resolveTelemetryEnabled(options) {
|
|
338
|
+
return options.telemetryEnabled ?? isTruthyEnv(process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY);
|
|
339
|
+
}
|
|
340
|
+
function resolveExplicitAgentBaseURL(options) {
|
|
341
|
+
const raw = options.agentBaseURL ?? options.baseURL;
|
|
342
|
+
if (!raw)
|
|
332
343
|
return undefined;
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
344
|
+
const normalized = normalizeAgentRunOrigin(raw);
|
|
345
|
+
if (!normalized) {
|
|
346
|
+
throw new Error("Invalid Cursor agent base URL override: expected https://*.cursor.sh");
|
|
347
|
+
}
|
|
348
|
+
return normalized;
|
|
349
|
+
}
|
|
350
|
+
function isTruthyEnv(value) {
|
|
351
|
+
return value === "1" || value === "true";
|
|
336
352
|
}
|
|
337
353
|
/**
|
|
338
354
|
* Read the held-open stream, emitting stream parts, until the turn boundary:
|
package/dist/models.d.ts
CHANGED
|
@@ -24,9 +24,9 @@ export type ModelCache = {
|
|
|
24
24
|
fetchedAt: number;
|
|
25
25
|
};
|
|
26
26
|
export declare function isCacheFresh(cache: ModelCache, ttlMs?: number): boolean;
|
|
27
|
-
export declare function cacheFilePath(
|
|
28
|
-
export declare function readCache(
|
|
29
|
-
export declare function writeCache(
|
|
27
|
+
export declare function cacheFilePath(cacheDir: string): string;
|
|
28
|
+
export declare function readCache(cacheDir: string): Promise<ModelCache | null>;
|
|
29
|
+
export declare function writeCache(cacheDir: string, cache: ModelCache): Promise<void>;
|
|
30
30
|
export declare function mapAvailableModelsResponse(raw: Record<string, unknown>): ModelInfo[];
|
|
31
31
|
/**
|
|
32
32
|
* Resolve the parameter values to send in `requested_model.parameters` for a
|
|
@@ -43,7 +43,7 @@ export declare function fetchModels(token: string, options?: {
|
|
|
43
43
|
baseURL?: string;
|
|
44
44
|
headers?: Record<string, string>;
|
|
45
45
|
}): Promise<ModelInfo[]>;
|
|
46
|
-
export declare function discoverModels(token: string,
|
|
46
|
+
export declare function discoverModels(token: string, cacheDir: string, options?: {
|
|
47
47
|
baseURL?: string;
|
|
48
48
|
headers?: Record<string, string>;
|
|
49
49
|
}): Promise<ModelInfo[]>;
|
package/dist/models.js
CHANGED
|
@@ -8,11 +8,11 @@ import path from "node:path";
|
|
|
8
8
|
export function isCacheFresh(cache, ttlMs = MODEL_CACHE_TTL_MS) {
|
|
9
9
|
return Date.now() - cache.fetchedAt < ttlMs;
|
|
10
10
|
}
|
|
11
|
-
export function cacheFilePath(
|
|
12
|
-
return path.join(
|
|
11
|
+
export function cacheFilePath(cacheDir) {
|
|
12
|
+
return path.join(cacheDir, MODEL_CACHE_FILE);
|
|
13
13
|
}
|
|
14
|
-
export async function readCache(
|
|
15
|
-
const filePath = cacheFilePath(
|
|
14
|
+
export async function readCache(cacheDir) {
|
|
15
|
+
const filePath = cacheFilePath(cacheDir);
|
|
16
16
|
try {
|
|
17
17
|
if (!existsSync(filePath))
|
|
18
18
|
return null;
|
|
@@ -23,8 +23,8 @@ export async function readCache(configDir) {
|
|
|
23
23
|
return null;
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
|
-
export async function writeCache(
|
|
27
|
-
const filePath = cacheFilePath(
|
|
26
|
+
export async function writeCache(cacheDir, cache) {
|
|
27
|
+
const filePath = cacheFilePath(cacheDir);
|
|
28
28
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
29
29
|
await writeFile(filePath, JSON.stringify(cache, null, 2), "utf-8");
|
|
30
30
|
}
|
|
@@ -104,13 +104,13 @@ export async function fetchModels(token, options = {}) {
|
|
|
104
104
|
const raw = await unaryAvailableModels(token, options);
|
|
105
105
|
return mapAvailableModelsResponse(raw);
|
|
106
106
|
}
|
|
107
|
-
export async function discoverModels(token,
|
|
108
|
-
const cached = await readCache(
|
|
107
|
+
export async function discoverModels(token, cacheDir, options = {}) {
|
|
108
|
+
const cached = await readCache(cacheDir);
|
|
109
109
|
// Cache is fresh → return it; refresh in background
|
|
110
110
|
if (cached && isCacheFresh(cached)) {
|
|
111
111
|
// Background refresh (fire and forget)
|
|
112
112
|
fetchModels(token, options)
|
|
113
|
-
.then((models) => writeCache(
|
|
113
|
+
.then((models) => writeCache(cacheDir, { models, fetchedAt: Date.now() }))
|
|
114
114
|
.catch(() => {
|
|
115
115
|
/* background refresh failure is non-fatal */
|
|
116
116
|
});
|
|
@@ -121,7 +121,7 @@ export async function discoverModels(token, configDir, options = {}) {
|
|
|
121
121
|
try {
|
|
122
122
|
const models = await fetchModels(token, options);
|
|
123
123
|
const newCache = { models, fetchedAt: Date.now() };
|
|
124
|
-
await writeCache(
|
|
124
|
+
await writeCache(cacheDir, newCache);
|
|
125
125
|
return models;
|
|
126
126
|
}
|
|
127
127
|
catch {
|
|
@@ -131,6 +131,6 @@ export async function discoverModels(token, configDir, options = {}) {
|
|
|
131
131
|
// No cache → must fetch
|
|
132
132
|
const models = await fetchModels(token, options);
|
|
133
133
|
const newCache = { models, fetchedAt: Date.now() };
|
|
134
|
-
await writeCache(
|
|
134
|
+
await writeCache(cacheDir, newCache);
|
|
135
135
|
return models;
|
|
136
136
|
}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
1
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
import { type ModelInfo } from "./models.js";
|
|
3
|
+
/**
|
|
4
|
+
* Display names shared by both a thinking and a non-thinking model. A thinking
|
|
5
|
+
* model with such a name needs a "Thinking" tag to disambiguate it from its
|
|
6
|
+
* non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
|
|
7
|
+
* are already unique — including Cursor's GPT family, where the reasoning tier
|
|
8
|
+
* ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
|
|
9
|
+
* tagged redundantly.
|
|
10
|
+
*/
|
|
11
|
+
export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
|
|
12
|
+
export declare function modelInfoToConfig(mi: ModelInfo, options?: {
|
|
13
|
+
thinkingSuffix?: boolean;
|
|
14
|
+
}): Record<string, any>;
|
|
2
15
|
export declare function CursorPlugin(input: PluginInput): Promise<Hooks>;
|
package/dist/plugin.js
CHANGED
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
import { CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
|
|
2
2
|
import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtPayload } from "./auth.js";
|
|
3
|
-
import { readCache, discoverModels } from "./models.js";
|
|
3
|
+
import { readCache, discoverModels, isCacheFresh } from "./models.js";
|
|
4
|
+
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
5
|
+
import { readStoredAuth } from "./context/auth-store.js";
|
|
6
|
+
import { resolveAgentUrl } from "./agent-url.js";
|
|
4
7
|
const MODULE_URL = new URL("./index.js", import.meta.url).href;
|
|
8
|
+
/**
|
|
9
|
+
* Strip characters that break rendering in the OpenCode Desktop webview from a
|
|
10
|
+
* model or variant display name: HTML/markup chars (`< > & " ' \``), parentheses,
|
|
11
|
+
* Tabs/newlines collapse to single spaces; dots and unicode letters are preserved so names
|
|
12
|
+
* stay readable. Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
|
|
13
|
+
*/
|
|
14
|
+
function safeLabel(value) {
|
|
15
|
+
return (value
|
|
16
|
+
.replace(/[()<>&"'`]/g, "")
|
|
17
|
+
.replace(/\s+/g, " ")
|
|
18
|
+
.trim() || "default");
|
|
19
|
+
}
|
|
20
|
+
function baseName(mi) {
|
|
21
|
+
return safeLabel(mi.displayName ?? mi.id);
|
|
22
|
+
}
|
|
5
23
|
function modelInfoVariants(mi) {
|
|
6
24
|
if (mi.variants.length === 0)
|
|
7
25
|
return undefined;
|
|
8
26
|
const entries = {};
|
|
27
|
+
const usedKeys = new Set();
|
|
9
28
|
for (const v of mi.variants) {
|
|
10
|
-
const
|
|
29
|
+
const base = safeLabel(v.displayName || v.key || "default");
|
|
30
|
+
let key = base;
|
|
31
|
+
let suffix = 2;
|
|
32
|
+
while (usedKeys.has(key))
|
|
33
|
+
key = `${base}--${suffix++}`;
|
|
34
|
+
usedKeys.add(key);
|
|
11
35
|
const params = {};
|
|
12
36
|
for (const p of v.parameterValues) {
|
|
13
37
|
params[p.id] = p.value;
|
|
@@ -16,9 +40,37 @@ function modelInfoVariants(mi) {
|
|
|
16
40
|
}
|
|
17
41
|
return entries;
|
|
18
42
|
}
|
|
19
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Display names shared by both a thinking and a non-thinking model. A thinking
|
|
45
|
+
* model with such a name needs a "Thinking" tag to disambiguate it from its
|
|
46
|
+
* non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
|
|
47
|
+
* are already unique — including Cursor's GPT family, where the reasoning tier
|
|
48
|
+
* ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
|
|
49
|
+
* tagged redundantly.
|
|
50
|
+
*/
|
|
51
|
+
export function thinkingSuffixBaseNames(models) {
|
|
52
|
+
const flags = new Map();
|
|
53
|
+
for (const m of models) {
|
|
54
|
+
const base = baseName(m);
|
|
55
|
+
const entry = flags.get(base) ?? { hasThinking: false, hasNonThinking: false };
|
|
56
|
+
if (m.supportsThinking)
|
|
57
|
+
entry.hasThinking = true;
|
|
58
|
+
else
|
|
59
|
+
entry.hasNonThinking = true;
|
|
60
|
+
flags.set(base, entry);
|
|
61
|
+
}
|
|
62
|
+
const ambiguous = new Set();
|
|
63
|
+
for (const [base, f] of flags)
|
|
64
|
+
if (f.hasThinking && f.hasNonThinking)
|
|
65
|
+
ambiguous.add(base);
|
|
66
|
+
return ambiguous;
|
|
67
|
+
}
|
|
68
|
+
export function modelInfoToConfig(mi, options = {}) {
|
|
69
|
+
let name = baseName(mi);
|
|
70
|
+
if (options.thinkingSuffix)
|
|
71
|
+
name += " Thinking";
|
|
20
72
|
const config = {
|
|
21
|
-
name
|
|
73
|
+
name,
|
|
22
74
|
reasoning: mi.supportsThinking ?? false,
|
|
23
75
|
tool_call: mi.supportsAgent ?? true,
|
|
24
76
|
temperature: false,
|
|
@@ -32,17 +84,139 @@ function modelInfoToConfig(mi) {
|
|
|
32
84
|
config.variants = variants;
|
|
33
85
|
return config;
|
|
34
86
|
}
|
|
87
|
+
function modelsToConfig(models) {
|
|
88
|
+
const ambiguous = thinkingSuffixBaseNames(models);
|
|
89
|
+
const out = {};
|
|
90
|
+
for (const m of models) {
|
|
91
|
+
out[m.id] = modelInfoToConfig(m, {
|
|
92
|
+
thinkingSuffix: !!m.supportsThinking && ambiguous.has(baseName(m)),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function cursorApiBaseURL() {
|
|
98
|
+
return process.env.CURSOR_API_BASE_URL ?? `https://${CURSOR_API_HOST}`;
|
|
99
|
+
}
|
|
100
|
+
function cursorGetServerConfigTelemetryEnabled() {
|
|
101
|
+
return (process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "1" ||
|
|
102
|
+
process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "true");
|
|
103
|
+
}
|
|
35
104
|
export async function CursorPlugin(input) {
|
|
36
|
-
const
|
|
105
|
+
const cacheDir = opencodeGlobalCacheDir();
|
|
106
|
+
const apiBaseURL = cursorApiBaseURL();
|
|
107
|
+
// Last access token successfully resolved in this plugin instance. Config's
|
|
108
|
+
// loadModels can only read OpenCode's durable store (auth.json /
|
|
109
|
+
// OPENCODE_AUTH_CONTENT); auth.loader gets live credentials via getAuth().
|
|
110
|
+
// Those usually match, but after a refresh where persistAuthBestEffort fails,
|
|
111
|
+
// getAuth() may still see the old credentials while we already hold a usable
|
|
112
|
+
// token here — keep it so the loader can still discover models.
|
|
113
|
+
let sessionAccessToken;
|
|
114
|
+
async function persistAuth(body) {
|
|
115
|
+
await input.client.auth.set({
|
|
116
|
+
path: { id: CURSOR_PROVIDER_ID },
|
|
117
|
+
body,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Persist refreshed credentials without failing the caller that already holds a live token. */
|
|
121
|
+
async function persistAuthBestEffort(body) {
|
|
122
|
+
try {
|
|
123
|
+
await persistAuth(body);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// ignore — token is still usable for this process
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Durable credentials OpenCode stores on disk (same file getAuth() reads in
|
|
131
|
+
* the normal path). Used from `config`, which has no getAuth() callback.
|
|
132
|
+
*/
|
|
133
|
+
async function authFromStore() {
|
|
134
|
+
return readStoredAuth(CURSOR_PROVIDER_ID);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Prefer OpenCode's live getAuth(); fall back to the durable store so loader
|
|
138
|
+
* and config share the same underlying credentials when possible.
|
|
139
|
+
*/
|
|
140
|
+
async function authForLoader(getAuth) {
|
|
141
|
+
return (await getAuth()) ?? (await authFromStore());
|
|
142
|
+
}
|
|
143
|
+
async function resolveAccessToken(auth) {
|
|
144
|
+
if (auth.type === "api") {
|
|
145
|
+
let accessToken = auth.key;
|
|
146
|
+
const refreshToken = auth.metadata?.refreshToken;
|
|
147
|
+
// API-key exchange returns a short-lived JWT stored as `key`. Refresh
|
|
148
|
+
// it the same way as OAuth when it is expiring / already expired.
|
|
149
|
+
if (refreshToken && isExpiringSoon(auth.key)) {
|
|
150
|
+
try {
|
|
151
|
+
const newTokens = await refreshAccessToken(refreshToken, apiBaseURL);
|
|
152
|
+
accessToken = newTokens.accessToken;
|
|
153
|
+
await persistAuthBestEffort({
|
|
154
|
+
type: "api",
|
|
155
|
+
key: newTokens.accessToken,
|
|
156
|
+
metadata: {
|
|
157
|
+
...auth.metadata,
|
|
158
|
+
refreshToken: newTokens.refreshToken,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// refresh failed — keep the existing key; the next call may still work
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (accessToken)
|
|
167
|
+
sessionAccessToken = accessToken;
|
|
168
|
+
return accessToken;
|
|
169
|
+
}
|
|
170
|
+
if (auth.type === "oauth") {
|
|
171
|
+
if (!isExpiringSoon(auth.access)) {
|
|
172
|
+
sessionAccessToken = auth.access;
|
|
173
|
+
return auth.access;
|
|
174
|
+
}
|
|
175
|
+
if (!auth.refresh)
|
|
176
|
+
return undefined;
|
|
177
|
+
try {
|
|
178
|
+
const newTokens = await refreshAccessToken(auth.refresh, apiBaseURL);
|
|
179
|
+
// Preserve optional OAuth fields (v2 Auth / plugin may carry these).
|
|
180
|
+
const extras = auth;
|
|
181
|
+
// Use the new token even if persisting back to OpenCode fails.
|
|
182
|
+
await persistAuthBestEffort({
|
|
183
|
+
type: "oauth",
|
|
184
|
+
access: newTokens.accessToken,
|
|
185
|
+
refresh: newTokens.refreshToken,
|
|
186
|
+
expires: decodeExpFromJwt(newTokens.accessToken),
|
|
187
|
+
...(extras.accountId !== undefined ? { accountId: extras.accountId } : {}),
|
|
188
|
+
...(extras.enterpriseUrl !== undefined ? { enterpriseUrl: extras.enterpriseUrl } : {}),
|
|
189
|
+
});
|
|
190
|
+
sessionAccessToken = newTokens.accessToken;
|
|
191
|
+
return newTokens.accessToken;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
37
199
|
async function loadModels() {
|
|
38
|
-
const cached = await readCache(
|
|
39
|
-
if (!cached || cached.models.length === 0)
|
|
200
|
+
const cached = await readCache(cacheDir);
|
|
201
|
+
if (!cached || cached.models.length === 0) {
|
|
202
|
+
// Config runs before auth.loader and has no getAuth(); read the durable
|
|
203
|
+
// store (normally the same source getAuth() uses).
|
|
204
|
+
const auth = await authFromStore();
|
|
205
|
+
if (auth) {
|
|
206
|
+
const accessToken = await resolveAccessToken(auth);
|
|
207
|
+
if (accessToken) {
|
|
208
|
+
try {
|
|
209
|
+
const models = await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL });
|
|
210
|
+
return modelsToConfig(models);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// discovery failed — leave the list empty
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
40
217
|
return {};
|
|
41
|
-
const models = {};
|
|
42
|
-
for (const m of cached.models) {
|
|
43
|
-
models[m.id] = modelInfoToConfig(m);
|
|
44
218
|
}
|
|
45
|
-
return models;
|
|
219
|
+
return modelsToConfig(cached.models);
|
|
46
220
|
}
|
|
47
221
|
return {
|
|
48
222
|
async config(cfg) {
|
|
@@ -115,7 +289,7 @@ export async function CursorPlugin(input) {
|
|
|
115
289
|
if (!apiKey)
|
|
116
290
|
return { type: "failed" };
|
|
117
291
|
try {
|
|
118
|
-
const result = await exchangeApiKey(apiKey);
|
|
292
|
+
const result = await exchangeApiKey(apiKey, apiBaseURL);
|
|
119
293
|
return {
|
|
120
294
|
type: "success",
|
|
121
295
|
key: result.accessToken,
|
|
@@ -130,60 +304,27 @@ export async function CursorPlugin(input) {
|
|
|
130
304
|
},
|
|
131
305
|
],
|
|
132
306
|
async loader(getAuth) {
|
|
133
|
-
const auth = await getAuth
|
|
134
|
-
if
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (auth.type === "api") {
|
|
138
|
-
accessToken = auth.key;
|
|
139
|
-
const refreshToken = auth.metadata?.refreshToken;
|
|
140
|
-
// API-key exchange returns a short-lived JWT stored as `key`. Refresh
|
|
141
|
-
// it the same way as OAuth when it is expiring / already expired.
|
|
142
|
-
if (refreshToken && isExpiringSoon(auth.key)) {
|
|
143
|
-
try {
|
|
144
|
-
const newTokens = await refreshAccessToken(refreshToken);
|
|
145
|
-
await input.client.auth.set({
|
|
146
|
-
path: { id: CURSOR_PROVIDER_ID },
|
|
147
|
-
body: {
|
|
148
|
-
type: "api",
|
|
149
|
-
key: newTokens.accessToken,
|
|
150
|
-
metadata: { refreshToken: newTokens.refreshToken },
|
|
151
|
-
},
|
|
152
|
-
});
|
|
153
|
-
accessToken = newTokens.accessToken;
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
// refresh failed — keep the existing key; the next call may still work
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
else if (auth.type === "oauth") {
|
|
161
|
-
if (!isExpiringSoon(auth.access)) {
|
|
162
|
-
accessToken = auth.access;
|
|
163
|
-
}
|
|
164
|
-
else if (auth.refresh) {
|
|
165
|
-
try {
|
|
166
|
-
const newTokens = await refreshAccessToken(auth.refresh);
|
|
167
|
-
await input.client.auth.set({
|
|
168
|
-
path: { id: CURSOR_PROVIDER_ID },
|
|
169
|
-
body: {
|
|
170
|
-
type: "oauth",
|
|
171
|
-
access: newTokens.accessToken,
|
|
172
|
-
refresh: newTokens.refreshToken,
|
|
173
|
-
expires: decodeExpFromJwt(newTokens.accessToken),
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
accessToken = newTokens.accessToken;
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
// refresh failed
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
307
|
+
const auth = await authForLoader(getAuth);
|
|
308
|
+
// Prefer credentials from getAuth/store; if refresh already succeeded in
|
|
309
|
+
// loadModels but persist failed, fall back to the in-memory session token.
|
|
310
|
+
const accessToken = (auth ? await resolveAccessToken(auth) : undefined) ?? sessionAccessToken;
|
|
183
311
|
if (accessToken) {
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
312
|
+
// Skip when config already filled a fresh cache (avoids a second
|
|
313
|
+
// AvailableModels round-trip + background refresh on cold start).
|
|
314
|
+
const cached = await readCache(cacheDir);
|
|
315
|
+
if (!cached || cached.models.length === 0 || !isCacheFresh(cached)) {
|
|
316
|
+
// Await so an empty/missing cache is written before the loader returns
|
|
317
|
+
// (fire-and-forget often loses the race on short-lived CLI commands).
|
|
318
|
+
await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL }).catch(() => { });
|
|
319
|
+
}
|
|
320
|
+
// Resolve the region-specific Run stream origin so the first turn
|
|
321
|
+
// does not spend time on GetServerConfig. Best-effort: a failure is
|
|
322
|
+
// surfaced by startSession, which can fail the actual model call with
|
|
323
|
+
// a clear endpoint-resolution error instead of using global fallback.
|
|
324
|
+
await resolveAgentUrl(accessToken, {
|
|
325
|
+
apiBaseURL,
|
|
326
|
+
telemetryEnabled: cursorGetServerConfigTelemetryEnabled(),
|
|
327
|
+
}).catch(() => { });
|
|
187
328
|
}
|
|
188
329
|
return {
|
|
189
330
|
...(accessToken ? { accessToken } : {}),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { FALLBACK_CLIENT_VERSION, MODEL_CACHE_TTL_MS, VERSION_CACHE_FILE, } from "../shared.js";
|
|
4
|
+
import { opencodeGlobalCacheDir } from "../context/paths.js";
|
|
4
5
|
const INSTALL_URL = "https://cursor.com/install";
|
|
5
6
|
const REMOTE_TIMEOUT_MS = 5_000;
|
|
6
7
|
const BUILD_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9A-Za-z][0-9A-Za-z.-]*$/;
|
|
@@ -69,24 +70,11 @@ export function extractVersionFromInstaller(script) {
|
|
|
69
70
|
const match = script.match(/downloads\.cursor\.com\/lab\/([^/"'\s]+)\//);
|
|
70
71
|
return match && BUILD_RE.test(match[1]) ? match[1] : undefined;
|
|
71
72
|
}
|
|
72
|
-
function resolveCacheDir() {
|
|
73
|
-
if (process.env.CURSOR_CONFIG_DIR)
|
|
74
|
-
return process.env.CURSOR_CONFIG_DIR;
|
|
75
|
-
const home = process.env.HOME || process.env.USERPROFILE;
|
|
76
|
-
if (!home)
|
|
77
|
-
return undefined;
|
|
78
|
-
return process.env.XDG_CONFIG_HOME
|
|
79
|
-
? path.join(process.env.XDG_CONFIG_HOME, "opencode")
|
|
80
|
-
: path.join(home, ".config", "opencode");
|
|
81
|
-
}
|
|
82
73
|
function versionCachePath() {
|
|
83
|
-
|
|
84
|
-
return dir ? path.join(dir, VERSION_CACHE_FILE) : undefined;
|
|
74
|
+
return path.join(opencodeGlobalCacheDir(), VERSION_CACHE_FILE);
|
|
85
75
|
}
|
|
86
76
|
function readVersionCache() {
|
|
87
77
|
const file = versionCachePath();
|
|
88
|
-
if (!file)
|
|
89
|
-
return undefined;
|
|
90
78
|
try {
|
|
91
79
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
92
80
|
if (!isClientVersion(value.version) ||
|
|
@@ -102,8 +90,6 @@ function readVersionCache() {
|
|
|
102
90
|
}
|
|
103
91
|
function writeVersionCache(cache) {
|
|
104
92
|
const file = versionCachePath();
|
|
105
|
-
if (!file)
|
|
106
|
-
return;
|
|
107
93
|
try {
|
|
108
94
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
109
95
|
fs.writeFileSync(file, JSON.stringify(cache, null, 2));
|
package/dist/shared.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export declare const CURSOR_AGENT_HOST = "agentn.global.api5.cursor.sh";
|
|
2
1
|
export declare const CURSOR_API_HOST = "api2.cursor.sh";
|
|
3
2
|
export declare const CURSOR_WEBSITE_HOST = "cursor.com";
|
|
4
3
|
export declare const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
|
|
@@ -6,6 +5,7 @@ export declare const CURSOR_PROVIDER_ID = "cursor";
|
|
|
6
5
|
export declare const TOKEN_EXPIRY_THRESHOLD_S = 300;
|
|
7
6
|
export declare const RUN_PATH = "/agent.v1.AgentService/Run";
|
|
8
7
|
export declare const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
|
|
8
|
+
export declare const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
|
|
9
9
|
export declare const MODEL_CACHE_FILE = "cursor-models.json";
|
|
10
10
|
export declare const MODEL_CACHE_TTL_MS = 86400000;
|
|
11
11
|
export declare const VERSION_CACHE_FILE = "cursor-client-version.json";
|
package/dist/shared.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export const CURSOR_AGENT_HOST = "agentn.global.api5.cursor.sh";
|
|
2
1
|
export const CURSOR_API_HOST = "api2.cursor.sh";
|
|
3
2
|
export const CURSOR_WEBSITE_HOST = "cursor.com";
|
|
4
3
|
export const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
|
|
@@ -6,6 +5,7 @@ export const CURSOR_PROVIDER_ID = "cursor";
|
|
|
6
5
|
export const TOKEN_EXPIRY_THRESHOLD_S = 300;
|
|
7
6
|
export const RUN_PATH = "/agent.v1.AgentService/Run";
|
|
8
7
|
export const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
|
|
8
|
+
export const SERVER_CONFIG_PATH = "/aiserver.v1.ServerConfigService/GetServerConfig";
|
|
9
9
|
export const MODEL_CACHE_FILE = "cursor-models.json";
|
|
10
10
|
export const MODEL_CACHE_TTL_MS = 86_400_000;
|
|
11
11
|
export const VERSION_CACHE_FILE = "cursor-client-version.json";
|
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
export declare function trace(msg: string): void;
|
|
2
2
|
export declare function buildBaseHeaders(token: string, clientVersion: string, extra?: Record<string, string>): Record<string, string>;
|
|
3
3
|
export declare function unaryAvailableModels(token: string, options?: {
|
|
4
|
+
apiBaseURL?: string;
|
|
4
5
|
baseURL?: string;
|
|
5
6
|
headers?: Record<string, string>;
|
|
6
7
|
}): Promise<Record<string, unknown>>;
|
|
8
|
+
/**
|
|
9
|
+
* Cursor Run stream hosts from GetServerConfig / agentBaseURL.
|
|
10
|
+
* Any HTTPS subdomain of cursor.sh is accepted — hostnames vary
|
|
11
|
+
* (agentn.*, agent.*, agent-gcpp-*, api5 / api5lat, …) and may change.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isAllowedAgentHost(hostname: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Normalize a GetServerConfig agent URL to an https origin.
|
|
16
|
+
* Returns null for missing, malformed, non-https, or non-*.cursor.sh hosts.
|
|
17
|
+
*/
|
|
18
|
+
export declare function normalizeAgentRunOrigin(raw: string | undefined): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Fetch the Cursor server config (a Connect unary RPC on the API host) and
|
|
21
|
+
* return the `agentUrlConfig.agentnUrl` field — the region-specific Run stream
|
|
22
|
+
* origin the server routes this account/team to (e.g. `agentn.us.api5.cursor.sh`).
|
|
23
|
+
*
|
|
24
|
+
* Region-routed accounts can be silently rejected by the wrong regional host, so
|
|
25
|
+
* this lookup fails closed instead of substituting any host on error. Any
|
|
26
|
+
* authoritative `*.cursor.sh` origin from GetServerConfig is accepted.
|
|
27
|
+
*/
|
|
28
|
+
export declare function fetchAgentUrl(token: string, options?: {
|
|
29
|
+
apiBaseURL?: string;
|
|
30
|
+
baseURL?: string;
|
|
31
|
+
telemetryEnabled?: boolean;
|
|
32
|
+
}): Promise<string>;
|
|
7
33
|
export type BidiStream = {
|
|
8
34
|
write(msg: Uint8Array): void;
|
|
9
35
|
end(): void;
|
|
@@ -14,10 +40,10 @@ export type BidiStream = {
|
|
|
14
40
|
destroy(): void;
|
|
15
41
|
};
|
|
16
42
|
/** Resolve the HTTP/2 connect origin for a Run stream (exported for tests). */
|
|
17
|
-
export declare function resolveAgentOrigin(baseURL
|
|
18
|
-
export declare function bidiRunStream(token: string, options
|
|
43
|
+
export declare function resolveAgentOrigin(baseURL: string): string;
|
|
44
|
+
export declare function bidiRunStream(token: string, options: {
|
|
19
45
|
signal?: AbortSignal;
|
|
20
|
-
baseURL
|
|
46
|
+
baseURL: string;
|
|
21
47
|
headers?: Record<string, string>;
|
|
22
48
|
}): Promise<BidiStream>;
|
|
23
49
|
export declare function makeRequestId(): string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CURSOR_API_HOST,
|
|
1
|
+
import { CURSOR_API_HOST, CONNECT_PROTOCOL_VERSION, SERVER_CONFIG_PATH } from "../shared.js";
|
|
2
2
|
import { encodeFrame, streamFrames } from "../protocol/framing.js";
|
|
3
3
|
import { createCursorChecksumHeader } from "../protocol/checksum.js";
|
|
4
4
|
import { getDeviceIds } from "../protocol/device-id.js";
|
|
@@ -6,7 +6,9 @@ import { resolveClientVersion } from "../protocol/client-version.js";
|
|
|
6
6
|
import http2 from "node:http2";
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
const API_BASE = `https://${CURSOR_API_HOST}`;
|
|
9
|
-
|
|
9
|
+
function resolveApiBaseURL(options) {
|
|
10
|
+
return new URL(options.apiBaseURL ?? options.baseURL ?? API_BASE).origin;
|
|
11
|
+
}
|
|
10
12
|
// Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
|
|
11
13
|
// Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
|
|
12
14
|
// Truncated once per process. Captures h2 response status, parsed frames, and
|
|
@@ -43,7 +45,7 @@ export function buildBaseHeaders(token, clientVersion, extra) {
|
|
|
43
45
|
}
|
|
44
46
|
// ── Unary (AvailableModels) ──
|
|
45
47
|
export async function unaryAvailableModels(token, options = {}) {
|
|
46
|
-
const base = options
|
|
48
|
+
const base = resolveApiBaseURL(options);
|
|
47
49
|
const url = `${base}/aiserver.v1.AiService/AvailableModels`;
|
|
48
50
|
const clientVersion = await resolveClientVersion();
|
|
49
51
|
const headers = buildBaseHeaders(token, clientVersion, options.headers);
|
|
@@ -62,6 +64,82 @@ export async function unaryAvailableModels(token, options = {}) {
|
|
|
62
64
|
}
|
|
63
65
|
return (await res.json());
|
|
64
66
|
}
|
|
67
|
+
// ── Unary (GetServerConfig) ──
|
|
68
|
+
/**
|
|
69
|
+
* Cursor Run stream hosts from GetServerConfig / agentBaseURL.
|
|
70
|
+
* Any HTTPS subdomain of cursor.sh is accepted — hostnames vary
|
|
71
|
+
* (agentn.*, agent.*, agent-gcpp-*, api5 / api5lat, …) and may change.
|
|
72
|
+
*/
|
|
73
|
+
export function isAllowedAgentHost(hostname) {
|
|
74
|
+
return /^([a-z0-9-]+\.)+cursor\.sh$/i.test(hostname);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Normalize a GetServerConfig agent URL to an https origin.
|
|
78
|
+
* Returns null for missing, malformed, non-https, or non-*.cursor.sh hosts.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeAgentRunOrigin(raw) {
|
|
81
|
+
if (raw === undefined || raw === null)
|
|
82
|
+
return null;
|
|
83
|
+
const trimmed = String(raw).trim();
|
|
84
|
+
if (!trimmed)
|
|
85
|
+
return null;
|
|
86
|
+
try {
|
|
87
|
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
88
|
+
const parsed = new URL(withScheme);
|
|
89
|
+
if (parsed.protocol !== "https:")
|
|
90
|
+
return null;
|
|
91
|
+
if (parsed.username || parsed.password)
|
|
92
|
+
return null;
|
|
93
|
+
if (!isAllowedAgentHost(parsed.hostname))
|
|
94
|
+
return null;
|
|
95
|
+
return parsed.origin;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fetch the Cursor server config (a Connect unary RPC on the API host) and
|
|
103
|
+
* return the `agentUrlConfig.agentnUrl` field — the region-specific Run stream
|
|
104
|
+
* origin the server routes this account/team to (e.g. `agentn.us.api5.cursor.sh`).
|
|
105
|
+
*
|
|
106
|
+
* Region-routed accounts can be silently rejected by the wrong regional host, so
|
|
107
|
+
* this lookup fails closed instead of substituting any host on error. Any
|
|
108
|
+
* authoritative `*.cursor.sh` origin from GetServerConfig is accepted.
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchAgentUrl(token, options = {}) {
|
|
111
|
+
const base = resolveApiBaseURL(options);
|
|
112
|
+
const url = `${base}${SERVER_CONFIG_PATH}`;
|
|
113
|
+
const clientVersion = await resolveClientVersion();
|
|
114
|
+
// Match Cursor CLI: GetServerConfig uses base headers only — session headers belong on Run.
|
|
115
|
+
const headers = buildBaseHeaders(token, clientVersion);
|
|
116
|
+
trace(`GetServerConfig POST ${url}`);
|
|
117
|
+
const res = await fetch(url, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
...headers,
|
|
121
|
+
"content-type": "application/json",
|
|
122
|
+
accept: "application/json",
|
|
123
|
+
},
|
|
124
|
+
body: JSON.stringify({ telem_enabled: options.telemetryEnabled ?? false }),
|
|
125
|
+
});
|
|
126
|
+
if (!res.ok) {
|
|
127
|
+
const text = await res.text().catch(() => "");
|
|
128
|
+
throw new Error(`GetServerConfig failed: ${res.status} ${res.statusText}${text ? ` - ${text.slice(0, 200)}` : ""}`);
|
|
129
|
+
}
|
|
130
|
+
const body = (await res.json());
|
|
131
|
+
const cfg = body.agentUrlConfig;
|
|
132
|
+
const raw = cfg?.agentnUrl || cfg?.agentUrl;
|
|
133
|
+
const normalized = normalizeAgentRunOrigin(raw);
|
|
134
|
+
trace(`GetServerConfig reply: agentnUrl=${cfg?.agentnUrl ?? "<missing>"} ` +
|
|
135
|
+
`agentUrl=${cfg?.agentUrl ?? "<missing>"} → ${normalized ?? "<invalid>"}`);
|
|
136
|
+
if (!normalized) {
|
|
137
|
+
throw new Error(raw
|
|
138
|
+
? "GetServerConfig returned an invalid Cursor agent URL"
|
|
139
|
+
: "GetServerConfig response missing agentUrlConfig.agentnUrl");
|
|
140
|
+
}
|
|
141
|
+
return normalized;
|
|
142
|
+
}
|
|
65
143
|
// Cache http2 sessions keyed by origin so a custom baseURL never reuses a
|
|
66
144
|
// connection opened to the default agent host — and vice versa.
|
|
67
145
|
const _http2Sessions = new Map();
|
|
@@ -73,8 +151,11 @@ const _http2Connecting = new Map();
|
|
|
73
151
|
const CONNECT_TIMEOUT_MS = 15_000;
|
|
74
152
|
/** Resolve the HTTP/2 connect origin for a Run stream (exported for tests). */
|
|
75
153
|
export function resolveAgentOrigin(baseURL) {
|
|
76
|
-
|
|
77
|
-
|
|
154
|
+
const origin = normalizeAgentRunOrigin(baseURL);
|
|
155
|
+
if (!origin) {
|
|
156
|
+
throw new Error("Cursor Run stream requires an allowlisted Cursor agent base URL");
|
|
157
|
+
}
|
|
158
|
+
return origin;
|
|
78
159
|
}
|
|
79
160
|
function dropSession(origin, session) {
|
|
80
161
|
if (_http2Sessions.get(origin) === session)
|
|
@@ -133,7 +214,7 @@ function getSession(baseURL) {
|
|
|
133
214
|
_http2Connecting.set(origin, promise);
|
|
134
215
|
return promise;
|
|
135
216
|
}
|
|
136
|
-
export async function bidiRunStream(token, options
|
|
217
|
+
export async function bidiRunStream(token, options) {
|
|
137
218
|
const [session, clientVersion] = await Promise.all([
|
|
138
219
|
getSession(options.baseURL),
|
|
139
220
|
resolveClientVersion(),
|