diff --git a/manifest.json b/manifest.json index ac588738..0576952b 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index be1e999f..19541146 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.27.6", + "version": "0.28.0", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/agent-setup-cov.test.ts b/packages/cli/src/__tests__/agent-setup-cov.test.ts index 2eeb734a..6e6f0c73 100644 --- a/packages/cli/src/__tests__/agent-setup-cov.test.ts +++ b/packages/cli/src/__tests__/agent-setup-cov.test.ts @@ -246,6 +246,7 @@ describe("createCloudAgents", () => { expect([ "minimal", "node", + "bun", "full", ]).toContain(agent.cloudInitTier); } diff --git a/packages/cli/src/__tests__/cursor-proxy.test.ts b/packages/cli/src/__tests__/cursor-proxy.test.ts new file mode 100644 index 00000000..f13b8ffd --- /dev/null +++ b/packages/cli/src/__tests__/cursor-proxy.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index a7ed108b..8954a8e3 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -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 { - 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 --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 { cursor: { name: "Cursor CLI", - cloudInitTier: "minimal", + cloudInitTier: "bun", preProvision: detectGithubAuth, install: () => installAgent( @@ -1180,11 +1128,11 @@ function createAgents(runner: CloudRunner): Record { ), 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', }, }; diff --git a/packages/cli/src/shared/cursor-proxy.ts b/packages/cli/src/shared/cursor-proxy.ts new file mode 100644 index 00000000..0d23f892 --- /dev/null +++ b/packages/cli/src/shared/cursor-proxy.ts @@ -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 { + 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 { + 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 { + 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"); + } +}