mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-22 03:14:57 +00:00
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:
parent
ccd86005ce
commit
b9473f25b8
6 changed files with 791 additions and 63 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.27.6",
|
||||
"version": "0.28.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ describe("createCloudAgents", () => {
|
|||
expect([
|
||||
"minimal",
|
||||
"node",
|
||||
"bun",
|
||||
"full",
|
||||
]).toContain(agent.cloudInitTier);
|
||||
}
|
||||
|
|
|
|||
330
packages/cli/src/__tests__/cursor-proxy.test.ts
Normal file
330
packages/cli/src/__tests__/cursor-proxy.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
452
packages/cli/src/shared/cursor-proxy.ts
Normal file
452
packages/cli/src/shared/cursor-proxy.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue