Skip to content

Commit b9473f2

Browse files
la14-1AhmedTMMclaude
authored
feat: add OpenRouter proxy for Cursor CLI agent (#3100)
Cursor CLI uses a proprietary ConnectRPC/protobuf protocol with BiDi streaming over HTTP/2. It validates API keys against Cursor's own servers and hardcodes api2.cursor.sh for agent streaming — making direct OpenRouter integration impossible. This adds a local translation proxy that intercepts Cursor's protocol and routes LLM traffic through OpenRouter: Architecture: Cursor CLI → Caddy (HTTPS/H2, port 443) → split routing: /agent.v1.AgentService/* → H2C Node.js (BiDi streaming → OpenRouter) everything else → HTTP/1.1 Node.js (fake auth, models, config) Key components: - cursor-proxy.ts: proxy scripts + deployment functions - Caddy reverse proxy for TLS + HTTP/2 termination - /etc/hosts spoofing to intercept api2.cursor.sh - Hand-rolled protobuf codec for AgentServerMessage format - SSE stream translation (OpenRouter → ConnectRPC protobuf frames) Proto schemas reverse-engineered from Cursor CLI binary v2026.03.25: - AgentServerMessage.InteractionUpdate.TextDeltaUpdate.text - agent.v1.ModelDetails (model_id, display_model_id, display_name) - TurnEndedUpdate (input_tokens, output_tokens) Tested end-to-end on Sprite VM: Cursor CLI printed proxy response with EXIT=0. Co-authored-by: Ahmed Abushagur <ahmed@abushagur.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ccd8600 commit b9473f2

6 files changed

Lines changed: 791 additions & 63 deletions

File tree

manifest.json

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -306,16 +306,13 @@
306306
]
307307
},
308308
"cursor": {
309-
"disabled": true,
310-
"disabled_reason": "Cursor CLI uses a proprietary protocol (ConnectRPC) and validates API keys against Cursor's own servers. Cannot route through OpenRouter. Re-enable when Cursor adds BYOK/custom endpoint support for agent mode.",
311309
"name": "Cursor CLI",
312310
"description": "Cursor's terminal-based AI coding agent — autonomous coding with plan, agent, and ask modes",
313311
"url": "https://cursor.com/cli",
314312
"install": "curl https://cursor.com/install -fsS | bash",
315313
"launch": "agent",
316314
"env": {
317-
"OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}",
318-
"CURSOR_API_KEY": "${OPENROUTER_API_KEY}"
315+
"OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}"
319316
},
320317
"config_files": {
321318
"~/.cursor/cli-config.json": {
@@ -332,7 +329,7 @@
332329
}
333330
}
334331
},
335-
"notes": "Works with OpenRouter via --endpoint flag pointing to openrouter.ai/api/v1 and CURSOR_API_KEY set to OpenRouter key. Binary installs to ~/.local/bin/agent.",
332+
"notes": "Routes through OpenRouter via a local ConnectRPC-to-REST translation proxy (Caddy + Node.js). The proxy intercepts Cursor's proprietary protobuf protocol, translates to OpenAI-compatible API calls, and streams responses back. Binary installs to ~/.local/bin/agent.",
336333
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/cursor.png",
337334
"featured_cloud": [
338335
"digitalocean",

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openrouter/spawn",
3-
"version": "0.27.6",
3+
"version": "0.28.0",
44
"type": "module",
55
"bin": {
66
"spawn": "cli.js"

packages/cli/src/__tests__/agent-setup-cov.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ describe("createCloudAgents", () => {
246246
expect([
247247
"minimal",
248248
"node",
249+
"bun",
249250
"full",
250251
]).toContain(agent.cloudInitTier);
251252
}
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
/**
2+
* cursor-proxy.test.ts — Tests for the Cursor CLI → OpenRouter proxy.
3+
* Covers: protobuf encoding, ConnectRPC framing, model details, deployment functions.
4+
*/
5+
6+
import { describe, expect, it, mock } from "bun:test";
7+
import { tryCatch } from "../shared/result";
8+
9+
// ── Protobuf helpers (mirrors the proxy script's functions) ─────────────────
10+
11+
function ev(v: number): Buffer {
12+
const b: number[] = [];
13+
while (v > 0x7f) {
14+
b.push((v & 0x7f) | 0x80);
15+
v >>>= 7;
16+
}
17+
b.push(v & 0x7f);
18+
return Buffer.from(b);
19+
}
20+
21+
function es(f: number, s: string): Buffer {
22+
const sb = Buffer.from(s);
23+
return Buffer.concat([
24+
ev((f << 3) | 2),
25+
ev(sb.length),
26+
sb,
27+
]);
28+
}
29+
30+
function em(f: number, p: Buffer): Buffer {
31+
return Buffer.concat([
32+
ev((f << 3) | 2),
33+
ev(p.length),
34+
p,
35+
]);
36+
}
37+
38+
// ConnectRPC frame
39+
function cf(p: Buffer): Buffer {
40+
const f = Buffer.alloc(5 + p.length);
41+
f[0] = 0x00;
42+
f.writeUInt32BE(p.length, 1);
43+
p.copy(f, 5);
44+
return f;
45+
}
46+
47+
// ConnectRPC trailer
48+
function ct(): Buffer {
49+
const j = Buffer.from("{}");
50+
const t = Buffer.alloc(5 + j.length);
51+
t[0] = 0x02;
52+
t.writeUInt32BE(j.length, 1);
53+
j.copy(t, 5);
54+
return t;
55+
}
56+
57+
// AgentServerMessage.InteractionUpdate.TextDeltaUpdate
58+
function tdf(text: string): Buffer {
59+
return cf(em(1, em(1, es(1, text))));
60+
}
61+
62+
// AgentServerMessage.InteractionUpdate.TurnEndedUpdate
63+
function tef(): Buffer {
64+
return cf(
65+
em(
66+
1,
67+
em(
68+
14,
69+
Buffer.from([
70+
8,
71+
10,
72+
16,
73+
5,
74+
]),
75+
),
76+
),
77+
);
78+
}
79+
80+
// ModelDetails
81+
function bmd(id: string, name: string): Buffer {
82+
return Buffer.concat([
83+
es(1, id),
84+
es(3, id),
85+
es(4, name),
86+
es(5, name),
87+
]);
88+
}
89+
90+
// Extract strings from protobuf
91+
function xstr(buf: Buffer, out: string[]): void {
92+
let o = 0;
93+
while (o < buf.length) {
94+
let t = 0;
95+
let s = 0;
96+
while (o < buf.length) {
97+
const b = buf[o++];
98+
t |= (b & 0x7f) << s;
99+
s += 7;
100+
if (!(b & 0x80)) {
101+
break;
102+
}
103+
}
104+
const wt = t & 7;
105+
if (wt === 0) {
106+
while (o < buf.length && buf[o++] & 0x80) {
107+
/* consume varint */
108+
}
109+
} else if (wt === 2) {
110+
let len = 0;
111+
let ls = 0;
112+
while (o < buf.length) {
113+
const b = buf[o++];
114+
len |= (b & 0x7f) << ls;
115+
ls += 7;
116+
if (!(b & 0x80)) {
117+
break;
118+
}
119+
}
120+
const d = buf.slice(o, o + len);
121+
o += len;
122+
const st = d.toString("utf8");
123+
if (/^[\x20-\x7e]+$/.test(st)) {
124+
out.push(st);
125+
} else {
126+
const r = tryCatch(() => xstr(d, out));
127+
if (!r.ok) {
128+
/* ignore nested parse errors */
129+
}
130+
}
131+
} else {
132+
break;
133+
}
134+
}
135+
}
136+
137+
// ── Tests ───────────────────────────────────────────────────────────────────
138+
139+
describe("protobuf encoding", () => {
140+
it("encodes varint correctly", () => {
141+
expect(ev(0)).toEqual(
142+
Buffer.from([
143+
0,
144+
]),
145+
);
146+
expect(ev(1)).toEqual(
147+
Buffer.from([
148+
1,
149+
]),
150+
);
151+
expect(ev(127)).toEqual(
152+
Buffer.from([
153+
127,
154+
]),
155+
);
156+
expect(ev(128)).toEqual(
157+
Buffer.from([
158+
0x80,
159+
0x01,
160+
]),
161+
);
162+
expect(ev(300)).toEqual(
163+
Buffer.from([
164+
0xac,
165+
0x02,
166+
]),
167+
);
168+
});
169+
170+
it("encodes string fields", () => {
171+
const buf = es(1, "hello");
172+
// field 1, wire type 2 (length-delimited) = tag 0x0a
173+
expect(buf[0]).toBe(0x0a);
174+
// length = 5
175+
expect(buf[1]).toBe(5);
176+
// string content
177+
expect(buf.slice(2).toString("utf8")).toBe("hello");
178+
});
179+
180+
it("encodes nested messages", () => {
181+
const inner = es(1, "test");
182+
const outer = em(2, inner);
183+
// field 2, wire type 2 = tag 0x12
184+
expect(outer[0]).toBe(0x12);
185+
// length of inner message
186+
expect(outer[1]).toBe(inner.length);
187+
});
188+
});
189+
190+
describe("ConnectRPC framing", () => {
191+
it("wraps payload in a frame with 5-byte header", () => {
192+
const payload = Buffer.from("test");
193+
const frame = cf(payload);
194+
expect(frame.length).toBe(5 + payload.length);
195+
expect(frame[0]).toBe(0x00); // no compression
196+
expect(frame.readUInt32BE(1)).toBe(payload.length);
197+
expect(frame.slice(5).toString()).toBe("test");
198+
});
199+
200+
it("creates a JSON trailer frame", () => {
201+
const trailer = ct();
202+
expect(trailer[0]).toBe(0x02); // JSON type
203+
expect(trailer.readUInt32BE(1)).toBe(2); // length of "{}"
204+
expect(trailer.slice(5).toString()).toBe("{}");
205+
});
206+
});
207+
208+
describe("AgentServerMessage encoding", () => {
209+
it("encodes text delta update", () => {
210+
const frame = tdf("Hello world");
211+
// Should be a ConnectRPC frame (starts with 0x00)
212+
expect(frame[0]).toBe(0x00);
213+
// Payload should contain the text
214+
const payload = frame.slice(5);
215+
const strings: string[] = [];
216+
xstr(payload, strings);
217+
expect(strings).toContain("Hello world");
218+
});
219+
220+
it("encodes turn ended update", () => {
221+
const frame = tef();
222+
expect(frame[0]).toBe(0x00);
223+
// Payload should be non-empty (contains token counts)
224+
const payloadLen = frame.readUInt32BE(1);
225+
expect(payloadLen).toBeGreaterThan(0);
226+
});
227+
});
228+
229+
describe("ModelDetails encoding", () => {
230+
it("encodes model with all required fields", () => {
231+
const model = bmd("claude-4-sonnet", "Claude Sonnet 4");
232+
const strings: string[] = [];
233+
xstr(model, strings);
234+
expect(strings).toContain("claude-4-sonnet");
235+
expect(strings).toContain("Claude Sonnet 4");
236+
});
237+
238+
it("encodes model list response", () => {
239+
const models = [
240+
[
241+
"claude-4-sonnet",
242+
"Claude 4",
243+
],
244+
[
245+
"gpt-4o",
246+
"GPT-4o",
247+
],
248+
];
249+
const response = Buffer.concat(models.map(([id, name]) => em(1, bmd(id, name))));
250+
const strings: string[] = [];
251+
xstr(response, strings);
252+
expect(strings).toContain("claude-4-sonnet");
253+
expect(strings).toContain("gpt-4o");
254+
});
255+
});
256+
257+
describe("protobuf string extraction", () => {
258+
it("extracts strings from nested protobuf", () => {
259+
// Simulate a request with user message
260+
const msg = em(
261+
1,
262+
Buffer.concat([
263+
es(1, "say hello"),
264+
es(2, "uuid-1234-5678"),
265+
]),
266+
);
267+
const strings: string[] = [];
268+
xstr(msg, strings);
269+
expect(strings).toContain("say hello");
270+
expect(strings).toContain("uuid-1234-5678");
271+
});
272+
273+
it("skips binary data", () => {
274+
const binary = Buffer.from([
275+
0x0a,
276+
0x03,
277+
0xff,
278+
0xfe,
279+
0xfd,
280+
]);
281+
const strings: string[] = [];
282+
xstr(binary, strings);
283+
expect(strings.length).toBe(0);
284+
});
285+
});
286+
287+
describe("setupCursorProxy", () => {
288+
it("calls runner.runServer for caddy install and proxy deployment", async () => {
289+
const runServerCalls: string[] = [];
290+
const runner = {
291+
runServer: mock(async (cmd: string) => {
292+
runServerCalls.push(cmd.slice(0, 50));
293+
}),
294+
uploadFile: mock(async () => {}),
295+
downloadFile: mock(async () => {}),
296+
};
297+
298+
const { setupCursorProxy: setup } = await import("../shared/cursor-proxy");
299+
await setup(runner);
300+
301+
// Should have called runServer multiple times (caddy install, deploy, hosts, trust)
302+
expect(runServerCalls.length).toBeGreaterThanOrEqual(3);
303+
// Should include caddy install check
304+
expect(runServerCalls.some((c) => c.includes("caddy"))).toBe(true);
305+
// Should include hosts configuration
306+
expect(runServerCalls.some((c) => c.includes("hosts") || c.includes("cursor.sh"))).toBe(true);
307+
});
308+
});
309+
310+
describe("startCursorProxy", () => {
311+
it("calls runner.runServer with port checks", async () => {
312+
const runServerCalls: string[] = [];
313+
const runner = {
314+
runServer: mock(async (cmd: string) => {
315+
runServerCalls.push(cmd);
316+
}),
317+
uploadFile: mock(async () => {}),
318+
downloadFile: mock(async () => {}),
319+
};
320+
321+
const { startCursorProxy: start } = await import("../shared/cursor-proxy");
322+
await start(runner);
323+
324+
// Should include port checks for 443, 18644, 18645
325+
const fullCmd = runServerCalls.join(" ");
326+
expect(fullCmd.includes("18644")).toBe(true);
327+
expect(fullCmd.includes("18645")).toBe(true);
328+
expect(fullCmd.includes("443")).toBe(true);
329+
});
330+
});

0 commit comments

Comments
 (0)