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
9 changes: 7 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions dist/auth.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export interface CursorAuthParams {
verifier: string;
challenge: string;
uuid: string;
loginUrl: string;
}
export interface CursorCredentials {
access: string;
refresh: string;
expires: number;
}
export declare function generateCursorAuthParams(): Promise<CursorAuthParams>;
export declare function pollCursorAuth(uuid: string, verifier: string): Promise<{
accessToken: string;
refreshToken: string;
}>;
export declare function refreshCursorToken(refreshToken: string): Promise<CursorCredentials>;
/**
* Extract JWT expiry with 5-minute safety margin.
* Falls back to 1 hour from now if token can't be parsed.
*/
export declare function getTokenExpiry(token: string): number;
92 changes: 92 additions & 0 deletions dist/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { generatePKCE } from "./pkce.js";
const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
const CURSOR_REFRESH_URL = process.env.CURSOR_REFRESH_URL ??
"https://api2.cursor.sh/auth/exchange_user_api_key";
const POLL_MAX_ATTEMPTS = 150;
const POLL_BASE_DELAY = 1000;
const POLL_MAX_DELAY = 10_000;
const POLL_BACKOFF_MULTIPLIER = 1.2;
export async function generateCursorAuthParams() {
const { verifier, challenge } = await generatePKCE();
const uuid = crypto.randomUUID();
const params = new URLSearchParams({
challenge,
uuid,
mode: "login",
redirectTarget: "cli",
});
const loginUrl = `${CURSOR_LOGIN_URL}?${params.toString()}`;
return { verifier, challenge, uuid, loginUrl };
}
export async function pollCursorAuth(uuid, verifier) {
let delay = POLL_BASE_DELAY;
let consecutiveErrors = 0;
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
await new Promise((resolve) => setTimeout(resolve, delay));
try {
const response = await fetch(`${CURSOR_POLL_URL}?uuid=${uuid}&verifier=${verifier}`);
if (response.status === 404) {
consecutiveErrors = 0;
delay = Math.min(delay * POLL_BACKOFF_MULTIPLIER, POLL_MAX_DELAY);
continue;
}
if (response.ok) {
const data = (await response.json());
return {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
};
}
throw new Error(`Poll failed: ${response.status}`);
}
catch {
consecutiveErrors++;
if (consecutiveErrors >= 3) {
throw new Error("Too many consecutive errors during Cursor auth polling");
}
}
}
throw new Error("Cursor authentication polling timeout");
}
export async function refreshCursorToken(refreshToken) {
const response = await fetch(CURSOR_REFRESH_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${refreshToken}`,
"Content-Type": "application/json",
},
body: "{}",
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Cursor token refresh failed: ${error}`);
}
const data = (await response.json());
return {
access: data.accessToken,
refresh: data.refreshToken || refreshToken,
expires: getTokenExpiry(data.accessToken),
};
}
/**
* Extract JWT expiry with 5-minute safety margin.
* Falls back to 1 hour from now if token can't be parsed.
*/
export function getTokenExpiry(token) {
try {
const parts = token.split(".");
if (parts.length !== 3 || !parts[1]) {
return Date.now() + 3600 * 1000;
}
const decoded = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));
if (decoded &&
typeof decoded === "object" &&
typeof decoded.exp === "number") {
return decoded.exp * 1000 - 5 * 60 * 1000;
}
}
catch {
}
return Date.now() + 3600 * 1000;
}
173 changes: 173 additions & 0 deletions dist/h2-bridge.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env node
/**
* Dumb HTTP/2 bidirectional pipe for Cursor gRPC.
*
* Bun's node:http2 is broken. This Node script acts as a transparent
* HTTP/2 proxy: it opens a single bidirectional stream and ferries
* raw bytes between the parent process (via stdin/stdout) and Cursor.
*
* Protocol (length-prefixed framing over stdin/stdout):
* [4 bytes big-endian length][payload]
*
* First message on stdin is JSON config:
* { "accessToken": "...", "url": "...", "path": "...", "unary": false }
*
* When unary=true, the bridge uses application/proto (raw protobuf) instead
* of application/connect+proto (Connect streaming). The single stdin message
* is written as the request body and the stream is ended immediately.
* After config, subsequent stdin messages are raw bytes to write to the H2 stream.
* H2 response data is written to stdout using the same length-prefixed framing.
*/
import http2 from "node:http2";
import crypto from "node:crypto";

const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";

/** Write one length-prefixed message to stdout. */
function writeMessage(data) {
const lenBuf = Buffer.alloc(4);
lenBuf.writeUInt32BE(data.length, 0);
process.stdout.write(lenBuf);
process.stdout.write(data);
}

// --- Buffered stdin reader ---

let stdinBuf = Buffer.alloc(0);
let stdinResolve = null;
let stdinEnded = false;

process.stdin.on("data", (chunk) => {
stdinBuf = Buffer.concat([stdinBuf, chunk]);
if (stdinResolve) {
const r = stdinResolve;
stdinResolve = null;
r();
}
});

process.stdin.on("end", () => {
stdinEnded = true;
if (stdinResolve) {
const r = stdinResolve;
stdinResolve = null;
r();
}
});

function waitForData() {
return new Promise((resolve) => { stdinResolve = resolve; });
}

async function readExact(n) {
while (stdinBuf.length < n) {
if (stdinEnded) return null;
await waitForData();
}
const result = stdinBuf.subarray(0, n);
stdinBuf = stdinBuf.subarray(n);
return Buffer.from(result);
}

async function readMessage() {
const lenBuf = await readExact(4);
if (!lenBuf) return null;
const len = lenBuf.readUInt32BE(0);
if (len === 0) return Buffer.alloc(0);
return readExact(len);
}

// --- Main ---

const configBuf = await readMessage();
if (!configBuf) process.exit(1);

const config = JSON.parse(configBuf.toString("utf8"));
const { accessToken, url, path: rpcPath, unary } = config;

const client = http2.connect(url || "https://api2.cursor.sh");

// Guard against initial connection failure. Reset on any h2 activity
// so long-running agent conversations (with tool call round-trips) survive.
let timeout = setTimeout(killBridge, 30_000);

function resetTimeout() {
clearTimeout(timeout);
timeout = setTimeout(killBridge, 120_000);
}

function killBridge() {
clearTimeout(timeout);
client.destroy();
process.exit(1);
}

client.on("error", () => {
clearTimeout(timeout);
process.exit(1);
});

const headers = {
":method": "POST",
":path": rpcPath || "/agent.v1.AgentService/Run",
"content-type": unary ? "application/proto" : "application/connect+proto",
te: "trailers",
authorization: `Bearer ${accessToken}`,
"x-ghost-mode": "true",
"x-cursor-client-version": CURSOR_CLIENT_VERSION,
"x-cursor-client-type": "cli",
"x-request-id": crypto.randomUUID(),
};
if (!unary) {
headers["connect-protocol-version"] = "1";
}
const h2Stream = client.request(headers);

// Forward H2 response data → stdout (length-prefixed)
h2Stream.on("data", (chunk) => {
resetTimeout();
writeMessage(chunk);
});

h2Stream.on("end", () => {
clearTimeout(timeout);
client.close();
// Give stdout time to flush
setTimeout(() => process.exit(0), 100);
});

h2Stream.on("error", () => {
clearTimeout(timeout);
client.close();
process.exit(1);
});

// Forward stdin → H2 stream (after config message)
if (unary) {
// Unary mode: read a single body message, write it, and end the stream.
const body = await readMessage();
if (body && body.length > 0 && !h2Stream.closed && !h2Stream.destroyed) {
h2Stream.end(body);
} else {
h2Stream.end();
}
} else {
// Streaming mode: forward all stdin messages as Connect frames.
(async () => {
while (true) {
const msg = await readMessage();
if (!msg || msg.length === 0) {
// EOF or zero-length = done writing
break;
}
if (!h2Stream.closed && !h2Stream.destroyed) {
resetTimeout();
h2Stream.write(msg);
}
}

if (!h2Stream.closed && !h2Stream.destroyed) {
h2Stream.end();
}
})();
}
23 changes: 23 additions & 0 deletions dist/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* OpenCode Cursor Auth Plugin
*
* Enables using Cursor models (Claude, GPT, etc.) inside OpenCode via:
* 1. Browser-based OAuth login to Cursor
* 2. Local proxy translating OpenAI format → Cursor gRPC protocol
*/
import type { Plugin } from "@opencode-ai/plugin";
/**
* OpenCode plugin that provides Cursor authentication and model access.
* Register in opencode.json: { "plugin": ["opencode-cursor-oauth"] }
*/
export declare const CursorAuthPlugin: Plugin;
/**
* Modern plugin module format: `{ id, server }`. Newer opencode loaders
* (including the Desktop app's sidecar) require this shape; the bare
* function default export was silently ignored there.
*/
export declare const CursorAuthPluginModule: {
id: string;
server: Plugin;
};
export default CursorAuthPluginModule;
Loading