cursor-opencode-provider 0.1.3 → 0.1.5
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 +16 -9
- package/dist/agent-url.d.ts +17 -0
- package/dist/agent-url.js +75 -0
- package/dist/context/auth-store.js +1 -1
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +22 -0
- package/dist/index.d.ts +7 -0
- package/dist/language-model.js +36 -4
- package/dist/plugin.js +22 -5
- package/dist/protocol/tools.d.ts +28 -1
- package/dist/protocol/tools.js +81 -5
- package/dist/session.d.ts +6 -1
- package/dist/session.js +3 -3
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/transport/connect.d.ts +29 -4
- package/dist/transport/connect.js +87 -26
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
|
|
|
@@ -18,7 +18,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
|
|
|
18
18
|
- **Authentication** — browser OAuth (PKCE), or API key from [cursor.com/settings](https://cursor.com/settings)
|
|
19
19
|
- **Model discovery** — fetches available models from Cursor's API and caches them locally
|
|
20
20
|
- **Streaming** — bidirectional Connect-RPC stream for agent runs
|
|
21
|
-
- **Tool calls** — maps Cursor exec-server messages to AI SDK tool-call parts
|
|
21
|
+
- **Tool calls** — maps Cursor exec-server messages to AI SDK tool-call parts; strips OpenCode's `read` XML envelope (`<path>`/`<content>` + `N:` prefixes) before returning content to Cursor so the model cannot echo the wrapper into writes
|
|
22
22
|
- **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
|
|
23
23
|
|
|
24
24
|
## Requirements
|
|
@@ -47,7 +47,7 @@ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun
|
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
Pin a version if you want: `"cursor-opencode-provider@0.1.
|
|
50
|
+
Pin a version if you want: `"cursor-opencode-provider@0.1.5"`.
|
|
51
51
|
|
|
52
52
|
### From a local clone
|
|
53
53
|
|
|
@@ -128,26 +128,30 @@ import { createCursor } from "cursor-opencode-provider"
|
|
|
128
128
|
const cursor = createCursor({
|
|
129
129
|
name: "cursor",
|
|
130
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
|
|
131
134
|
})
|
|
132
135
|
|
|
133
136
|
const model = cursor.languageModel("composer-2.5")
|
|
134
137
|
// model implements AI SDK LanguageModelV3 (doStream / doGenerate)
|
|
135
138
|
```
|
|
136
139
|
|
|
137
|
-
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`.
|
|
138
141
|
|
|
139
142
|
## Environment variables
|
|
140
143
|
|
|
141
144
|
| Variable | Description |
|
|
142
145
|
|----------|-------------|
|
|
143
146
|
| `CURSOR_WEBSITE_URL` | Override OAuth login base URL (default `https://cursor.com`) |
|
|
144
|
-
| `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 |
|
|
145
149
|
| `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
|
|
146
150
|
| `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default `/tmp/cursor-provider-debug.log`) |
|
|
147
151
|
| `XDG_CACHE_HOME` | When set, model/version caches go under `$XDG_CACHE_HOME/opencode/` instead of `~/.cache/opencode/` |
|
|
148
152
|
| `XDG_DATA_HOME` | When set, OpenCode `auth.json` is read from `$XDG_DATA_HOME/opencode/` instead of `~/.local/share/opencode/` |
|
|
149
153
|
|
|
150
|
-
`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`.
|
|
151
155
|
|
|
152
156
|
## Development
|
|
153
157
|
|
|
@@ -167,7 +171,7 @@ OpenCode
|
|
|
167
171
|
└── createCursor() → LanguageModelV3
|
|
168
172
|
├── session.ts held-open Run stream + exec bridge
|
|
169
173
|
├── protocol/ protobuf messages, framing, tools, thinking
|
|
170
|
-
└── transport/ Connect-RPC over HTTP/2 to
|
|
174
|
+
└── transport/ Connect-RPC over HTTP/2 to Cursor's agent backend
|
|
171
175
|
```
|
|
172
176
|
|
|
173
177
|
| Module | Role |
|
|
@@ -177,8 +181,10 @@ OpenCode
|
|
|
177
181
|
| `src/index.ts` | `createCursor` factory; default export is `CursorPlugin` |
|
|
178
182
|
| `src/language-model.ts` | AI SDK `LanguageModelV3` adapter (`doStream`, `doGenerate`) |
|
|
179
183
|
| `src/session.ts` | Held-open agent Run session and pending exec correlation |
|
|
184
|
+
| `src/debug.ts` | Opt-in wire-level debug logging (`CURSOR_PROVIDER_DEBUG`) |
|
|
180
185
|
| `src/auth.ts` | PKCE OAuth, API key exchange, JWT refresh |
|
|
181
186
|
| `src/models.ts` | `AvailableModels` fetch and `cursor-models.json` cache |
|
|
187
|
+
| `src/agent-url.ts` | `GetServerConfig` fetch + in-process memo (region-specific Run host) |
|
|
182
188
|
| `src/transport/connect.ts` | HTTP/2 bidi stream and unary RPC calls |
|
|
183
189
|
| `src/protocol/` | Protobuf encode/decode, checksum/device ids, tool-call mapping |
|
|
184
190
|
|
|
@@ -200,7 +206,8 @@ OpenCode
|
|
|
200
206
|
| 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. |
|
|
201
207
|
| “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. |
|
|
202
208
|
| 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. |
|
|
203
|
-
| Stream hangs or HTTP/2 errors | Abort the turn and retry. The agent Run uses a bidirectional HTTP/2 stream
|
|
209
|
+
| 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. |
|
|
210
|
+
| 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. |
|
|
204
211
|
| Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`, default `/tmp/cursor-provider-debug.log`) and reproduce the issue. |
|
|
205
212
|
|
|
206
213
|
## Known limitations
|
|
@@ -212,4 +219,4 @@ OpenCode
|
|
|
212
219
|
|
|
213
220
|
## License
|
|
214
221
|
|
|
215
|
-
MIT
|
|
222
|
+
MIT
|
|
@@ -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,75 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { fetchAgentUrl } from "./transport/connect.js";
|
|
3
|
+
import { trace } from "./debug.js";
|
|
4
|
+
import { CURSOR_API_HOST } from "./shared.js";
|
|
5
|
+
const DEFAULT_API_BASE = `https://${CURSOR_API_HOST}`;
|
|
6
|
+
// In-process memo of the region-specific Run stream origin (agentnUrl). Resolved
|
|
7
|
+
// once per process — the auth loader warms it, and the first startSession reuses
|
|
8
|
+
// it — and held for the process lifetime. Region routing is near-static, and
|
|
9
|
+
// re-resolving mid-session would break a held-open bidi Run stream anyway.
|
|
10
|
+
//
|
|
11
|
+
// Unlike the models/version caches this is NOT persisted to disk. A wrong agent
|
|
12
|
+
// host is fatal and silent (HTTP 200 + immediate close, "This region is not
|
|
13
|
+
// available for your team"), so persisting it would risk pinning the process —
|
|
14
|
+
// or the next process — to a stale region or the legacy global host that some
|
|
15
|
+
// accounts reject. Resolving fresh per process is cheap (one unary RPC on the
|
|
16
|
+
// API host) and self-heals after a Cursor-side region migration on restart.
|
|
17
|
+
//
|
|
18
|
+
function normalizeApiBaseURL(baseURL) {
|
|
19
|
+
if (!baseURL)
|
|
20
|
+
return DEFAULT_API_BASE;
|
|
21
|
+
return new URL(baseURL).origin;
|
|
22
|
+
}
|
|
23
|
+
function resolveCacheKey(token, options) {
|
|
24
|
+
const tokenHash = createHash("sha256").update(token).digest("hex").slice(0, 16);
|
|
25
|
+
return `${tokenHash}|${normalizeApiBaseURL(options.apiBaseURL ?? options.baseURL)}|telem:${options.telemetryEnabled === true}`;
|
|
26
|
+
}
|
|
27
|
+
const _resolved = new Map();
|
|
28
|
+
// In-flight fetches share the same key as resolved URLs so concurrent callers dedup per account.
|
|
29
|
+
const _inflight = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the Run stream origin for this account via the `GetServerConfig`
|
|
32
|
+
* Connect RPC. Memoized for the process lifetime.
|
|
33
|
+
*
|
|
34
|
+
* - already resolved → return the memo (no fetch)
|
|
35
|
+
* - otherwise → fetch `agentUrlConfig.agentnUrl` (then `agentUrl`), memoize, return
|
|
36
|
+
* - fetch fails / no valid `agentUrlConfig` → throw (no global-host fallback)
|
|
37
|
+
*
|
|
38
|
+
* Concurrent callers share a single in-flight fetch.
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveAgentUrl(token, options = {}) {
|
|
41
|
+
const cacheKey = resolveCacheKey(token, options);
|
|
42
|
+
const memo = _resolved.get(cacheKey);
|
|
43
|
+
if (memo) {
|
|
44
|
+
trace(`agent-url: reuse in-process memo → ${memo}`);
|
|
45
|
+
return memo;
|
|
46
|
+
}
|
|
47
|
+
const inflight = _inflight.get(cacheKey);
|
|
48
|
+
if (inflight) {
|
|
49
|
+
trace("agent-url: awaiting in-flight GetServerConfig");
|
|
50
|
+
return inflight;
|
|
51
|
+
}
|
|
52
|
+
const promise = (async () => {
|
|
53
|
+
try {
|
|
54
|
+
const url = await fetchAgentUrl(token, options);
|
|
55
|
+
_resolved.set(cacheKey, url);
|
|
56
|
+
trace(`agent-url: resolved via GetServerConfig → ${url}`);
|
|
57
|
+
return url;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
61
|
+
trace(`agent-url: GetServerConfig failed (${reason}); no fallback agent host will be used`);
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
_inflight.delete(cacheKey);
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
_inflight.set(cacheKey, promise);
|
|
69
|
+
return promise;
|
|
70
|
+
}
|
|
71
|
+
/** Reset the in-process memo. Tests only. */
|
|
72
|
+
export function resetAgentUrlCache() {
|
|
73
|
+
_resolved.clear();
|
|
74
|
+
_inflight.clear();
|
|
75
|
+
}
|
|
@@ -7,7 +7,7 @@ function debugEnabled() {
|
|
|
7
7
|
function debugAuthStore(message) {
|
|
8
8
|
if (!debugEnabled())
|
|
9
9
|
return;
|
|
10
|
-
console.
|
|
10
|
+
console.debug(`[cursor-opencode-provider] auth-store: ${message}`);
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
13
|
* Read a provider's credentials from OpenCode's `auth.json` (XDG data dir).
|
package/dist/debug.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function trace(msg: string): void;
|
package/dist/debug.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
// Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
|
|
3
|
+
// Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
|
|
4
|
+
// Truncated once per process. Tokens / checksums should be redacted by callers.
|
|
5
|
+
const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
|
|
6
|
+
process.env.CURSOR_PROVIDER_DEBUG === "true";
|
|
7
|
+
const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
|
|
8
|
+
let _traceInitialized = false;
|
|
9
|
+
export function trace(msg) {
|
|
10
|
+
if (!DEBUG_ENABLED)
|
|
11
|
+
return;
|
|
12
|
+
try {
|
|
13
|
+
if (!_traceInitialized) {
|
|
14
|
+
_traceInitialized = true;
|
|
15
|
+
fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
|
|
16
|
+
}
|
|
17
|
+
fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
/* ignore */
|
|
21
|
+
}
|
|
22
|
+
}
|
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,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { bidiRunStream,
|
|
3
|
+
import { bidiRunStream, normalizeAgentRunOrigin } from "./transport/connect.js";
|
|
4
|
+
import { trace } from "./debug.js";
|
|
4
5
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
5
6
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
6
7
|
import { decodeMessage } from "./protocol/messages.js";
|
|
@@ -12,6 +13,8 @@ import { sessionManager } from "./session.js";
|
|
|
12
13
|
import { readCache, cacheFilePath, resolveVariantParameters } from "./models.js";
|
|
13
14
|
import { buildRequestContext } from "./context/build.js";
|
|
14
15
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
16
|
+
import { resolveAgentUrl } from "./agent-url.js";
|
|
17
|
+
import { CURSOR_API_HOST } from "./shared.js";
|
|
15
18
|
let _availableModels;
|
|
16
19
|
// mtime of the cache file the last time we loaded it. Compared on each call
|
|
17
20
|
// so discoverModels' background refresh is picked up without a process restart.
|
|
@@ -49,7 +52,7 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
49
52
|
const token = await resolveBearerToken({
|
|
50
53
|
accessToken: options.accessToken,
|
|
51
54
|
apiKey: options.apiKey,
|
|
52
|
-
baseUrl: options
|
|
55
|
+
baseUrl: resolveApiBaseURL(options),
|
|
53
56
|
});
|
|
54
57
|
const prompt = callOptions.prompt;
|
|
55
58
|
// ── Continuation vs fresh turn ──
|
|
@@ -76,6 +79,7 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
76
79
|
resultField: pending.resultField,
|
|
77
80
|
output: r.output,
|
|
78
81
|
error: r.error,
|
|
82
|
+
toolName: pending.toolName ?? r.toolName,
|
|
79
83
|
})) {
|
|
80
84
|
session.stream.write(frame);
|
|
81
85
|
}
|
|
@@ -171,6 +175,14 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
171
175
|
const systemPrompt = extractSystemPrompt(prompt);
|
|
172
176
|
const tools = extractTools(callOptions);
|
|
173
177
|
await loadAvailableModels();
|
|
178
|
+
// Resolve the region-specific Run stream origin once per process (memoized
|
|
179
|
+
// in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
|
|
180
|
+
// still go through the Cursor agent-host allowlist.
|
|
181
|
+
const agentBaseUrl = resolveExplicitAgentBaseURL(options) ??
|
|
182
|
+
(await resolveAgentUrl(token, {
|
|
183
|
+
apiBaseURL: resolveApiBaseURL(options),
|
|
184
|
+
telemetryEnabled: resolveTelemetryEnabled(options),
|
|
185
|
+
}));
|
|
174
186
|
const providerOptions = callOptions.providerOptions?.cursor;
|
|
175
187
|
const reasoningEffort = providerOptions?.reasoningEffort;
|
|
176
188
|
const maxMode = !!(providerOptions?.maxMode ?? false);
|
|
@@ -180,7 +192,7 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
180
192
|
// that signal when a turn ends with tool-calls; the Cursor stream must stay
|
|
181
193
|
// open until we write the exec results on the next doStream.
|
|
182
194
|
const stream = await bidiRunStream(token, {
|
|
183
|
-
baseURL:
|
|
195
|
+
baseURL: agentBaseUrl,
|
|
184
196
|
headers: options.headers,
|
|
185
197
|
});
|
|
186
198
|
// Build the tool descriptors once — advertised in AgentRunRequest #4 mcp_tools
|
|
@@ -321,6 +333,25 @@ async function loadAvailableModels() {
|
|
|
321
333
|
}
|
|
322
334
|
catch { /* ignore */ }
|
|
323
335
|
}
|
|
336
|
+
function resolveApiBaseURL(options) {
|
|
337
|
+
return options.apiBaseURL ?? process.env.CURSOR_API_BASE_URL ?? `https://${CURSOR_API_HOST}`;
|
|
338
|
+
}
|
|
339
|
+
function resolveTelemetryEnabled(options) {
|
|
340
|
+
return options.telemetryEnabled ?? isTruthyEnv(process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY);
|
|
341
|
+
}
|
|
342
|
+
function resolveExplicitAgentBaseURL(options) {
|
|
343
|
+
const raw = options.agentBaseURL ?? options.baseURL;
|
|
344
|
+
if (!raw)
|
|
345
|
+
return undefined;
|
|
346
|
+
const normalized = normalizeAgentRunOrigin(raw);
|
|
347
|
+
if (!normalized) {
|
|
348
|
+
throw new Error("Invalid Cursor agent base URL override: expected https://*.cursor.sh");
|
|
349
|
+
}
|
|
350
|
+
return normalized;
|
|
351
|
+
}
|
|
352
|
+
function isTruthyEnv(value) {
|
|
353
|
+
return value === "1" || value === "true";
|
|
354
|
+
}
|
|
324
355
|
/**
|
|
325
356
|
* Read the held-open stream, emitting stream parts, until the turn boundary:
|
|
326
357
|
* - a tool call (exec_server_message) → emit tool-call, finish "tool-calls",
|
|
@@ -546,6 +577,7 @@ async function pump(session, controller, ids, abortSignal) {
|
|
|
546
577
|
resultField: parsed.resultField,
|
|
547
578
|
output: "",
|
|
548
579
|
error: reason,
|
|
580
|
+
toolName: parsed.toolName,
|
|
549
581
|
})) {
|
|
550
582
|
session.stream.write(frame);
|
|
551
583
|
}
|
|
@@ -557,7 +589,7 @@ async function pump(session, controller, ids, abortSignal) {
|
|
|
557
589
|
}
|
|
558
590
|
const tc = buildToolCallPart(parsed, session.sessionId);
|
|
559
591
|
// Keep the stream open; the result arrives on the next doStream call.
|
|
560
|
-
sessionManager.registerPending(parsed.id, session, parsed.resultField);
|
|
592
|
+
sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName);
|
|
561
593
|
// tc.input is already a JSON string (LanguageModelV3ToolCall.input).
|
|
562
594
|
trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
|
|
563
595
|
// Close open text/reasoning spans before tool-call (required by AI SDK V3).
|
package/dist/plugin.js
CHANGED
|
@@ -3,6 +3,7 @@ import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, gene
|
|
|
3
3
|
import { readCache, discoverModels, isCacheFresh } from "./models.js";
|
|
4
4
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
5
5
|
import { readStoredAuth } from "./context/auth-store.js";
|
|
6
|
+
import { resolveAgentUrl } from "./agent-url.js";
|
|
6
7
|
const MODULE_URL = new URL("./index.js", import.meta.url).href;
|
|
7
8
|
/**
|
|
8
9
|
* Strip characters that break rendering in the OpenCode Desktop webview from a
|
|
@@ -93,8 +94,16 @@ function modelsToConfig(models) {
|
|
|
93
94
|
}
|
|
94
95
|
return out;
|
|
95
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
|
+
}
|
|
96
104
|
export async function CursorPlugin(input) {
|
|
97
105
|
const cacheDir = opencodeGlobalCacheDir();
|
|
106
|
+
const apiBaseURL = cursorApiBaseURL();
|
|
98
107
|
// Last access token successfully resolved in this plugin instance. Config's
|
|
99
108
|
// loadModels can only read OpenCode's durable store (auth.json /
|
|
100
109
|
// OPENCODE_AUTH_CONTENT); auth.loader gets live credentials via getAuth().
|
|
@@ -139,7 +148,7 @@ export async function CursorPlugin(input) {
|
|
|
139
148
|
// it the same way as OAuth when it is expiring / already expired.
|
|
140
149
|
if (refreshToken && isExpiringSoon(auth.key)) {
|
|
141
150
|
try {
|
|
142
|
-
const newTokens = await refreshAccessToken(refreshToken);
|
|
151
|
+
const newTokens = await refreshAccessToken(refreshToken, apiBaseURL);
|
|
143
152
|
accessToken = newTokens.accessToken;
|
|
144
153
|
await persistAuthBestEffort({
|
|
145
154
|
type: "api",
|
|
@@ -166,7 +175,7 @@ export async function CursorPlugin(input) {
|
|
|
166
175
|
if (!auth.refresh)
|
|
167
176
|
return undefined;
|
|
168
177
|
try {
|
|
169
|
-
const newTokens = await refreshAccessToken(auth.refresh);
|
|
178
|
+
const newTokens = await refreshAccessToken(auth.refresh, apiBaseURL);
|
|
170
179
|
// Preserve optional OAuth fields (v2 Auth / plugin may carry these).
|
|
171
180
|
const extras = auth;
|
|
172
181
|
// Use the new token even if persisting back to OpenCode fails.
|
|
@@ -197,7 +206,7 @@ export async function CursorPlugin(input) {
|
|
|
197
206
|
const accessToken = await resolveAccessToken(auth);
|
|
198
207
|
if (accessToken) {
|
|
199
208
|
try {
|
|
200
|
-
const models = await discoverModels(accessToken, cacheDir);
|
|
209
|
+
const models = await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL });
|
|
201
210
|
return modelsToConfig(models);
|
|
202
211
|
}
|
|
203
212
|
catch {
|
|
@@ -280,7 +289,7 @@ export async function CursorPlugin(input) {
|
|
|
280
289
|
if (!apiKey)
|
|
281
290
|
return { type: "failed" };
|
|
282
291
|
try {
|
|
283
|
-
const result = await exchangeApiKey(apiKey);
|
|
292
|
+
const result = await exchangeApiKey(apiKey, apiBaseURL);
|
|
284
293
|
return {
|
|
285
294
|
type: "success",
|
|
286
295
|
key: result.accessToken,
|
|
@@ -306,8 +315,16 @@ export async function CursorPlugin(input) {
|
|
|
306
315
|
if (!cached || cached.models.length === 0 || !isCacheFresh(cached)) {
|
|
307
316
|
// Await so an empty/missing cache is written before the loader returns
|
|
308
317
|
// (fire-and-forget often loses the race on short-lived CLI commands).
|
|
309
|
-
await discoverModels(accessToken, cacheDir).catch(() => { });
|
|
318
|
+
await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL }).catch(() => { });
|
|
310
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(() => { });
|
|
311
328
|
}
|
|
312
329
|
return {
|
|
313
330
|
...(accessToken ? { accessToken } : {}),
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export type ToolResultInput = {
|
|
|
53
53
|
output: string;
|
|
54
54
|
error?: string;
|
|
55
55
|
executionTimeMs?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Resolved opencode tool name (read/write/grep/…). Gates read-envelope
|
|
58
|
+
* unwrapping on the mcp_result path so non-read MCP output is never
|
|
59
|
+
* rewritten. read_result is always a read, so it unwraps regardless.
|
|
60
|
+
*/
|
|
61
|
+
toolName?: string;
|
|
56
62
|
};
|
|
57
63
|
/**
|
|
58
64
|
* Build one or more ExecClientMessage frames for a tool result.
|
|
@@ -64,12 +70,33 @@ export type ToolResultInput = {
|
|
|
64
70
|
export declare function buildExecClientMessages(input: ToolResultInput): Uint8Array[];
|
|
65
71
|
/** ACM #5 exec_client_control_message { stream_close { id } }. */
|
|
66
72
|
export declare function buildExecStreamClose(execId: number): Uint8Array;
|
|
73
|
+
/**
|
|
74
|
+
* Strip opencode's `read` envelope, leaving raw file content.
|
|
75
|
+
*
|
|
76
|
+
* opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
|
|
77
|
+
* envelope its own models are trained on, but Cursor's are not:
|
|
78
|
+
* <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
|
|
79
|
+
* Forwarding that envelope verbatim made Cursor's model treat the wrapper as
|
|
80
|
+
* literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
|
|
81
|
+
* back into files (silent corruption — the write still reports success; seen
|
|
82
|
+
* across 8+ sessions, e.g. language-model.ts rewritten starting with
|
|
83
|
+
* `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
|
|
84
|
+
*
|
|
85
|
+
* Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
|
|
86
|
+
*
|
|
87
|
+
* Deliberately exception-safe: if the expected envelope is absent — non-read
|
|
88
|
+
* output, already-raw text, or a future opencode format change — it returns the
|
|
89
|
+
* input unchanged, so a result is never broken and we never throw mid-turn.
|
|
90
|
+
* Callers must still gate `mcp_result` on `toolName === "read"`; this helper
|
|
91
|
+
* alone is not a tool-identity check.
|
|
92
|
+
*/
|
|
93
|
+
export declare function unwrapReadOutput(output: string): string;
|
|
67
94
|
/**
|
|
68
95
|
* Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
|
|
69
96
|
* OpenCode returns free-form text; we wrap it in the minimal success shape the
|
|
70
97
|
* server accepts (verified against agent.v1 wire captures).
|
|
71
98
|
*/
|
|
72
|
-
export declare function buildTypedExecResult(resultField: string, output: string, error?: string): Record<string, unknown>;
|
|
99
|
+
export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string): Record<string, unknown>;
|
|
73
100
|
export declare function buildToolCallPart(execMsg: ParsedExecRequest, sessionId: string): {
|
|
74
101
|
toolCallId: string;
|
|
75
102
|
toolName: string;
|
package/dist/protocol/tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { encodeMessage } from "./messages.js";
|
|
2
2
|
import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
|
|
3
|
+
import { trace } from "../debug.js";
|
|
3
4
|
// Exec variant field number whose reply is the server-initiated request_context
|
|
4
5
|
// probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
|
|
5
6
|
// field number for every exec variant, so this is also the result field.
|
|
@@ -394,7 +395,7 @@ export function buildExecClientMessages(input) {
|
|
|
394
395
|
id: input.execId,
|
|
395
396
|
local_execution_time_ms: input.executionTimeMs ?? 0,
|
|
396
397
|
};
|
|
397
|
-
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error);
|
|
398
|
+
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName);
|
|
398
399
|
frames.push(encodeMessage("AgentClientMessage", {
|
|
399
400
|
exec_client_message: clientMsg,
|
|
400
401
|
}));
|
|
@@ -411,21 +412,90 @@ export function buildExecStreamClose(execId) {
|
|
|
411
412
|
},
|
|
412
413
|
});
|
|
413
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Strip opencode's `read` envelope, leaving raw file content.
|
|
417
|
+
*
|
|
418
|
+
* opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
|
|
419
|
+
* envelope its own models are trained on, but Cursor's are not:
|
|
420
|
+
* <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
|
|
421
|
+
* Forwarding that envelope verbatim made Cursor's model treat the wrapper as
|
|
422
|
+
* literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
|
|
423
|
+
* back into files (silent corruption — the write still reports success; seen
|
|
424
|
+
* across 8+ sessions, e.g. language-model.ts rewritten starting with
|
|
425
|
+
* `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
|
|
426
|
+
*
|
|
427
|
+
* Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
|
|
428
|
+
*
|
|
429
|
+
* Deliberately exception-safe: if the expected envelope is absent — non-read
|
|
430
|
+
* output, already-raw text, or a future opencode format change — it returns the
|
|
431
|
+
* input unchanged, so a result is never broken and we never throw mid-turn.
|
|
432
|
+
* Callers must still gate `mcp_result` on `toolName === "read"`; this helper
|
|
433
|
+
* alone is not a tool-identity check.
|
|
434
|
+
*/
|
|
435
|
+
export function unwrapReadOutput(output) {
|
|
436
|
+
if (typeof output !== "string" || output.length === 0)
|
|
437
|
+
return output;
|
|
438
|
+
// Require the full opencode read-envelope skeleton *before* `<content>`
|
|
439
|
+
// (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
|
|
440
|
+
// stray "<content>" later in tool chatter can't trigger unwrapping just
|
|
441
|
+
// because path/type tags appear elsewhere in the payload.
|
|
442
|
+
const contentHeaderIdx = output.indexOf("<content>");
|
|
443
|
+
if (contentHeaderIdx === -1)
|
|
444
|
+
return output;
|
|
445
|
+
const header = output.slice(0, contentHeaderIdx);
|
|
446
|
+
const hasSkeleton = header.indexOf("<path>") !== -1 &&
|
|
447
|
+
header.indexOf("<type>file</type>") !== -1;
|
|
448
|
+
if (!hasSkeleton) {
|
|
449
|
+
// Saw "<content>" but not the read skeleton ahead of it — almost certainly
|
|
450
|
+
// a non-read payload, or an opencode format drift. Surface it so drift
|
|
451
|
+
// can't silently resurrect the wrapper-corruption bug, but still fail
|
|
452
|
+
// safe (no mutate).
|
|
453
|
+
trace("unwrapReadOutput: <content> present without leading <path>/<type>file> skeleton — leaving output unchanged (possible non-read payload or opencode read format drift)");
|
|
454
|
+
return output;
|
|
455
|
+
}
|
|
456
|
+
// Body starts right after "<content>\n". opencode emits one numbered line per
|
|
457
|
+
// file line ("N: <line>"), then a blank, a "(…)" footer, and a standalone
|
|
458
|
+
// "</content>". The body is a *contiguous run* of /^N: / lines — so we stop at
|
|
459
|
+
// the first non-numbered line. Critically we do NOT search for a closing
|
|
460
|
+
// "</content>" substring: a file line that literally contains "</content>"
|
|
461
|
+
// is rendered as "N: </content>" (a body line), and a raw indexOf would
|
|
462
|
+
// truncate the read there.
|
|
463
|
+
let rest = output.slice(contentHeaderIdx + "<content>".length);
|
|
464
|
+
if (rest.startsWith("\n"))
|
|
465
|
+
rest = rest.slice(1);
|
|
466
|
+
const raw = [];
|
|
467
|
+
for (const line of rest.split("\n")) {
|
|
468
|
+
const m = /^(\d+):[ \t]?(.*)$/.exec(line);
|
|
469
|
+
if (!m)
|
|
470
|
+
break; // blank / "(footer)" / "</content>" → end of body run
|
|
471
|
+
// Strip only the leading "N: " prefix; a line that itself begins with
|
|
472
|
+
// digits+colon keeps its content (we remove just the first match). Blank
|
|
473
|
+
// file lines render as "N: " and are preserved (capture group is "").
|
|
474
|
+
raw.push(m[2]);
|
|
475
|
+
}
|
|
476
|
+
// Envelope confirmed but no numbered body → empty file. Return "" rather
|
|
477
|
+
// than the envelope (the envelope is exactly what Cursor echoes into writes).
|
|
478
|
+
return raw.join("\n");
|
|
479
|
+
}
|
|
414
480
|
/**
|
|
415
481
|
* Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
|
|
416
482
|
* OpenCode returns free-form text; we wrap it in the minimal success shape the
|
|
417
483
|
* server accepts (verified against agent.v1 wire captures).
|
|
418
484
|
*/
|
|
419
|
-
export function buildTypedExecResult(resultField, output, error) {
|
|
485
|
+
export function buildTypedExecResult(resultField, output, error, toolName) {
|
|
420
486
|
switch (resultField) {
|
|
421
487
|
case "read_result":
|
|
422
488
|
if (error)
|
|
423
489
|
return { error: { path: "", error } };
|
|
490
|
+
// Strip opencode's <path>/<content> envelope so Cursor's model receives
|
|
491
|
+
// raw file content and can't echo the wrapper into subsequent writes.
|
|
492
|
+
// reads route here only for native read_args; most arrive via mcp_result.
|
|
493
|
+
const content = unwrapReadOutput(output);
|
|
424
494
|
return {
|
|
425
495
|
success: {
|
|
426
496
|
path: extractPathTag(output) ?? "",
|
|
427
|
-
content
|
|
428
|
-
total_lines: countLines(
|
|
497
|
+
content,
|
|
498
|
+
total_lines: countLines(content),
|
|
429
499
|
},
|
|
430
500
|
};
|
|
431
501
|
case "grep_result": {
|
|
@@ -492,9 +562,15 @@ export function buildTypedExecResult(resultField, output, error) {
|
|
|
492
562
|
case "mcp_result":
|
|
493
563
|
if (error)
|
|
494
564
|
return { error: { error } };
|
|
565
|
+
// opencode built-ins (read/write/grep/…) are advertised as MCP tools, so
|
|
566
|
+
// a read call returns through mcp_result. Scope the unwrap to toolName
|
|
567
|
+
// "read" so a non-read MCP tool whose output merely contains a
|
|
568
|
+
// "<content>"-like block is never rewritten.
|
|
495
569
|
return {
|
|
496
570
|
success: {
|
|
497
|
-
content: [
|
|
571
|
+
content: [
|
|
572
|
+
{ text: { text: toolName === "read" ? unwrapReadOutput(output) : output } },
|
|
573
|
+
],
|
|
498
574
|
is_error: false,
|
|
499
575
|
},
|
|
500
576
|
};
|
package/dist/session.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ export type Frame = {
|
|
|
14
14
|
export type PendingExec = {
|
|
15
15
|
/** ExecClientMessage result field to reply with (matches the request variant). */
|
|
16
16
|
resultField: string;
|
|
17
|
+
/**
|
|
18
|
+
* Resolved opencode tool name (read/write/grep/…). Used on continuation so
|
|
19
|
+
* mcp_result can unwrap read envelopes even if the prompt omits toolName.
|
|
20
|
+
*/
|
|
21
|
+
toolName?: string;
|
|
17
22
|
};
|
|
18
23
|
export type CursorSession = {
|
|
19
24
|
/**
|
|
@@ -58,7 +63,7 @@ export declare class SessionManager {
|
|
|
58
63
|
constructor(idleTimeoutMs?: number);
|
|
59
64
|
touch(session: CursorSession): void;
|
|
60
65
|
/** Register that `session` is awaiting a result for `execId`. */
|
|
61
|
-
registerPending(execId: number, session: CursorSession, resultField: string): void;
|
|
66
|
+
registerPending(execId: number, session: CursorSession, resultField: string, toolName?: string): void;
|
|
62
67
|
/** The pending exec info for an id on a specific session, if still awaiting it. */
|
|
63
68
|
pendingFor(sessionId: string, execId: number): PendingExec | undefined;
|
|
64
69
|
/** Find the live session awaiting one of the given exec ids. */
|
package/dist/session.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { trace } from "./
|
|
1
|
+
import { trace } from "./debug.js";
|
|
2
2
|
export class SessionManager {
|
|
3
3
|
// Composite key `${sessionId}:${execId}` → owning session. Composite keying
|
|
4
4
|
// means two Run streams that both register an execId of 1 (Cursor resets
|
|
@@ -12,8 +12,8 @@ export class SessionManager {
|
|
|
12
12
|
session.expiresAt = Date.now() + this.idleTimeoutMs;
|
|
13
13
|
}
|
|
14
14
|
/** Register that `session` is awaiting a result for `execId`. */
|
|
15
|
-
registerPending(execId, session, resultField) {
|
|
16
|
-
session.pending.set(execId, { resultField });
|
|
15
|
+
registerPending(execId, session, resultField, toolName) {
|
|
16
|
+
session.pending.set(execId, { resultField, toolName });
|
|
17
17
|
this.byExecId.set(this.key(session.sessionId, execId), session);
|
|
18
18
|
this.touch(session);
|
|
19
19
|
}
|
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,34 @@
|
|
|
1
|
-
export declare function trace(msg: string): void;
|
|
2
1
|
export declare function buildBaseHeaders(token: string, clientVersion: string, extra?: Record<string, string>): Record<string, string>;
|
|
3
2
|
export declare function unaryAvailableModels(token: string, options?: {
|
|
3
|
+
apiBaseURL?: string;
|
|
4
4
|
baseURL?: string;
|
|
5
5
|
headers?: Record<string, string>;
|
|
6
6
|
}): Promise<Record<string, unknown>>;
|
|
7
|
+
/**
|
|
8
|
+
* Cursor Run stream hosts from GetServerConfig / agentBaseURL.
|
|
9
|
+
* Any HTTPS subdomain of cursor.sh is accepted — hostnames vary
|
|
10
|
+
* (agentn.*, agent.*, agent-gcpp-*, api5 / api5lat, …) and may change.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isAllowedAgentHost(hostname: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Normalize a GetServerConfig agent URL to an https origin.
|
|
15
|
+
* Returns null for missing, malformed, non-https, or non-*.cursor.sh hosts.
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalizeAgentRunOrigin(raw: string | undefined): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Fetch the Cursor server config (a Connect unary RPC on the API host) and
|
|
20
|
+
* return the `agentUrlConfig.agentnUrl` field — the region-specific Run stream
|
|
21
|
+
* origin the server routes this account/team to (e.g. `agentn.us.api5.cursor.sh`).
|
|
22
|
+
*
|
|
23
|
+
* Region-routed accounts can be silently rejected by the wrong regional host, so
|
|
24
|
+
* this lookup fails closed instead of substituting any host on error. Any
|
|
25
|
+
* authoritative `*.cursor.sh` origin from GetServerConfig is accepted.
|
|
26
|
+
*/
|
|
27
|
+
export declare function fetchAgentUrl(token: string, options?: {
|
|
28
|
+
apiBaseURL?: string;
|
|
29
|
+
baseURL?: string;
|
|
30
|
+
telemetryEnabled?: boolean;
|
|
31
|
+
}): Promise<string>;
|
|
7
32
|
export type BidiStream = {
|
|
8
33
|
write(msg: Uint8Array): void;
|
|
9
34
|
end(): void;
|
|
@@ -14,10 +39,10 @@ export type BidiStream = {
|
|
|
14
39
|
destroy(): void;
|
|
15
40
|
};
|
|
16
41
|
/** 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
|
|
42
|
+
export declare function resolveAgentOrigin(baseURL: string): string;
|
|
43
|
+
export declare function bidiRunStream(token: string, options: {
|
|
19
44
|
signal?: AbortSignal;
|
|
20
|
-
baseURL
|
|
45
|
+
baseURL: string;
|
|
21
46
|
headers?: Record<string, string>;
|
|
22
47
|
}): Promise<BidiStream>;
|
|
23
48
|
export declare function makeRequestId(): string;
|
|
@@ -1,31 +1,13 @@
|
|
|
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";
|
|
5
5
|
import { resolveClientVersion } from "../protocol/client-version.js";
|
|
6
|
+
import { trace } from "../debug.js";
|
|
6
7
|
import http2 from "node:http2";
|
|
7
|
-
import fs from "node:fs";
|
|
8
8
|
const API_BASE = `https://${CURSOR_API_HOST}`;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
|
|
12
|
-
// Truncated once per process. Captures h2 response status, parsed frames, and
|
|
13
|
-
// stream errors. Tokens / checksums are redacted in header dumps.
|
|
14
|
-
const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
|
|
15
|
-
process.env.CURSOR_PROVIDER_DEBUG === "true";
|
|
16
|
-
const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
|
|
17
|
-
let _traceInitialized = false;
|
|
18
|
-
export function trace(msg) {
|
|
19
|
-
if (!DEBUG_ENABLED)
|
|
20
|
-
return;
|
|
21
|
-
try {
|
|
22
|
-
if (!_traceInitialized) {
|
|
23
|
-
_traceInitialized = true;
|
|
24
|
-
fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
|
|
25
|
-
}
|
|
26
|
-
fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
|
|
27
|
-
}
|
|
28
|
-
catch { /* ignore */ }
|
|
9
|
+
function resolveApiBaseURL(options) {
|
|
10
|
+
return new URL(options.apiBaseURL ?? options.baseURL ?? API_BASE).origin;
|
|
29
11
|
}
|
|
30
12
|
trace("connect.ts module loaded");
|
|
31
13
|
export function buildBaseHeaders(token, clientVersion, extra) {
|
|
@@ -43,7 +25,7 @@ export function buildBaseHeaders(token, clientVersion, extra) {
|
|
|
43
25
|
}
|
|
44
26
|
// ── Unary (AvailableModels) ──
|
|
45
27
|
export async function unaryAvailableModels(token, options = {}) {
|
|
46
|
-
const base = options
|
|
28
|
+
const base = resolveApiBaseURL(options);
|
|
47
29
|
const url = `${base}/aiserver.v1.AiService/AvailableModels`;
|
|
48
30
|
const clientVersion = await resolveClientVersion();
|
|
49
31
|
const headers = buildBaseHeaders(token, clientVersion, options.headers);
|
|
@@ -62,6 +44,82 @@ export async function unaryAvailableModels(token, options = {}) {
|
|
|
62
44
|
}
|
|
63
45
|
return (await res.json());
|
|
64
46
|
}
|
|
47
|
+
// ── Unary (GetServerConfig) ──
|
|
48
|
+
/**
|
|
49
|
+
* Cursor Run stream hosts from GetServerConfig / agentBaseURL.
|
|
50
|
+
* Any HTTPS subdomain of cursor.sh is accepted — hostnames vary
|
|
51
|
+
* (agentn.*, agent.*, agent-gcpp-*, api5 / api5lat, …) and may change.
|
|
52
|
+
*/
|
|
53
|
+
export function isAllowedAgentHost(hostname) {
|
|
54
|
+
return /^([a-z0-9-]+\.)+cursor\.sh$/i.test(hostname);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Normalize a GetServerConfig agent URL to an https origin.
|
|
58
|
+
* Returns null for missing, malformed, non-https, or non-*.cursor.sh hosts.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeAgentRunOrigin(raw) {
|
|
61
|
+
if (raw === undefined || raw === null)
|
|
62
|
+
return null;
|
|
63
|
+
const trimmed = String(raw).trim();
|
|
64
|
+
if (!trimmed)
|
|
65
|
+
return null;
|
|
66
|
+
try {
|
|
67
|
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
68
|
+
const parsed = new URL(withScheme);
|
|
69
|
+
if (parsed.protocol !== "https:")
|
|
70
|
+
return null;
|
|
71
|
+
if (parsed.username || parsed.password)
|
|
72
|
+
return null;
|
|
73
|
+
if (!isAllowedAgentHost(parsed.hostname))
|
|
74
|
+
return null;
|
|
75
|
+
return parsed.origin;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Fetch the Cursor server config (a Connect unary RPC on the API host) and
|
|
83
|
+
* return the `agentUrlConfig.agentnUrl` field — the region-specific Run stream
|
|
84
|
+
* origin the server routes this account/team to (e.g. `agentn.us.api5.cursor.sh`).
|
|
85
|
+
*
|
|
86
|
+
* Region-routed accounts can be silently rejected by the wrong regional host, so
|
|
87
|
+
* this lookup fails closed instead of substituting any host on error. Any
|
|
88
|
+
* authoritative `*.cursor.sh` origin from GetServerConfig is accepted.
|
|
89
|
+
*/
|
|
90
|
+
export async function fetchAgentUrl(token, options = {}) {
|
|
91
|
+
const base = resolveApiBaseURL(options);
|
|
92
|
+
const url = `${base}${SERVER_CONFIG_PATH}`;
|
|
93
|
+
const clientVersion = await resolveClientVersion();
|
|
94
|
+
// Match Cursor CLI: GetServerConfig uses base headers only — session headers belong on Run.
|
|
95
|
+
const headers = buildBaseHeaders(token, clientVersion);
|
|
96
|
+
trace(`GetServerConfig POST ${url}`);
|
|
97
|
+
const res = await fetch(url, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: {
|
|
100
|
+
...headers,
|
|
101
|
+
"content-type": "application/json",
|
|
102
|
+
accept: "application/json",
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({ telem_enabled: options.telemetryEnabled ?? false }),
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
const text = await res.text().catch(() => "");
|
|
108
|
+
throw new Error(`GetServerConfig failed: ${res.status} ${res.statusText}${text ? ` - ${text.slice(0, 200)}` : ""}`);
|
|
109
|
+
}
|
|
110
|
+
const body = (await res.json());
|
|
111
|
+
const cfg = body.agentUrlConfig;
|
|
112
|
+
const raw = cfg?.agentnUrl || cfg?.agentUrl;
|
|
113
|
+
const normalized = normalizeAgentRunOrigin(raw);
|
|
114
|
+
trace(`GetServerConfig reply: agentnUrl=${cfg?.agentnUrl ?? "<missing>"} ` +
|
|
115
|
+
`agentUrl=${cfg?.agentUrl ?? "<missing>"} → ${normalized ?? "<invalid>"}`);
|
|
116
|
+
if (!normalized) {
|
|
117
|
+
throw new Error(raw
|
|
118
|
+
? "GetServerConfig returned an invalid Cursor agent URL"
|
|
119
|
+
: "GetServerConfig response missing agentUrlConfig.agentnUrl");
|
|
120
|
+
}
|
|
121
|
+
return normalized;
|
|
122
|
+
}
|
|
65
123
|
// Cache http2 sessions keyed by origin so a custom baseURL never reuses a
|
|
66
124
|
// connection opened to the default agent host — and vice versa.
|
|
67
125
|
const _http2Sessions = new Map();
|
|
@@ -73,8 +131,11 @@ const _http2Connecting = new Map();
|
|
|
73
131
|
const CONNECT_TIMEOUT_MS = 15_000;
|
|
74
132
|
/** Resolve the HTTP/2 connect origin for a Run stream (exported for tests). */
|
|
75
133
|
export function resolveAgentOrigin(baseURL) {
|
|
76
|
-
|
|
77
|
-
|
|
134
|
+
const origin = normalizeAgentRunOrigin(baseURL);
|
|
135
|
+
if (!origin) {
|
|
136
|
+
throw new Error("Cursor Run stream requires an allowlisted Cursor agent base URL");
|
|
137
|
+
}
|
|
138
|
+
return origin;
|
|
78
139
|
}
|
|
79
140
|
function dropSession(origin, session) {
|
|
80
141
|
if (_http2Sessions.get(origin) === session)
|
|
@@ -133,7 +194,7 @@ function getSession(baseURL) {
|
|
|
133
194
|
_http2Connecting.set(origin, promise);
|
|
134
195
|
return promise;
|
|
135
196
|
}
|
|
136
|
-
export async function bidiRunStream(token, options
|
|
197
|
+
export async function bidiRunStream(token, options) {
|
|
137
198
|
const [session, clientVersion] = await Promise.all([
|
|
138
199
|
getSession(options.baseURL),
|
|
139
200
|
resolveClientVersion(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-opencode-provider",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -54,12 +54,12 @@
|
|
|
54
54
|
"protobufjs": "^7.4.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@opencode-ai/plugin": "
|
|
57
|
+
"@opencode-ai/plugin": "^1.17.13"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@opencode-ai/plugin": "
|
|
60
|
+
"@opencode-ai/plugin": "^1.17.13",
|
|
61
61
|
"@tsconfig/node22": "^22.0.1",
|
|
62
62
|
"@types/node": "^22.15.3",
|
|
63
63
|
"typescript": "^5.8.3"
|
|
64
64
|
}
|
|
65
|
-
}
|
|
65
|
+
}
|