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>
This commit is contained in:
A 2026-03-29 17:59:00 -07:00 committed by GitHub
parent ccd86005ce
commit b9473f25b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 791 additions and 63 deletions

View file

@ -306,16 +306,13 @@
]
},
"cursor": {
"disabled": true,
"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.",
"name": "Cursor CLI",
"description": "Cursor's terminal-based AI coding agent — autonomous coding with plan, agent, and ask modes",
"url": "https://cursor.com/cli",
"install": "curl https://cursor.com/install -fsS | bash",
"launch": "agent",
"env": {
"OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}",
"CURSOR_API_KEY": "${OPENROUTER_API_KEY}"
"OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}"
},
"config_files": {
"~/.cursor/cli-config.json": {
@ -332,7 +329,7 @@
}
}
},
"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.",
"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.",
"icon": "https://raw.githubusercontent.com/OpenRouterTeam/spawn/main/assets/agents/cursor.png",
"featured_cloud": [
"digitalocean",

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.27.6",
"version": "0.28.0",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -246,6 +246,7 @@ describe("createCloudAgents", () => {
expect([
"minimal",
"node",
"bun",
"full",
]).toContain(agent.cloudInitTier);
}

View file

@ -0,0 +1,330 @@
/**
* cursor-proxy.test.ts Tests for the Cursor CLI OpenRouter proxy.
* Covers: protobuf encoding, ConnectRPC framing, model details, deployment functions.
*/
import { describe, expect, it, mock } from "bun:test";
import { tryCatch } from "../shared/result";
// ── Protobuf helpers (mirrors the proxy script's functions) ─────────────────
function ev(v: number): Buffer {
const b: number[] = [];
while (v > 0x7f) {
b.push((v & 0x7f) | 0x80);
v >>>= 7;
}
b.push(v & 0x7f);
return Buffer.from(b);
}
function es(f: number, s: string): Buffer {
const sb = Buffer.from(s);
return Buffer.concat([
ev((f << 3) | 2),
ev(sb.length),
sb,
]);
}
function em(f: number, p: Buffer): Buffer {
return Buffer.concat([
ev((f << 3) | 2),
ev(p.length),
p,
]);
}
// ConnectRPC frame
function cf(p: Buffer): Buffer {
const f = Buffer.alloc(5 + p.length);
f[0] = 0x00;
f.writeUInt32BE(p.length, 1);
p.copy(f, 5);
return f;
}
// ConnectRPC trailer
function ct(): Buffer {
const j = Buffer.from("{}");
const t = Buffer.alloc(5 + j.length);
t[0] = 0x02;
t.writeUInt32BE(j.length, 1);
j.copy(t, 5);
return t;
}
// AgentServerMessage.InteractionUpdate.TextDeltaUpdate
function tdf(text: string): Buffer {
return cf(em(1, em(1, es(1, text))));
}
// AgentServerMessage.InteractionUpdate.TurnEndedUpdate
function tef(): Buffer {
return cf(
em(
1,
em(
14,
Buffer.from([
8,
10,
16,
5,
]),
),
),
);
}
// ModelDetails
function bmd(id: string, name: string): Buffer {
return Buffer.concat([
es(1, id),
es(3, id),
es(4, name),
es(5, name),
]);
}
// Extract strings from protobuf
function xstr(buf: Buffer, out: string[]): void {
let o = 0;
while (o < buf.length) {
let t = 0;
let s = 0;
while (o < buf.length) {
const b = buf[o++];
t |= (b & 0x7f) << s;
s += 7;
if (!(b & 0x80)) {
break;
}
}
const wt = t & 7;
if (wt === 0) {
while (o < buf.length && buf[o++] & 0x80) {
/* consume varint */
}
} else if (wt === 2) {
let len = 0;
let ls = 0;
while (o < buf.length) {
const b = buf[o++];
len |= (b & 0x7f) << ls;
ls += 7;
if (!(b & 0x80)) {
break;
}
}
const d = buf.slice(o, o + len);
o += len;
const st = d.toString("utf8");
if (/^[\x20-\x7e]+$/.test(st)) {
out.push(st);
} else {
const r = tryCatch(() => xstr(d, out));
if (!r.ok) {
/* ignore nested parse errors */
}
}
} else {
break;
}
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
describe("protobuf encoding", () => {
it("encodes varint correctly", () => {
expect(ev(0)).toEqual(
Buffer.from([
0,
]),
);
expect(ev(1)).toEqual(
Buffer.from([
1,
]),
);
expect(ev(127)).toEqual(
Buffer.from([
127,
]),
);
expect(ev(128)).toEqual(
Buffer.from([
0x80,
0x01,
]),
);
expect(ev(300)).toEqual(
Buffer.from([
0xac,
0x02,
]),
);
});
it("encodes string fields", () => {
const buf = es(1, "hello");
// field 1, wire type 2 (length-delimited) = tag 0x0a
expect(buf[0]).toBe(0x0a);
// length = 5
expect(buf[1]).toBe(5);
// string content
expect(buf.slice(2).toString("utf8")).toBe("hello");
});
it("encodes nested messages", () => {
const inner = es(1, "test");
const outer = em(2, inner);
// field 2, wire type 2 = tag 0x12
expect(outer[0]).toBe(0x12);
// length of inner message
expect(outer[1]).toBe(inner.length);
});
});
describe("ConnectRPC framing", () => {
it("wraps payload in a frame with 5-byte header", () => {
const payload = Buffer.from("test");
const frame = cf(payload);
expect(frame.length).toBe(5 + payload.length);
expect(frame[0]).toBe(0x00); // no compression
expect(frame.readUInt32BE(1)).toBe(payload.length);
expect(frame.slice(5).toString()).toBe("test");
});
it("creates a JSON trailer frame", () => {
const trailer = ct();
expect(trailer[0]).toBe(0x02); // JSON type
expect(trailer.readUInt32BE(1)).toBe(2); // length of "{}"
expect(trailer.slice(5).toString()).toBe("{}");
});
});
describe("AgentServerMessage encoding", () => {
it("encodes text delta update", () => {
const frame = tdf("Hello world");
// Should be a ConnectRPC frame (starts with 0x00)
expect(frame[0]).toBe(0x00);
// Payload should contain the text
const payload = frame.slice(5);
const strings: string[] = [];
xstr(payload, strings);
expect(strings).toContain("Hello world");
});
it("encodes turn ended update", () => {
const frame = tef();
expect(frame[0]).toBe(0x00);
// Payload should be non-empty (contains token counts)
const payloadLen = frame.readUInt32BE(1);
expect(payloadLen).toBeGreaterThan(0);
});
});
describe("ModelDetails encoding", () => {
it("encodes model with all required fields", () => {
const model = bmd("claude-4-sonnet", "Claude Sonnet 4");
const strings: string[] = [];
xstr(model, strings);
expect(strings).toContain("claude-4-sonnet");
expect(strings).toContain("Claude Sonnet 4");
});
it("encodes model list response", () => {
const models = [
[
"claude-4-sonnet",
"Claude 4",
],
[
"gpt-4o",
"GPT-4o",
],
];
const response = Buffer.concat(models.map(([id, name]) => em(1, bmd(id, name))));
const strings: string[] = [];
xstr(response, strings);
expect(strings).toContain("claude-4-sonnet");
expect(strings).toContain("gpt-4o");
});
});
describe("protobuf string extraction", () => {
it("extracts strings from nested protobuf", () => {
// Simulate a request with user message
const msg = em(
1,
Buffer.concat([
es(1, "say hello"),
es(2, "uuid-1234-5678"),
]),
);
const strings: string[] = [];
xstr(msg, strings);
expect(strings).toContain("say hello");
expect(strings).toContain("uuid-1234-5678");
});
it("skips binary data", () => {
const binary = Buffer.from([
0x0a,
0x03,
0xff,
0xfe,
0xfd,
]);
const strings: string[] = [];
xstr(binary, strings);
expect(strings.length).toBe(0);
});
});
describe("setupCursorProxy", () => {
it("calls runner.runServer for caddy install and proxy deployment", async () => {
const runServerCalls: string[] = [];
const runner = {
runServer: mock(async (cmd: string) => {
runServerCalls.push(cmd.slice(0, 50));
}),
uploadFile: mock(async () => {}),
downloadFile: mock(async () => {}),
};
const { setupCursorProxy: setup } = await import("../shared/cursor-proxy");
await setup(runner);
// Should have called runServer multiple times (caddy install, deploy, hosts, trust)
expect(runServerCalls.length).toBeGreaterThanOrEqual(3);
// Should include caddy install check
expect(runServerCalls.some((c) => c.includes("caddy"))).toBe(true);
// Should include hosts configuration
expect(runServerCalls.some((c) => c.includes("hosts") || c.includes("cursor.sh"))).toBe(true);
});
});
describe("startCursorProxy", () => {
it("calls runner.runServer with port checks", async () => {
const runServerCalls: string[] = [];
const runner = {
runServer: mock(async (cmd: string) => {
runServerCalls.push(cmd);
}),
uploadFile: mock(async () => {}),
downloadFile: mock(async () => {}),
};
const { startCursorProxy: start } = await import("../shared/cursor-proxy");
await start(runner);
// Should include port checks for 443, 18644, 18645
const fullCmd = runServerCalls.join(" ");
expect(fullCmd.includes("18644")).toBe(true);
expect(fullCmd.includes("18645")).toBe(true);
expect(fullCmd.includes("443")).toBe(true);
});
});

View file

@ -7,6 +7,7 @@ import type { Result } from "./ui.js";
import { unlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { getErrorMessage } from "@openrouter/spawn-shared";
import { setupCursorProxy, startCursorProxy } from "./cursor-proxy.js";
import { getTmpDir } from "./paths.js";
import { asyncTryCatch, asyncTryCatchIf, isOperationalError, tryCatchIf } from "./result.js";
import { validateRemotePath } from "./ssh.js";
@ -164,59 +165,6 @@ async function setupClaudeCodeConfig(runner: CloudRunner, apiKey: string): Promi
// ─── Cursor CLI Config ────────────────────────────────────────────────────────
async function setupCursorConfig(runner: CloudRunner, _apiKey: string): Promise<void> {
logStep("Configuring Cursor CLI for OpenRouter...");
const configJson = `{
"version": 1,
"permissions": {
"allow": ["Shell(*)", "Read(*)", "Write(*)", "WebFetch(*)", "Mcp(*)"],
"deny": []
}
}`;
// Spawn skill rule — injected into .cursor/rules/ so the agent knows
// it can provision child VMs via the spawn CLI.
const spawnRule = [
"---",
"description: Spawn CLI — provision child cloud VMs with AI coding agents",
"alwaysApply: true",
"---",
"",
"# Spawn — Create Child VMs",
"",
"You have the `spawn` CLI installed. Use it to provision cloud VMs with AI agents.",
"",
"## Usage",
"",
"```bash",
'spawn <agent> <cloud> --headless --output json --prompt "task description"',
"```",
"",
"## Agents: claude, codex, openclaw, zeroclaw, opencode, kilocode, hermes, junie",
"## Clouds: hetzner, digitalocean, aws, gcp, sprite",
"",
"The command returns JSON with connection details. Use this to delegate subtasks",
"to specialized agents running on separate cloud VMs.",
"",
].join("\n");
// Upload config files via SCP — avoids base64 interpolation into shell commands.
await uploadConfigFile(runner, configJson, "$HOME/.cursor/cli-config.json");
await uploadConfigFile(runner, spawnRule, "$HOME/.cursor/rules/spawn.mdc");
// Spawn rule should be world-readable (not sensitive)
await runner.runServer("chmod 644 ~/.cursor/rules/spawn.mdc");
// Persist PATH so agent binary is available (cursor installs to ~/.local/bin since 2026-03-25)
const pathScript = [
'grep -q ".local/bin" ~/.bashrc 2>/dev/null || printf \'\\nexport PATH="$HOME/.local/bin:$PATH"\\n\' >> ~/.bashrc',
'grep -q ".local/bin" ~/.zshrc 2>/dev/null || printf \'\\nexport PATH="$HOME/.local/bin:$PATH"\\n\' >> ~/.zshrc',
].join(" && ");
await runner.runServer(pathScript);
logInfo("Cursor CLI configured");
}
// ─── GitHub Auth ─────────────────────────────────────────────────────────────
let githubAuthRequested = false;
@ -1168,7 +1116,7 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
cursor: {
name: "Cursor CLI",
cloudInitTier: "minimal",
cloudInitTier: "bun",
preProvision: detectGithubAuth,
install: () =>
installAgent(
@ -1180,11 +1128,11 @@ function createAgents(runner: CloudRunner): Record<string, AgentConfig> {
),
envVars: (apiKey) => [
`OPENROUTER_API_KEY=${apiKey}`,
`CURSOR_API_KEY=${apiKey}`,
],
configure: (apiKey) => setupCursorConfig(runner, apiKey),
configure: () => setupCursorProxy(runner),
preLaunch: () => startCursorProxy(runner),
launchCmd: () =>
'source ~/.spawnrc 2>/dev/null; export PATH="$HOME/.local/bin:$PATH"; agent --endpoint https://openrouter.ai/api/v1',
'source ~/.spawnrc 2>/dev/null; export PATH="$HOME/.local/bin:$PATH"; agent --endpoint https://api2.cursor.sh --trust',
updateCmd: 'export PATH="$HOME/.local/bin:$PATH"; agent update',
},
};

View file

@ -0,0 +1,452 @@
// cursor-proxy.ts — OpenRouter proxy for Cursor CLI
// Deploys a local translation proxy that intercepts Cursor's proprietary
// ConnectRPC/protobuf protocol and translates it to OpenRouter's OpenAI-compatible API.
//
// Architecture:
// Cursor CLI → Caddy (HTTPS/H2, port 443) → split routing:
// /agent.v1.AgentService/* → H2C Node.js (port 18645, BiDi streaming)
// everything else → HTTP/1.1 Node.js (port 18644, unary RPCs)
//
// /etc/hosts spoofs api2.cursor.sh → 127.0.0.1 so Cursor's hardcoded
// streaming endpoint routes to the local proxy.
import type { CloudRunner } from "./agent-setup.js";
import { wrapSshCall } from "./agent-setup.js";
import { asyncTryCatchIf, isOperationalError } from "./result.js";
import { logInfo, logStep, logWarn } from "./ui.js";
// ── Protobuf helpers (used in proxy scripts) ────────────────────────────────
// These are string-embedded in the proxy scripts that run on the VM.
// They implement minimal protobuf encoding for the specific message types
// Cursor CLI expects: AgentServerMessage, ModelDetails, etc.
const PROTO_HELPERS = `
function ev(v){const b=[];while(v>0x7f){b.push((v&0x7f)|0x80);v>>>=7;}b.push(v&0x7f);return Buffer.from(b);}
function es(f,s){const sb=Buffer.from(s);return Buffer.concat([ev((f<<3)|2),ev(sb.length),sb]);}
function em(f,p){return Buffer.concat([ev((f<<3)|2),ev(p.length),p]);}
function cf(p){const f=Buffer.alloc(5+p.length);f[0]=0;f.writeUInt32BE(p.length,1);p.copy(f,5);return f;}
function ct(){const j=Buffer.from("{}");const t=Buffer.alloc(5+j.length);t[0]=2;t.writeUInt32BE(j.length,1);j.copy(t,5);return t;}
function tdf(t){return cf(em(1,em(1,es(1,t))));}
function tef(){return cf(em(1,em(14,Buffer.from([8,10,16,5]))));}
function bmd(id,n){return Buffer.concat([es(1,id),es(3,id),es(4,n),es(5,n)]);}
function bmr(){return Buffer.concat([["anthropic/claude-sonnet-4","Claude Sonnet 4"],["openai/gpt-4o","GPT-4o"],["google/gemini-2.5-flash","Gemini 2.5 Flash"]].map(([i,n])=>em(1,bmd(i,n))));}
function bdr(){return em(1,bmd("anthropic/claude-sonnet-4","Claude Sonnet 4"));}
function xstr(buf,out){let o=0;while(o<buf.length){let t=0,s=0;while(o<buf.length){const b=buf[o++];t|=(b&0x7f)<<s;s+=7;if(!(b&0x80))break;}const wt=t&7;if(wt===0){while(o<buf.length&&buf[o++]&0x80);}else if(wt===2){let l=0,s=0;while(o<buf.length){const b=buf[o++];l|=(b&0x7f)<<s;s+=7;if(!(b&0x80))break;}const d=buf.slice(o,o+l);o+=l;const st=d.toString("utf8");if(/^[\\x20-\\x7e]+$/.test(st))out.push(st);else try{xstr(d,out);}catch(e){}}else break;}}
`.trim();
// ── Unary backend (HTTP/1.1, port 18644) ─────────────────────────────────────
function getUnaryScript(): string {
return `import http from "node:http";
import { appendFileSync } from "node:fs";
const LOG="/var/log/cursor-proxy-unary.log";
function log(msg){try{appendFileSync(LOG,new Date().toISOString()+" "+msg+"\\n");}catch(e){}}
${PROTO_HELPERS}
const server = http.createServer((req, res) => {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("error", (e) => log("REQ ERR: " + e.message));
req.on("end", () => {
try {
const buf = Buffer.concat(chunks);
const ct = req.headers["content-type"] || "";
const url = req.url || "";
log(req.method + " " + url + " [" + buf.length + "B]");
// Auth — return fake JWT
if (url === "/auth/exchange_user_api_key") {
res.writeHead(200, {"content-type":"application/json"});
res.end(JSON.stringify({
accessToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzcGF3bl9wcm94eSJ9.ok",
refreshToken: "spawn-proxy-refresh",
authId: "user_spawn_proxy",
}));
return;
}
// Analytics — accept silently
if (url.includes("Analytics") || url.includes("TrackEvents") || url.includes("SubmitLogs")) {
res.writeHead(200, {"content-type":"application/json"});
res.end('{"success":true}');
return;
}
// Model list
if (url.includes("GetUsableModels")) {
res.writeHead(200, {"content-type":"application/proto"});
res.end(bmr());
return;
}
// Default model
if (url.includes("GetDefaultModelForCli")) {
res.writeHead(200, {"content-type":"application/proto"});
res.end(bdr());
return;
}
// OTEL traces
if (url.includes("/v1/traces")) {
res.writeHead(200, {"content-type":"application/json"});
res.end("{}");
return;
}
// Other proto endpoints — empty response
if (ct.includes("proto")) {
res.writeHead(200, {"content-type": ct.includes("connect") ? "application/connect+proto" : "application/proto"});
res.end();
return;
}
res.writeHead(200);
res.end("ok");
} catch(e) {
log("ERR: " + e.message);
try { res.writeHead(500); res.end(); } catch(e2) {}
}
});
});
server.on("error", (e) => log("SVR: " + e.message));
server.listen(18644, "127.0.0.1", () => log("Cursor proxy (unary) on 18644"));
`;
}
// ── BiDi backend (H2C, port 18645) ──────────────────────────────────────────
function getBidiScript(): string {
return `import http2 from "node:http2";
import { appendFileSync } from "node:fs";
const LOG="/var/log/cursor-proxy-bidi.log";
function log(msg){try{appendFileSync(LOG,new Date().toISOString()+" "+msg+"\\n");}catch(e){}}
${PROTO_HELPERS}
const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY || "";
const server = http2.createServer();
server.on("stream", (stream, headers) => {
const path = headers[":path"] || "";
log("STREAM " + path);
// BiDi: respond on first data frame, don't wait for stream end
let gotData = false;
stream.on("data", (chunk) => {
if (gotData) return;
gotData = true;
log(" Data [" + chunk.length + "B]");
// Extract user message from protobuf
let msg = "hello";
const strs = [];
try { xstr(chunk.length > 5 ? chunk.slice(5) : chunk, strs); } catch(e) {}
for (const s of strs) {
if (s.length > 0 && s.length < 500 && !s.match(/^[a-f0-9]{8}-/)) { msg = s; break; }
}
log(" User: " + msg);
stream.respond({":status": 200, "content-type": "application/connect+proto"});
if (OPENROUTER_KEY) {
callOpenRouter(msg, stream);
} else {
stream.write(tdf("Cursor proxy is working but OPENROUTER_API_KEY is not set. "));
stream.write(tdf("Please configure the API key to connect to real models."));
stream.write(tef());
stream.end(ct());
}
});
stream.on("error", (e) => {
if (!e.message.includes("cancel")) log(" STREAM ERR: " + e.message);
});
});
async function callOpenRouter(msg, stream) {
try {
const r = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": "Bearer " + OPENROUTER_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "openrouter/auto",
messages: [{ role: "user", content: msg }],
stream: true,
}),
});
if (!r.ok) {
const errText = await r.text().catch(() => "");
stream.write(tdf("OpenRouter error " + r.status + ": " + errText.slice(0, 200)));
stream.write(tef());
stream.end(ct());
return;
}
const reader = r.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\\n");
buf = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const json = JSON.parse(data);
const content = json.choices?.[0]?.delta?.content;
if (content) stream.write(tdf(content));
} catch(e) {}
}
}
stream.write(tef());
stream.end(ct());
log(" OpenRouter stream complete");
} catch(e) {
log(" OpenRouter error: " + e.message);
try {
stream.write(tdf("Proxy error: " + e.message));
stream.write(tef());
stream.end(ct());
} catch(e2) {}
}
}
server.on("error", (e) => log("SVR: " + e.message));
server.listen(18645, "127.0.0.1", () => log("Cursor proxy (bidi) on 18645"));
`;
}
// ── Caddyfile ───────────────────────────────────────────────────────────────
function getCaddyfile(): string {
return `{
\tlocal_certs
\tauto_https disable_redirects
}
https://api2.cursor.sh,
https://api2geo.cursor.sh,
https://api2direct.cursor.sh,
https://agentn.api5.cursor.sh,
https://agent.api5.cursor.sh {
\ttls internal
\thandle /agent.v1.AgentService/* {
\t\treverse_proxy h2c://127.0.0.1:18645 {
\t\t\tflush_interval -1
\t\t}
\t}
\thandle {
\t\treverse_proxy http://127.0.0.1:18644 {
\t\t\tflush_interval -1
\t\t}
\t}
}
`;
}
// ── Hosts entries ───────────────────────────────────────────────────────────
const CURSOR_DOMAINS = [
"api2.cursor.sh",
"api2geo.cursor.sh",
"api2direct.cursor.sh",
"agentn.api5.cursor.sh",
"agent.api5.cursor.sh",
];
// ── Deployment ──────────────────────────────────────────────────────────────
/**
* Deploy the Cursor proxy infrastructure onto the remote VM.
* Installs Caddy, uploads proxy scripts, writes Caddyfile, configures /etc/hosts.
*/
export async function setupCursorProxy(runner: CloudRunner): Promise<void> {
logStep("Deploying Cursor→OpenRouter proxy...");
// 1. Install Caddy if not present
const installCaddy = [
'if command -v caddy >/dev/null 2>&1; then echo "caddy already installed"; exit 0; fi',
'echo "Installing Caddy..."',
'curl -sf "https://caddyserver.com/api/download?os=linux&arch=amd64" -o /usr/local/bin/caddy',
"chmod +x /usr/local/bin/caddy",
"caddy version",
].join("\n");
const caddyResult = await asyncTryCatchIf(isOperationalError, () => wrapSshCall(runner.runServer(installCaddy, 60)));
if (!caddyResult.ok) {
logWarn("Caddy install failed — Cursor proxy will not work");
return;
}
logInfo("Caddy available");
// 2. Upload proxy scripts via base64
const unaryB64 = Buffer.from(getUnaryScript()).toString("base64");
const bidiB64 = Buffer.from(getBidiScript()).toString("base64");
const caddyfileB64 = Buffer.from(getCaddyfile()).toString("base64");
for (const b64 of [
unaryB64,
bidiB64,
caddyfileB64,
]) {
if (!/^[A-Za-z0-9+/=]+$/.test(b64)) {
throw new Error("Unexpected characters in base64 output");
}
}
const deployScript = [
"mkdir -p ~/.cursor/proxy",
`printf '%s' '${unaryB64}' | base64 -d > ~/.cursor/proxy/unary.mjs`,
`printf '%s' '${bidiB64}' | base64 -d > ~/.cursor/proxy/bidi.mjs`,
`printf '%s' '${caddyfileB64}' | base64 -d > ~/.cursor/proxy/Caddyfile`,
"chmod 600 ~/.cursor/proxy/*.mjs",
"chmod 644 ~/.cursor/proxy/Caddyfile",
].join(" && ");
await wrapSshCall(runner.runServer(deployScript));
logInfo("Proxy scripts deployed");
// 3. Configure /etc/hosts for domain spoofing
const hostsScript = [
// Remove any existing cursor entries
'sed -i "/cursor\\.sh/d" /etc/hosts 2>/dev/null || true',
// Add our entries
`echo "127.0.0.1 ${CURSOR_DOMAINS.join(" ")}" >> /etc/hosts`,
].join(" && ");
await wrapSshCall(runner.runServer(hostsScript));
logInfo("Hosts spoofing configured");
// 4. Install Caddy's internal CA cert
const trustScript = "caddy trust 2>/dev/null || true";
await wrapSshCall(runner.runServer(trustScript, 30));
logInfo("Caddy CA trusted");
// 5. Write Cursor CLI config (permissions + PATH)
const configScript = [
"mkdir -p ~/.cursor/rules",
`cat > ~/.cursor/cli-config.json << 'CONF'
{"version":1,"permissions":{"allow":["Shell(*)","Read(*)","Write(*)","WebFetch(*)","Mcp(*)"],"deny":[]}}
CONF`,
"chmod 600 ~/.cursor/cli-config.json",
'grep -q ".local/bin" ~/.bashrc 2>/dev/null || printf \'\\nexport PATH="$HOME/.local/bin:$PATH"\\n\' >> ~/.bashrc',
'grep -q ".local/bin" ~/.zshrc 2>/dev/null || printf \'\\nexport PATH="$HOME/.local/bin:$PATH"\\n\' >> ~/.zshrc',
].join(" && ");
await wrapSshCall(runner.runServer(configScript));
logInfo("Cursor CLI configured");
}
/**
* Start the Cursor proxy services (Caddy + two Node.js backends).
* Uses systemd if available, falls back to setsid/nohup.
*/
export async function startCursorProxy(runner: CloudRunner): Promise<void> {
logStep("Starting Cursor proxy services...");
// Find Node.js binary (cursor bundles its own)
const nodeFind =
"NODE=$(find ~/.local/share/cursor-agent -name node -type f 2>/dev/null | head -1); " +
'[ -z "$NODE" ] && NODE=$(command -v node); ' +
'echo "Using node: $NODE"';
// Port check (same pattern as startGateway)
const portCheck = (port: number) =>
`ss -tln 2>/dev/null | grep -q ":${port} " || nc -z 127.0.0.1 ${port} 2>/dev/null`;
const script = [
"source ~/.spawnrc 2>/dev/null",
nodeFind,
// Start unary backend
`if ${portCheck(18644)}; then echo "Unary backend already running"; else`,
" if command -v systemctl >/dev/null 2>&1; then",
' _sudo=""; [ "$(id -u)" != "0" ] && _sudo="sudo"',
" cat > /tmp/cursor-proxy-unary.service << UNIT",
"[Unit]",
"Description=Cursor Proxy (unary)",
"After=network.target",
"[Service]",
"Type=simple",
"ExecStart=$NODE $HOME/.cursor/proxy/unary.mjs",
"Restart=always",
"RestartSec=3",
"User=$(whoami)",
"Environment=HOME=$HOME",
"Environment=PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin",
"[Install]",
"WantedBy=multi-user.target",
"UNIT",
" $_sudo mv /tmp/cursor-proxy-unary.service /etc/systemd/system/",
" $_sudo systemctl daemon-reload",
" $_sudo systemctl restart cursor-proxy-unary",
" else",
" setsid $NODE ~/.cursor/proxy/unary.mjs < /dev/null &",
" fi",
"fi",
// Start bidi backend
`if ${portCheck(18645)}; then echo "BiDi backend already running"; else`,
" if command -v systemctl >/dev/null 2>&1; then",
' _sudo=""; [ "$(id -u)" != "0" ] && _sudo="sudo"',
" cat > /tmp/cursor-proxy-bidi.service << UNIT",
"[Unit]",
"Description=Cursor Proxy (bidi)",
"After=network.target",
"[Service]",
"Type=simple",
"ExecStart=$NODE $HOME/.cursor/proxy/bidi.mjs",
"Restart=always",
"RestartSec=3",
"User=$(whoami)",
"Environment=HOME=$HOME",
'Environment=OPENROUTER_API_KEY=$(grep OPENROUTER_API_KEY ~/.spawnrc 2>/dev/null | head -1 | cut -d= -f2- | tr -d "\'")',
"Environment=PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin",
"[Install]",
"WantedBy=multi-user.target",
"UNIT",
" $_sudo mv /tmp/cursor-proxy-bidi.service /etc/systemd/system/",
" $_sudo systemctl daemon-reload",
" $_sudo systemctl restart cursor-proxy-bidi",
" else",
" setsid $NODE ~/.cursor/proxy/bidi.mjs < /dev/null &",
" fi",
"fi",
// Start Caddy
`if ${portCheck(443)}; then echo "Caddy already running"; else`,
" caddy start --config ~/.cursor/proxy/Caddyfile --adapter caddyfile 2>/dev/null || true",
"fi",
// Wait for all services
"elapsed=0; while [ $elapsed -lt 30 ]; do",
` if ${portCheck(443)} && ${portCheck(18644)} && ${portCheck(18645)}; then`,
' echo "Cursor proxy ready after ${elapsed}s"',
" exit 0",
" fi",
" sleep 1; elapsed=$((elapsed + 1))",
"done",
'echo "Cursor proxy failed to start"; exit 1',
].join("\n");
const result = await asyncTryCatchIf(isOperationalError, () => wrapSshCall(runner.runServer(script, 60)));
if (result.ok) {
logInfo("Cursor proxy started");
} else {
logWarn("Cursor proxy start failed — agent may not work");
}
}