Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
node_modules
dist
.wrangler
.git
.dev.vars
.env
.env.*
!docker/env.example
!.env.example
*.log
.DS_Store
macos
sample-projects
agent-transcripts
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ import OpenAI from "openai";

const client = new OpenAI({
apiKey: "local",
baseURL: "http://127.0.0.1:8787/v1"
baseURL: "http://127.0.0.1:8787/v1",
});

const completion = await client.chat.completions.create({
model: "composer-2.5",
messages: [{ role: "user", content: "Write a TypeScript debounce." }]
messages: [{ role: "user", content: "Write a TypeScript debounce." }],
});
```

Expand Down Expand Up @@ -120,6 +120,20 @@ The bridge process also accepts `CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS`; the default
Release packages prefer a bundled Bun runtime for the local SDK bridge and fall
back to Node when Bun is unavailable.

## Self-hosted Docker (Linux)

For a headless Linux host, use Docker Compose to run a plain Node API server plus the SDK bridge (no Cloudflare runtime in the container):

```bash
cp docker/env.example .env
docker compose up --build
```

For development, use `docker compose up --watch` to sync `worker/` and bridge
scripts into the running containers. See [docs/deploy-self-hosted.md](docs/deploy-self-hosted.md).

The API listens on `http://127.0.0.1:8787/v1` by default. See [docs/deploy-self-hosted.md](docs/deploy-self-hosted.md) for configuration, security notes, and limitations compared to the macOS app.

## Cloudflare

The Worker uses Cloudflare Vite and D1.
Expand Down
13 changes: 11 additions & 2 deletions containers/cursor-sdk-bridge/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
FROM node:22-alpine
FROM node:24-bookworm-slim

WORKDIR /app

# Non-Composer models use the full Cursor SDK agent path, which spawns /bin/sh for
# tools like `date`. Alpine's minimal image and USER node often break that path.
RUN apt-get update \
&& apt-get install -y --no-install-recommends bash ca-certificates coreutils git \
&& rm -rf /var/lib/apt/lists/*

ENV NODE_ENV=production
ENV SHELL=/bin/bash
ENV CURSOR_SDK_BRIDGE_HOST=0.0.0.0
ENV CURSOR_SDK_BRIDGE_PORT=8792

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY scripts/cursor-sdk-local-agent-bridge.mjs ./scripts/cursor-sdk-local-agent-bridge.mjs
COPY scripts/composer-tool-markers.mjs ./scripts/composer-tool-markers.mjs

EXPOSE 8792

USER node
HEALTHCHECK --interval=10s --timeout=5s --retries=5 --start-period=15s \
CMD node -e "fetch('http://127.0.0.1:8792/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

CMD ["node", "scripts/cursor-sdk-local-agent-bridge.mjs"]
78 changes: 78 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
services:
bridge:
container_name: bridge
build:
context: .
dockerfile: containers/cursor-sdk-bridge/Dockerfile
develop:
watch:
- action: sync+restart
path: ./scripts/cursor-sdk-local-agent-bridge.mjs
target: /app/scripts/cursor-sdk-local-agent-bridge.mjs
- action: sync+restart
path: ./scripts/composer-tool-markers.mjs
target: /app/scripts/composer-tool-markers.mjs
- action: rebuild
path: ./package.json
- action: rebuild
path: ./package-lock.json
environment:
CURSOR_SDK_BRIDGE_TOKEN: ${CURSOR_SDK_BRIDGE_TOKEN:-}
CURSOR_SDK_WORKING_DIRECTORY: /workspace
CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS: ${CURSOR_SDK_BRIDGE_RUN_TIMEOUT_MS:-180000}
CURSOR_SDK_BRIDGE_MAX_JSON_BYTES: ${CURSOR_SDK_BRIDGE_MAX_JSON_BYTES:-16777216}
volumes:
- ${WORKSPACE:-.}:/workspace:rw
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8792/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
networks:
- composer-api
restart: always

api:
container_name: api
build:
context: .
dockerfile: docker/Dockerfile.api
develop:
watch:
- action: sync+restart
path: ./worker
target: /app/worker
- action: sync+restart
path: ./docker
target: /app/docker
- action: sync+restart
path: ./scripts/composer-tool-markers.mjs
target: /app/scripts/composer-tool-markers.mjs
- action: rebuild
path: ./package.json
- action: rebuild
path: ./package-lock.json
ports:
- "${CURSOR_API_PORT:-8787}:8787"
environment:
CURSOR_SDK_BRIDGE_URL: http://bridge:8792/sdk
CURSOR_SDK_BRIDGE_TOKEN: ${CURSOR_SDK_BRIDGE_TOKEN:-}
CURSOR_API_BASE: ${CURSOR_API_BASE:-https://api.cursor.com}
CURSOR_CLIENT_VERSION: ${CURSOR_CLIENT_VERSION:-2.6.22}
CURSOR_SDK_CLIENT_VERSION: ${CURSOR_SDK_CLIENT_VERSION:-sdk-1.0.13}
CURSOR_SDK_BRIDGE_TIMEOUT_MS: ${CURSOR_SDK_BRIDGE_TIMEOUT_MS:-180000}
depends_on:
bridge:
condition: service_healthy
networks:
- composer-api
restart: always
networks:
composer-api:
17 changes: 17 additions & 0 deletions docker/Dockerfile.all-in-one
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM node:24-alpine

WORKDIR /app

RUN apk add --no-cache tini

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

RUN chmod +x docker/entrypoint-api.sh docker/entrypoint-all-in-one.sh

EXPOSE 8787 8792

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["docker/entrypoint-all-in-one.sh"]
17 changes: 17 additions & 0 deletions docker/Dockerfile.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FROM node:24-alpine

WORKDIR /app

RUN apk add --no-cache tini

COPY package.json package-lock.json ./
RUN npm ci

COPY . .

RUN chmod +x docker/entrypoint-api.sh

EXPOSE 8787

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["docker/entrypoint-api.sh"]
173 changes: 173 additions & 0 deletions docker/api-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import "./register-cloudflare-mock.mjs";
import http from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";
import type { Env } from "../worker/types.js";

function parsePort(value: string | undefined, fallback: number): number {
const parsed = Number.parseInt(value || "", 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

async function loadRuntime() {
const [{ handleRequest, defaultDeps }, { FakeD1, fakeCtx }] =
await Promise.all([
import("../worker/index.ts"),
import("../worker/test-helpers.ts"),
]);
return { handleRequest, defaultDeps, FakeD1, fakeCtx };
}

export function createSelfHostedEnv(
FakeD1: typeof import("../worker/test-helpers.ts").FakeD1,
): Env {
const bridgeUrl = process.env.CURSOR_SDK_BRIDGE_URL?.trim();
if (!bridgeUrl) {
throw new Error(
"CURSOR_SDK_BRIDGE_URL is required (for example http://bridge:8792/sdk)",
);
}

return {
DB: new FakeD1() as unknown as D1Database,
ASSETS: {
fetch: async () => new Response("Not Found", { status: 404 }),
} as unknown as Fetcher,
CURSOR_API_BASE:
process.env.CURSOR_API_BASE?.trim() || "https://api.cursor.com",
CURSOR_CLIENT_VERSION:
process.env.CURSOR_CLIENT_VERSION?.trim() || "2.6.22",
CURSOR_SDK_CLIENT_VERSION:
process.env.CURSOR_SDK_CLIENT_VERSION?.trim() || "sdk-1.0.13",
CURSOR_SDK_BRIDGE_URL: bridgeUrl,
CURSOR_SDK_BRIDGE_TOKEN: process.env.CURSOR_SDK_BRIDGE_TOKEN,
CURSOR_SDK_BRIDGE_TIMEOUT_MS: process.env.CURSOR_SDK_BRIDGE_TIMEOUT_MS,
CURSOR_BACKEND_BASE_URL: process.env.CURSOR_BACKEND_BASE_URL?.trim(),
CURSOR_CHAT_ENDPOINT: process.env.CURSOR_CHAT_ENDPOINT?.trim(),
CURSOR_LOCAL_AGENT_ENDPOINT:
process.env.CURSOR_LOCAL_AGENT_ENDPOINT?.trim(),
};
}

function healthResponse(): Response {
return Response.json({
ok: true,
service: "api-for-cursor-self-hosted",
bridgeConfigured: Boolean(process.env.CURSOR_SDK_BRIDGE_URL?.trim()),
});
}

async function readRequestBody(request: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks);
}

function nodeRequestToWeb(
request: IncomingMessage,
body: Buffer,
origin: string,
): Request {
const url = new URL(request.url || "/", origin);
const headers = new Headers();
for (const [key, value] of Object.entries(request.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const item of value) headers.append(key, item);
} else {
headers.set(key, value);
}
}

const method = request.method || "GET";
const init: RequestInit = { method, headers };
if (method !== "GET" && method !== "HEAD" && body.length > 0) {
init.body = new Uint8Array(body);
}

return new Request(url.toString(), init);
}

async function writeWebResponse(
response: Response,
res: ServerResponse,
): Promise<void> {
res.statusCode = response.status;
response.headers.forEach((value, key) => {
if (key.toLowerCase() === "transfer-encoding") return;
res.setHeader(key, value);
});

if (!response.body) {
res.end();
return;
}

const nodeStream = Readable.fromWeb(
response.body as ReadableStream<Uint8Array>,
);
for await (const chunk of nodeStream) {
res.write(chunk);
}
res.end();
}

export async function startApiServer(): Promise<http.Server> {
const { handleRequest, defaultDeps, FakeD1, fakeCtx } = await loadRuntime();
const env = createSelfHostedEnv(FakeD1);
const ctx = fakeCtx();
const host = process.env.CURSOR_API_HOST?.trim() || "0.0.0.0";
const port = parsePort(process.env.CURSOR_API_PORT, 8787);
const origin = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;

const server = http.createServer(async (req, res) => {
try {
const pathname = new URL(req.url || "/", origin).pathname;
if (req.method === "GET" && pathname === "/health") {
await writeWebResponse(healthResponse(), res);
return;
}

const body = await readRequestBody(req);
const webRequest = nodeRequestToWeb(req, body, origin);
const webResponse = await handleRequest(
webRequest,
env,
ctx,
defaultDeps,
);
await writeWebResponse(webResponse, res);
} catch (error) {
const message =
error instanceof Error ? error.message : "Internal Server Error";
if (!res.headersSent) {
res.statusCode = 500;
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ error: { message, type: "server_error" } }));
return;
}
res.end();
}
});

await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => resolve());
});

console.log(
`API for Cursor self-hosted server listening on http://${host}:${port}/v1`,
);
return server;
}

const isMainModule = process.argv[1] === fileURLToPath(import.meta.url);
if (isMainModule) {
startApiServer().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
}
24 changes: 24 additions & 0 deletions docker/entrypoint-all-in-one.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/bin/sh
set -eu

export CURSOR_SDK_BRIDGE_HOST="${CURSOR_SDK_BRIDGE_HOST:-0.0.0.0}"
export CURSOR_SDK_BRIDGE_PORT="${CURSOR_SDK_BRIDGE_PORT:-8792}"
export CURSOR_SDK_BRIDGE_URL="${CURSOR_SDK_BRIDGE_URL:-http://127.0.0.1:${CURSOR_SDK_BRIDGE_PORT}/sdk}"

node scripts/cursor-sdk-local-agent-bridge.mjs &
BRIDGE_PID=$!

for _ in $(seq 1 60); do
if node -e "fetch('http://127.0.0.1:${CURSOR_SDK_BRIDGE_PORT}/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"; then
break
fi
if ! kill -0 "$BRIDGE_PID" 2>/dev/null; then
echo "Cursor SDK bridge exited before becoming healthy." >&2
exit 1
fi
sleep 0.5
done

trap 'kill "$BRIDGE_PID" 2>/dev/null || true' EXIT INT TERM

exec docker/entrypoint-api.sh
Loading